From 9a011287d3286c3aebb3b4a2c0fe82be469de82a Mon Sep 17 00:00:00 2001 From: Christian Lopez-Aguila Date: Fri, 22 May 2026 14:14:13 -0700 Subject: [PATCH] Thermo-nuclear decomposition: eliminate 1k+ line monoliths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five parallel thermo-nuclear audits decomposed Library.jsx (3413→735 max), App.jsx (1584→289), SidePanel.jsx (1364→193), backend library.js (1825→558 max), and pipeline.js (1127→487 max). Removed dead duplicate backend modules, extracted hooks/components/services, and added thermo-nuclear Cursor agent skill. Co-authored-by: Cursor --- .../thermo-nuclear-code-quality-review.md | 12 + .../SKILL.md | 52 + .env.example | 33 +- .github/workflows/ci.yml | 45 +- .gitignore | 3 +- CONTRIBUTING.md | 2 +- README.md | 215 +- SETUP.md | 282 +- backend/Dockerfile | 12 +- backend/eslint.config.js | 23 + backend/package-lock.json | 2878 ++++++++++++++++- backend/package.json | 16 +- backend/server.js | 19 +- backend/src/config.js | 136 +- backend/src/errors.js | 57 +- backend/src/library.js | 1476 +-------- backend/src/library/arrConfig.js | 11 + backend/src/library/cacheRefresh.js | 147 + .../src/library/helpers/metadataProfile.js | 41 + backend/src/library/helpers/musicTitle.js | 66 + backend/src/library/helpers/watchSearch.js | 87 + backend/src/library/index.js | 1 + backend/src/library/routes/add.js | 558 ++++ backend/src/library/routes/cacheSearch.js | 38 + backend/src/library/routes/delete.js | 141 + backend/src/library/routes/lookup.js | 274 ++ backend/src/library/routes/movie.js | 39 + backend/src/library/routes/music.js | 306 ++ backend/src/library/routes/profiles.js | 89 + backend/src/library/routes/tv.js | 92 + backend/src/middleware.js | 94 +- backend/src/pipeline.js | 868 +---- backend/src/pipeline/bootstrap.js | 7 + backend/src/pipeline/constants.js | 16 + backend/src/pipeline/grab.js | 29 + backend/src/pipeline/handlers.js | 487 +++ backend/src/pipeline/index.js | 6 + backend/src/pipeline/pendingSearches.js | 2 + backend/src/pipeline/queueMonitor.js | 292 ++ backend/src/pipeline/searchWatchers.js | 237 ++ backend/src/pipeline/stuckCheck.js | 39 + backend/src/pipeline/utils.js | 49 + backend/src/routes/activity.js | 21 +- backend/src/routes/health.js | 391 +-- backend/src/routes/index.js | 2 - backend/src/routes/library.js | 1606 +-------- backend/src/routes/media.js | 221 +- backend/src/routes/pipeline.js | 1456 +-------- backend/src/routes/qbittorrent.js | 320 +- backend/src/routes/search.js | 161 +- backend/src/routes/slskd.js | 118 +- backend/src/state.js | 305 +- backend/src/utils.js | 158 +- backend/test/routes.test.js | 52 + backend/test/utils.test.js | 31 +- docker-compose.yml | 15 +- frontend/Dockerfile | 12 +- frontend/eslint.config.js | 37 + frontend/index.html | 9 +- frontend/nginx.conf | 22 - frontend/package-lock.json | 1939 ++++++++++- frontend/package.json | 15 +- frontend/src/ActivityLog.jsx | 514 ++- frontend/src/App.css | 307 +- frontend/src/App.jsx | 2120 ++---------- frontend/src/Library.jsx | 2631 +-------------- frontend/src/ManualSearchModal.jsx | 511 ++- frontend/src/PipelineCard.jsx | 628 +++- frontend/src/SidePanel.jsx | 973 +----- frontend/src/TorrentTable.jsx | 785 +---- frontend/src/api.js | 277 +- frontend/src/components/app/AppHeader.jsx | 244 ++ frontend/src/components/app/AppIcons.jsx | 100 + frontend/src/components/app/AppNavRail.jsx | 80 + frontend/src/components/app/ArrQueueCard.jsx | 130 + frontend/src/components/app/ContainerChip.jsx | 62 + frontend/src/components/app/DownloadsView.jsx | 197 ++ .../src/components/app/LoadingSkeleton.jsx | 22 + .../src/components/app/MobileBottomNav.jsx | 106 + frontend/src/components/app/RailItem.jsx | 69 + frontend/src/components/app/ServiceStrip.jsx | 42 + .../src/components/sidePanel/ActivityRow.jsx | 126 + .../components/sidePanel/DownloadMoreMenu.jsx | 90 + .../src/components/sidePanel/IconButton.jsx | 35 + .../src/components/sidePanel/PanelBody.jsx | 345 ++ .../components/sidePanel/QbDownloadItem.jsx | 203 ++ .../sidePanel/SlskdDownloadItem.jsx | 146 + .../src/components/sidePanel/Sparkline.jsx | 38 + frontend/src/components/sidePanel/Thumb.jsx | 61 + .../src/components/sidePanel/constants.js | 25 + .../torrent/TorrentDownloadCards.jsx | 767 +++++ .../src/components/torrent/torrentGrouping.js | 46 + frontend/src/constants.js | 128 +- frontend/src/hooks/useAppData.js | 173 + frontend/src/hooks/useBandwidth.js | 30 + frontend/src/hooks/useLayoutPrefs.js | 90 + frontend/src/hooks/useManualSearch.js | 39 + frontend/src/hooks/useMobilePanelFocus.js | 31 + frontend/src/hooks/useSidePanelData.js | 69 + frontend/src/index.css | 146 +- frontend/src/library/LibraryView.jsx | 97 + frontend/src/library/components/AddPanel.jsx | 497 +++ .../src/library/components/AddResults.jsx | 124 + .../src/library/components/AlbumSelector.jsx | 41 + .../src/library/components/ArtistDetail.jsx | 735 +++++ .../src/library/components/LibraryGrid.jsx | 123 + .../src/library/components/LibraryHeader.jsx | 140 + .../library/components/ManualSearchView.jsx | 361 +++ .../library/components/MovieDownloadPanel.jsx | 245 ++ frontend/src/library/components/PosterImg.jsx | 32 + .../src/library/components/SeasonSelector.jsx | 39 + frontend/src/library/components/Select.jsx | 18 + .../src/library/components/SeriesDetail.jsx | 478 +++ .../library/components/cards/ArtistCard.jsx | 62 + .../library/components/cards/MovieCard.jsx | 94 + .../library/components/cards/ResultCard.jsx | 73 + .../library/components/cards/SeriesCard.jsx | 104 + frontend/src/library/constants.js | 13 + .../src/library/hooks/useAnimatedClose.js | 12 + frontend/src/library/hooks/useLibraryState.js | 218 ++ .../src/library/utils/releaseDetection.js | 26 + frontend/src/main.jsx | 2 +- frontend/src/test/utils.test.js | 2 +- frontend/src/utils.js | 91 +- frontend/tailwind.config.js | 82 +- frontend/vite.config.js | 29 +- install.sh | 621 +--- 127 files changed, 18106 insertions(+), 14807 deletions(-) create mode 100644 .cursor/agents/thermo-nuclear-code-quality-review.md create mode 100644 .cursor/skills/thermo-nuclear-code-quality-review/SKILL.md create mode 100644 backend/eslint.config.js create mode 100644 backend/src/library/arrConfig.js create mode 100644 backend/src/library/cacheRefresh.js create mode 100644 backend/src/library/helpers/metadataProfile.js create mode 100644 backend/src/library/helpers/musicTitle.js create mode 100644 backend/src/library/helpers/watchSearch.js create mode 100644 backend/src/library/index.js create mode 100644 backend/src/library/routes/add.js create mode 100644 backend/src/library/routes/cacheSearch.js create mode 100644 backend/src/library/routes/delete.js create mode 100644 backend/src/library/routes/lookup.js create mode 100644 backend/src/library/routes/movie.js create mode 100644 backend/src/library/routes/music.js create mode 100644 backend/src/library/routes/profiles.js create mode 100644 backend/src/library/routes/tv.js create mode 100644 backend/src/pipeline/bootstrap.js create mode 100644 backend/src/pipeline/constants.js create mode 100644 backend/src/pipeline/grab.js create mode 100644 backend/src/pipeline/handlers.js create mode 100644 backend/src/pipeline/index.js create mode 100644 backend/src/pipeline/pendingSearches.js create mode 100644 backend/src/pipeline/queueMonitor.js create mode 100644 backend/src/pipeline/searchWatchers.js create mode 100644 backend/src/pipeline/stuckCheck.js create mode 100644 backend/src/pipeline/utils.js create mode 100644 backend/test/routes.test.js create mode 100644 frontend/eslint.config.js create mode 100644 frontend/src/components/app/AppHeader.jsx create mode 100644 frontend/src/components/app/AppIcons.jsx create mode 100644 frontend/src/components/app/AppNavRail.jsx create mode 100644 frontend/src/components/app/ArrQueueCard.jsx create mode 100644 frontend/src/components/app/ContainerChip.jsx create mode 100644 frontend/src/components/app/DownloadsView.jsx create mode 100644 frontend/src/components/app/LoadingSkeleton.jsx create mode 100644 frontend/src/components/app/MobileBottomNav.jsx create mode 100644 frontend/src/components/app/RailItem.jsx create mode 100644 frontend/src/components/app/ServiceStrip.jsx create mode 100644 frontend/src/components/sidePanel/ActivityRow.jsx create mode 100644 frontend/src/components/sidePanel/DownloadMoreMenu.jsx create mode 100644 frontend/src/components/sidePanel/IconButton.jsx create mode 100644 frontend/src/components/sidePanel/PanelBody.jsx create mode 100644 frontend/src/components/sidePanel/QbDownloadItem.jsx create mode 100644 frontend/src/components/sidePanel/SlskdDownloadItem.jsx create mode 100644 frontend/src/components/sidePanel/Sparkline.jsx create mode 100644 frontend/src/components/sidePanel/Thumb.jsx create mode 100644 frontend/src/components/sidePanel/constants.js create mode 100644 frontend/src/components/torrent/TorrentDownloadCards.jsx create mode 100644 frontend/src/components/torrent/torrentGrouping.js create mode 100644 frontend/src/hooks/useAppData.js create mode 100644 frontend/src/hooks/useBandwidth.js create mode 100644 frontend/src/hooks/useLayoutPrefs.js create mode 100644 frontend/src/hooks/useManualSearch.js create mode 100644 frontend/src/hooks/useMobilePanelFocus.js create mode 100644 frontend/src/hooks/useSidePanelData.js create mode 100644 frontend/src/library/LibraryView.jsx create mode 100644 frontend/src/library/components/AddPanel.jsx create mode 100644 frontend/src/library/components/AddResults.jsx create mode 100644 frontend/src/library/components/AlbumSelector.jsx create mode 100644 frontend/src/library/components/ArtistDetail.jsx create mode 100644 frontend/src/library/components/LibraryGrid.jsx create mode 100644 frontend/src/library/components/LibraryHeader.jsx create mode 100644 frontend/src/library/components/ManualSearchView.jsx create mode 100644 frontend/src/library/components/MovieDownloadPanel.jsx create mode 100644 frontend/src/library/components/PosterImg.jsx create mode 100644 frontend/src/library/components/SeasonSelector.jsx create mode 100644 frontend/src/library/components/Select.jsx create mode 100644 frontend/src/library/components/SeriesDetail.jsx create mode 100644 frontend/src/library/components/cards/ArtistCard.jsx create mode 100644 frontend/src/library/components/cards/MovieCard.jsx create mode 100644 frontend/src/library/components/cards/ResultCard.jsx create mode 100644 frontend/src/library/components/cards/SeriesCard.jsx create mode 100644 frontend/src/library/constants.js create mode 100644 frontend/src/library/hooks/useAnimatedClose.js create mode 100644 frontend/src/library/hooks/useLibraryState.js create mode 100644 frontend/src/library/utils/releaseDetection.js 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. +![screenshot](https://via.placeholder.com/800x450/1a1a2e/e0e0e0?text=arr-dashboard) -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 @@ - + - vibarr + Icarus - + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e1399de..2549f03 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -3,8 +3,6 @@ server { server_name localhost; root /usr/share/nginx/html; index index.html; - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log notice; gzip on; gzip_vary on; @@ -16,22 +14,6 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; - location ^~ /api/setup/ { - proxy_pass http://backend:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 15s; - proxy_send_timeout 600s; - proxy_read_timeout 600s; - send_timeout 600s; - } - location /api/ { proxy_pass http://backend:3000; proxy_http_version 1.1; @@ -42,10 +24,6 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_connect_timeout 15s; - proxy_send_timeout 120s; - proxy_read_timeout 120s; - send_timeout 120s; } location / { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10eaabb..0d3d5b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,25 +1,30 @@ { - "name": "vibarr-frontend", + "name": "arr-dashboard-frontend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vibarr-frontend", + "name": "arr-dashboard-frontend", "version": "1.0.0", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@eslint/js": "^9.26.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", + "eslint": "^9.26.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^24.0.0", "postcss": "^8.4.38", + "prettier": "^3.5.3", "tailwindcss": "^3.4.3", "vite": "^5.1.0", "vitest": "^1.6.0" @@ -864,6 +869,216 @@ "node": ">=12" } }, + "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/@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/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1487,6 +1702,13 @@ "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/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1568,6 +1790,35 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@vitest/snapshot": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", @@ -1695,6 +1946,16 @@ "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/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -1718,6 +1979,23 @@ "node": ">= 14" } }, + "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", @@ -1772,6 +2050,13 @@ "dev": true, "license": "MIT" }, + "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/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -1799,6 +2084,127 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1809,6 +2215,16 @@ "node": "*" } }, + "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/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1869,6 +2285,13 @@ "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/baseline-browser-mapping": { "version": "2.10.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", @@ -1895,8 +2318,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/braces": { - "version": "3.0.3", + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, @@ -2002,6 +2436,16 @@ "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/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2163,6 +2607,13 @@ "node": ">= 6" } }, + "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/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -2255,6 +2706,60 @@ "node": ">=18" } }, + "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", @@ -2326,6 +2831,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -2396,6 +2908,19 @@ "dev": true, "license": "MIT" }, + "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/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -2438,6 +2963,75 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "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", @@ -2479,6 +3073,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -2508,6 +3130,37 @@ "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/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2557,6 +3210,233 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "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-plugin-react/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/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/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2567,6 +3447,16 @@ "@types/estree": "^1.0.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/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -2591,6 +3481,13 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "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-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2621,6 +3518,20 @@ "node": ">= 6" } }, + "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/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2631,6 +3542,19 @@ "reusify": "^1.0.4" } }, + "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/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2644,6 +3568,44 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -2716,6 +3678,27 @@ "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", @@ -2726,6 +3709,16 @@ "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/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2798,6 +3791,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -2811,6 +3822,36 @@ "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", @@ -2860,6 +3901,22 @@ "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", @@ -2966,6 +4023,43 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3026,6 +4120,26 @@ "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", @@ -3101,6 +4215,24 @@ "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", @@ -3118,16 +4250,52 @@ "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-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-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", @@ -3154,6 +4322,19 @@ "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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3284,6 +4465,22 @@ "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", @@ -3297,6 +4494,22 @@ "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", @@ -3328,6 +4541,24 @@ "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/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3344,6 +4575,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "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/jsdom": { "version": "24.1.3", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", @@ -3398,6 +4642,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3411,6 +4676,46 @@ "node": ">=6" } }, + "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/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3448,6 +4753,29 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3597,6 +4925,19 @@ "mini-svg-data-uri": "cli.js" } }, + "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/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -3655,6 +4996,32 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-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-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -3789,6 +5156,60 @@ "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/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -3805,22 +5226,87 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "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": { - "yocto-queue": "^1.0.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=18" + "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/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3834,6 +5320,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "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", @@ -4100,6 +5596,32 @@ "dev": true, "license": "MIT" }, + "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/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -4128,6 +5650,25 @@ "url": "https://github.com/chalk/ansi-styles?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/prop-types/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/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -4258,6 +5799,29 @@ "node": ">=8" } }, + "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", @@ -4308,6 +5872,16 @@ "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/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4395,6 +5969,43 @@ "queue-microtask": "^1.2.2" } }, + "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-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", @@ -4486,6 +6097,21 @@ "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.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4643,6 +6269,104 @@ "node": ">= 0.4" } }, + "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-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -4669,6 +6393,19 @@ "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/strip-literal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", @@ -4930,6 +6667,19 @@ "dev": true, "license": "Apache-2.0" }, + "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-detect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", @@ -4940,6 +6690,84 @@ "node": ">=4" } }, + "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/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -4947,6 +6775,25 @@ "dev": true, "license": "MIT" }, + "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/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -4988,6 +6835,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -5252,6 +7109,34 @@ "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", @@ -5310,6 +7195,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -5357,13 +7252,13 @@ "license": "ISC" }, "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "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": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/frontend/package.json b/frontend/package.json index ca1215f..a805108 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "vibarr-frontend", + "name": "arr-dashboard-frontend", "version": "1.0.0", "type": "module", "scripts": { @@ -8,9 +8,9 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint . --ext .js,.jsx", - "lint:fix": "eslint . --ext .js,.jsx --fix", - "format": "prettier --write \"**/*.{js,jsx}\"" + "lint": "eslint . --ext .js", + "lint:fix": "eslint . --ext .js --fix", + "format": "prettier --write \"**/*.js\"" }, "dependencies": { "react": "^18.2.0", @@ -27,6 +27,11 @@ "vitest": "^1.6.0", "@testing-library/react": "^14.2.0", "@testing-library/jest-dom": "^6.4.0", - "jsdom": "^24.0.0" + "jsdom": "^24.0.0", + "@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" } } diff --git a/frontend/src/ActivityLog.jsx b/frontend/src/ActivityLog.jsx index 8e49ba5..222e161 100644 --- a/frontend/src/ActivityLog.jsx +++ b/frontend/src/ActivityLog.jsx @@ -1,38 +1,23 @@ import { useState, useEffect, useRef, useMemo, memo, useCallback } from 'react'; - -// ─── Inject keyframes once (no new deps; uses existing CSS vars) ──────────── -if (typeof document !== 'undefined' && !document.getElementById('activity-log-anim-style')) { - const st = document.createElement('style'); - st.id = 'activity-log-anim-style'; - st.textContent = ` - @keyframes activity-log-slidein { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } - } - .activity-log-entry { animation: activity-log-slidein 250ms cubic-bezier(0.22, 1, 0.36, 1); } - .activity-log-truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - `; - document.head.appendChild(st); -} - import { formatBytes, formatSpeed, timeAgo } from './utils'; +import { TIMING } from './constants'; const STATUS_CONFIG = { success: { icon: 'check_circle', color: '#30d158', label: 'Completed' }, - error: { icon: 'error', color: '#ff453a', label: 'Failed' }, + error: { icon: 'error', color: '#ff453a', label: 'Failed' }, pending: { icon: 'progress_activity', color: '#ff9f0a', label: 'In Progress' }, - info: { icon: 'info', color: '#0a84ff', label: 'Info' }, + info: { icon: 'info', color: '#0a84ff', label: 'Info' }, }; const SERVICE_ICONS = { lidarr: 'album', sonarr: 'tv', radarr: 'movie' }; const SLSKD_STATES = { 'Completed, Succeeded': { color: '#30d158', icon: 'check_circle', short: 'Done' }, - 'InProgress': { color: '#ff9f0a', icon: 'downloading', short: 'Downloading' }, - 'Completed, Rejected': { color: '#ff453a', icon: 'cancel', short: 'Rejected' }, - 'Completed, Errored': { color: '#ff453a', icon: 'error', short: 'Error' }, - 'Queued, Locally': { color: '#636366', icon: 'schedule', short: 'Queued' }, - 'Queued, Remotely': { color: '#636366', icon: 'hourglass_top', short: 'Waiting' }, + InProgress: { color: '#ff9f0a', icon: 'downloading', short: 'Downloading' }, + 'Completed, Rejected': { color: '#ff453a', icon: 'cancel', short: 'Rejected' }, + 'Completed, Errored': { color: '#ff453a', icon: 'error', short: 'Error' }, + 'Queued, Locally': { color: '#636366', icon: 'schedule', short: 'Queued' }, + 'Queued, Remotely': { color: '#636366', icon: 'hourglass_top', short: 'Waiting' }, 'Completed, Cancelled': { color: '#8e8e93', icon: 'block', short: 'Cancelled' }, }; @@ -43,7 +28,9 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) const [loading, setLoading] = useState(true); useEffect(() => { - const handler = (e) => { if (e.key === 'Escape') onClose(); }; + const handler = e => { + if (e.key === 'Escape') onClose(); + }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [onClose]); @@ -52,9 +39,14 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) setLoading(true); const controller = new AbortController(); fetch(`/api/activity-log/${entry.id}`, { signal: controller.signal }) - .then(r => r.ok ? r.json() : null) - .then(data => { setDetail(data); setLoading(false); }) - .catch(e => { if (e.name !== 'AbortError') setLoading(false); }); + .then(r => (r.ok ? r.json() : null)) + .then(data => { + setDetail(data); + setLoading(false); + }) + .catch(e => { + if (e.name !== 'AbortError') setLoading(false); + }); return () => controller.abort(); }, [entry.id]); @@ -62,7 +54,7 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) if (!entry.context) return false; const ctx = entry.context; if (ctx.service === 'lidarr' && ctx.artistName) { - return dl.artistName.toLowerCase().includes(ctx.artistName.toLowerCase()); + return (dl.artistName || '').toLowerCase().includes(ctx.artistName.toLowerCase()); } return false; }); @@ -71,28 +63,52 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) const svcIcon = entry.context?.service ? SERVICE_ICONS[entry.context.service] : null; return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()} + >
- {cfg.icon} + + {cfg.icon} +

{cfg.label}

{new Date(entry.timestamp).toLocaleString()}

- {svcIcon && {svcIcon}} -
{loading ? (
- progress_activity + + progress_activity +
) : (
@@ -101,7 +117,12 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) {entry.context?.albumNames?.length > 0 && (
{entry.context.albumNames.map((n, i) => ( - {n} + + {n} + ))}
)} @@ -115,7 +136,9 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry })
- {i < detail.steps.length - 1 &&
} + {i < detail.steps.length - 1 && ( +
+ )}

{step.message}

@@ -126,43 +149,70 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) })}
)} - {detail?.details && (() => { - const d = detail.details; - const hasStatusMsgs = d.statusMessages?.length > 0; - const hasMeta = d.trackedDownloadStatus || d.trackedDownloadState || d.status || d.protocol || d.errorMessage; - if (!hasStatusMsgs && !hasMeta) return null; - return ( -
-

Error Details

-
- {hasMeta && ( -
- {d.errorMessage && ( -

{d.errorMessage}

- )} -
- {d.status && status: {d.status}} - {d.trackedDownloadStatus && tracked: {d.trackedDownloadStatus}} - {d.trackedDownloadState && state: {d.trackedDownloadState}} - {d.protocol && via: {d.protocol}} + {detail?.details && + (() => { + const d = detail.details; + const hasStatusMsgs = d.statusMessages?.length > 0; + const hasMeta = + d.trackedDownloadStatus || d.trackedDownloadState || d.status || d.protocol || d.errorMessage; + if (!hasStatusMsgs && !hasMeta) return null; + return ( +
+

+ Error Details +

+
+ {hasMeta && ( +
+ {d.errorMessage && ( +

{d.errorMessage}

+ )} +
+ {d.status && ( + + status: {d.status} + + )} + {d.trackedDownloadStatus && ( + + tracked: {d.trackedDownloadStatus} + + )} + {d.trackedDownloadState && ( + + state: {d.trackedDownloadState} + + )} + {d.protocol && ( + + via: {d.protocol} + + )} +
-
- )} - {hasStatusMsgs && d.statusMessages.map((sm, i) => ( -
- {sm.title &&

{sm.title}

} - {sm.messages?.map((msg, j) => ( -

{msg}

+ )} + {hasStatusMsgs && + d.statusMessages.map((sm, i) => ( +
+ {sm.title && ( +

{sm.title}

+ )} + {sm.messages?.map((msg, j) => ( +

+ {msg} +

+ ))} +
))} -
- ))} +
-
- ); - })()} + ); + })()} {relatedDownloads.length > 0 && (
-

Soulseek Downloads

+

+ Soulseek Downloads +

{relatedDownloads.map((dl, i) => ( ))} @@ -170,8 +220,15 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) )} {relatedDownloads.length === 0 && entry.context?.service === 'lidarr' && (
- cloud_queue -

Soularr will search Soulseek. Downloads appear here once they begin.

+ + cloud_queue + +

+ Soularr will search Soulseek. Downloads appear here once they begin. +

)}
@@ -192,7 +249,7 @@ export function SlskdCard({ dl, onDelete, onRetry, compact }) { const isActive = dl.inProgress > 0; const isQueued = dl.queued > 0 && dl.inProgress === 0 && dl.completed === 0; - const handleDelete = async (e) => { + const handleDelete = async e => { e.stopPropagation(); setDeleting(true); await onDelete(dl.username); @@ -207,12 +264,25 @@ export function SlskdCard({ dl, onDelete, onRetry, compact }) { // Determine real state label let stateLabel, stateColor; - if (dl.overallState === 'completed') { stateLabel = 'Complete'; stateColor = '#30d158'; } - else if (dl.overallState === 'failed') { stateLabel = 'Failed'; stateColor = '#ff453a'; } - else if (dl.overallState === 'partial') { stateLabel = 'Partial'; stateColor = '#ff9f0a'; } - else if (isQueued) { stateLabel = 'Waiting for peer'; stateColor = '#636366'; } - else if (isActive) { stateLabel = `${dl.percentComplete}%`; stateColor = '#30d158'; } - else { stateLabel = 'Queued'; stateColor = '#636366'; } + if (dl.overallState === 'completed') { + stateLabel = 'Complete'; + stateColor = '#30d158'; + } else if (dl.overallState === 'failed') { + stateLabel = 'Failed'; + stateColor = '#ff453a'; + } else if (dl.overallState === 'partial') { + stateLabel = 'Partial'; + stateColor = '#ff9f0a'; + } else if (isQueued) { + stateLabel = 'Waiting for peer'; + stateColor = '#636366'; + } else if (isActive) { + stateLabel = `${dl.percentComplete}%`; + stateColor = '#30d158'; + } else { + stateLabel = 'Queued'; + stateColor = '#636366'; + } return (
@@ -220,31 +290,52 @@ export function SlskdCard({ dl, onDelete, onRetry, compact }) { {/* Action buttons */}
{hasFailed && ( - )} -
@@ -253,8 +344,10 @@ export function SlskdCard({ dl, onDelete, onRetry, compact }) { {(isActive || dl.overallState === 'partial') && (
-
+
)} @@ -262,27 +355,39 @@ export function SlskdCard({ dl, onDelete, onRetry, compact }) { {/* Expanded file list */} {expanded && (
- {dl.files.map((file, fi) => { - const sc = SLSKD_STATES[file.state] || { color: '#6b7280', icon: 'help', short: file.state }; - const isFailed = file.state.includes('Rejected') || file.state.includes('Errored'); + {(dl.files || []).map((file, fi) => { + const fileState = file.state || 'Unknown'; + const sc = SLSKD_STATES[fileState] || { color: '#6b7280', icon: 'help', short: fileState }; + const isFailed = fileState.includes('Rejected') || fileState.includes('Errored'); return ( -
- {sc.icon} +
+ + {sc.icon} +

{file.name}

{formatBytes(file.size)} - {file.state === 'InProgress' && file.averageSpeed > 0 && ( + {fileState === 'InProgress' && file.averageSpeed > 0 && ( {formatSpeed(file.averageSpeed)} )} - {sc.short} + + {sc.short} +
{isFailed && ( - )} @@ -295,15 +400,18 @@ export function SlskdCard({ dl, onDelete, onRetry, compact }) { ); } - // Severity color via CSS variables (consistent palette). function severityVar(status) { switch (status) { - case 'error': return 'var(--accent-red, #ff453a)'; + case 'error': + return 'var(--accent-red, #ff453a)'; case 'pending': - case 'warn': return 'var(--accent-orange, #ff9f0a)'; - case 'success': return 'var(--accent-green, #30d158)'; - default: return 'var(--text-muted, #8e8e93)'; + case 'warn': + return 'var(--accent-orange, #ff9f0a)'; + case 'success': + return 'var(--accent-green, #30d158)'; + default: + return 'var(--text-muted, #8e8e93)'; } } function bucketKey(ts) { @@ -315,7 +423,6 @@ function bucketLabel(ts) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } - const ActivityEntry = memo(function ActivityEntry({ entry, onSelect, onClear }) { const cfg = STATUS_CONFIG[entry.status] || STATUS_CONFIG.info; const svcIcon = entry.context?.service ? SERVICE_ICONS[entry.context.service] : null; @@ -323,23 +430,59 @@ const ActivityEntry = memo(function ActivityEntry({ entry, onSelect, onClear }) return (
- {timeAgo(entry.timestamp)} -
@@ -366,8 +509,15 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } slskdPropRef.current = slskdProp; const poll = () => { - fetch('/api/activity-log?limit=30').then(r => r.ok ? r.json() : []).then(setEntries).catch(() => {}); - if (!slskdPropRef.current) fetch('/api/slskd/downloads').then(r => r.ok ? r.json() : []).then(setSlskdOwn).catch(() => {}); + fetch('/api/activity-log?limit=30') + .then(r => (r.ok ? r.json() : [])) + .then(setEntries) + .catch(() => {}); + if (!slskdPropRef.current) + fetch('/api/slskd/downloads') + .then(r => (r.ok ? r.json() : [])) + .then(setSlskdOwn) + .catch(() => {}); }; const pollFnRef = useRef(poll); pollFnRef.current = poll; @@ -385,12 +535,12 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } if (el) el.scrollTop = el.scrollHeight; }, [entries]); - const handleLogScroll = useCallback((event) => { + const handleLogScroll = useCallback(event => { const el = event.currentTarget; stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 12; }, []); - const handleDeleteSlskd = async (username) => { + const handleDeleteSlskd = async username => { try { await fetch(`/api/slskd/downloads/${encodeURIComponent(username)}`, { method: 'DELETE' }); poll(); @@ -405,7 +555,10 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, fileId }), }); - setTimeout(() => { poll(); onSlskdUpdate?.(); }, 1000); + setTimeout(() => { + poll(); + onSlskdUpdate?.(); + }, 1000); } catch {} }; @@ -424,7 +577,8 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } } catch {} }; - const hasRecent = entries.length > 0 && entries.some(e => (Date.now() - new Date(e.timestamp).getTime()) < 600000); + const hasRecent = + entries.length > 0 && entries.some(e => Date.now() - new Date(e.timestamp).getTime() < TIMING.ACTIVITY_RECENT_MS); const hasSlskd = slskdDownloads.length > 0; if (!hasRecent && !hasSlskd && !expanded) return null; if (dismissed && !expanded) return null; @@ -442,28 +596,64 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } {/* Header */}
- terminal + + terminal + Activity - {pendingCount > 0 && {pendingCount} active} - {activeDL > 0 && {activeDL} DL} - {queuedDL > 0 && {queuedDL} queued} - {failedDL > 0 && {failedDL} err} + {pendingCount > 0 && ( + + {pendingCount} active + + )} + {activeDL > 0 && ( + + {activeDL} DL + + )} + {queuedDL > 0 && ( + + {queuedDL} queued + + )} + {failedDL > 0 && ( + + {failedDL} err + + )}
{(entries.length > 0 || slskdDownloads.length > 0) && expanded && ( - )} - -
@@ -472,12 +662,23 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } {hasSlskd && (
- cloud_download + + cloud_download + Soulseek
{slskdDownloads.map((dl, i) => ( - + ))}
@@ -488,11 +689,20 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } <> {hasSlskd && (
- history + + history + Log
)} -
+
{(() => { const out = []; let lastBucket = null; @@ -501,14 +711,23 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate } // Group bursts from the same minute under one compact timestamp header. if (bk !== lastBucket) { out.push( -
+
{bucketLabel(entry.timestamp)} -
+
, ); lastBucket = bk; } out.push( - + , ); } return out; @@ -524,8 +743,13 @@ export default function ActivityLog({ slskdDownloads: slskdProp, onSlskdUpdate }
{selectedEntry && ( - setSelectedEntry(null)} - slskdDownloads={slskdDownloads} onDeleteSlskd={handleDeleteSlskd} onRetry={handleRetry} /> + setSelectedEntry(null)} + slskdDownloads={slskdDownloads} + onDeleteSlskd={handleDeleteSlskd} + onRetry={handleRetry} + /> )} ); diff --git a/frontend/src/App.css b/frontend/src/App.css index 8ec1e92..f74ab64 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,18 +1,28 @@ /* ── Scrollbar ── */ -.scroll-area::-webkit-scrollbar { width: 8px; } -.scroll-area::-webkit-scrollbar-track { background: transparent; } +.scroll-area::-webkit-scrollbar { + width: 8px; +} +.scroll-area::-webkit-scrollbar-track { + background: transparent; +} .scroll-area::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; } -.scroll-area::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } +.scroll-area::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} /* ── Shimmer / skeleton ── */ @keyframes shimmer { - 0% { background-position: -600px 0; } - 100% { background-position: 600px 0; } + 0% { + background-position: -600px 0; + } + 100% { + background-position: 600px 0; + } } .shimmer { background: linear-gradient(90deg, var(--shimmer-base) 25%, var(--shimmer-highlight) 50%, var(--shimmer-base) 75%); @@ -22,11 +32,15 @@ /* ── Card ── */ .card { - transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 200ms ease; + transition: + transform 200ms cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 200ms ease; } .card:hover { transform: scale(1.02); - box-shadow: var(--shadow-md), 0 0 0 1px var(--border-subtle); + box-shadow: + var(--shadow-md), + 0 0 0 1px var(--border-subtle); } /* ── Carousel ── */ @@ -35,15 +49,26 @@ -webkit-overflow-scrolling: touch; scrollbar-width: none; } -.carousel::-webkit-scrollbar { display: none; } -.carousel-item { scroll-snap-align: start; } +.carousel::-webkit-scrollbar { + display: none; +} +.carousel-item { + scroll-snap-align: start; +} /* Carousel edge fades */ -.carousel-container { position: relative; } +.carousel-container { + position: relative; +} .carousel-container::before, .carousel-container::after { - content: ""; position: absolute; top: 0; bottom: 0; width: 40px; - z-index: 2; pointer-events: none; + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 40px; + z-index: 2; + pointer-events: none; } .carousel-container::before { left: 0; @@ -56,69 +81,118 @@ /* ── Genre pill ── */ .genre-pill { - display: inline-block; padding: 2px 8px; border-radius: 4px; - font-size: 10px; font-weight: 500; line-height: 1.6; white-space: nowrap; - color: var(--genre-pill-color); background: var(--genre-pill-bg); border: 1px solid var(--genre-pill-border); + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + line-height: 1.6; + white-space: nowrap; + color: var(--genre-pill-color); + background: var(--genre-pill-bg); + border: 1px solid var(--genre-pill-border); } /* ── Line clamp ── */ -.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} /* ── Poster gradient overlay ── */ .poster-overlay { - background: linear-gradient(to top, - rgba(0,0,0,0.7) 0%, - rgba(0,0,0,0.4) 30%, - transparent 60% - ); + background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.4) 30%, transparent 60%); } /* ── Library card ── */ .library-card { - transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 200ms ease; + transition: + transform 200ms cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 200ms ease; } .library-card:hover { transform: scale(1.02); - box-shadow: var(--shadow-md), 0 0 0 1px var(--border-subtle); + box-shadow: + var(--shadow-md), + 0 0 0 1px var(--border-subtle); } /* ── Search input ── */ .search-input { - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; } /* ── Select dropdown ── */ .add-select { - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23636366' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; } /* ── Spin animation ── */ -@keyframes spin { to { transform: rotate(360deg); } } -.animate-spin { animation: spin 1s linear infinite; } +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.animate-spin { + animation: spin 1s linear infinite; +} /* ── Modal animations ── */ @keyframes modalIn { - from { opacity: 0; transform: scale(0.96) translateY(6px); } - to { opacity: 1; transform: scale(1) translateY(0); } + from { + opacity: 0; + transform: scale(0.96) translateY(6px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } } @keyframes modalOut { - from { opacity: 1; transform: scale(1) translateY(0); } - to { opacity: 0; transform: scale(0.96) translateY(6px); } + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.96) translateY(6px); + } } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes fadeOut { - from { opacity: 1; } - to { opacity: 0; } + from { + opacity: 1; + } + to { + opacity: 0; + } } .modal-enter { animation: modalIn 0.28s cubic-bezier(0.16, 1, 0.3, 1) forwards; @@ -140,14 +214,17 @@ cursor: pointer; border-radius: 12px; overflow: hidden; - transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), - box-shadow 0.25s ease; + transition: + transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.25s ease; position: relative; background: var(--surface); } .poster-card:hover { transform: scale(1.04) translateY(-4px); - box-shadow: var(--shadow-lg), 0 0 0 1px var(--border-medium); + box-shadow: + var(--shadow-lg), + 0 0 0 1px var(--border-medium); z-index: 10; } .poster-film-grain { @@ -157,8 +234,8 @@ 0deg, transparent, transparent 2px, - rgba(0,0,0,0.02) 2px, - rgba(0,0,0,0.02) 4px + rgba(0, 0, 0, 0.02) 2px, + rgba(0, 0, 0, 0.02) 4px ); pointer-events: none; z-index: 2; @@ -170,46 +247,102 @@ color: var(--rail-hover-color) !important; } -.service-strip { - mask-image: linear-gradient(to right, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%); +/* ── Hide scrollbar utility ── */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} +.hide-scrollbar { + scrollbar-width: none; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +@keyframes downloadPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.82; + } +} + +@keyframes activity-log-slidein { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes posterShimmer { + 0% { + background-position: + -150% 0, + 0 0; + } + 100% { + background-position: + 150% 0, + 0 0; + } } -.service-strip__link { - display: inline-flex; - min-width: 0; +@keyframes gridFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } } -.container-chip:hover { - background: var(--surface) !important; +.activity-log-entry { + animation: activity-log-slidein 250ms cubic-bezier(0.22, 1, 0.36, 1); } -.container-chip__label { - min-width: 0; +.activity-log-truncate { + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.container-chip__external { - opacity: 0; - transition: opacity 150ms ease; +.pill-springy { + transition: + background-color 180ms ease, + color 180ms ease, + box-shadow 180ms ease, + transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1); } -.container-chip:hover .container-chip__external { - opacity: 1; +.pill-springy:active { + transform: scale(0.94); } -/* ── Hide scrollbar utility ── */ -.hide-scrollbar::-webkit-scrollbar { display: none; } -.hide-scrollbar { scrollbar-width: none; } - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.35; } +.library-grid-fade { + animation: gridFadeIn 220ms ease-out; } -@keyframes downloadPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.82; } +@media (prefers-reduced-motion: reduce) { + .activity-log-entry, + .library-grid-fade { + animation: none !important; + } + + .pill-springy { + transition: none !important; + } } /* ── Mobile touch active states ── */ @@ -226,7 +359,8 @@ } /* ── Prevent overscroll bounce and horizontal scroll on iOS ── */ -html, body { +html, +body { overscroll-behavior: none; overflow-x: hidden; max-width: 100%; @@ -244,41 +378,52 @@ html, body { /* ── Keyboard focus rings ── */ .rail-item:focus-visible { - outline: 2px solid #FF375F; + outline: 2px solid #ff375f; outline-offset: 2px; } button:focus-visible, -[role="button"]:focus-visible { - outline: 2px solid rgba(10,132,255,0.75); +[role='button']:focus-visible { + outline: 2px solid rgba(10, 132, 255, 0.75); outline-offset: 2px; border-radius: 8px; } a:focus-visible { - outline: 2px solid rgba(10,132,255,0.75); + outline: 2px solid rgba(10, 132, 255, 0.75); outline-offset: 2px; border-radius: 4px; } - /* ── Interactive transition baseline ── */ button, -[role="button"], +[role='button'], a, .pill, .chip, input, select, textarea { - transition: background-color 150ms ease, color 150ms ease, - border-color 150ms ease, box-shadow 150ms ease, - transform 150ms ease, opacity 150ms ease; + transition: + background-color 150ms ease, + color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease, + transform 150ms ease, + opacity 150ms ease; } /* ── Tabular numerals for metric-bearing classes ── */ .tabular, -.speed-cell, .size-cell, .eta-cell, .count-cell, -.bandwidth, .torrent-speed, .torrent-size, -[class*="-speed"], [class*="-size"], [class*="-count"], [class*="-eta"] { +.speed-cell, +.size-cell, +.eta-cell, +.count-cell, +.bandwidth, +.torrent-speed, +.torrent-size, +[class*='-speed'], +[class*='-size'], +[class*='-count'], +[class*='-eta'] { font-variant-numeric: tabular-nums; - font-feature-settings: "tnum"; + font-feature-settings: 'tnum'; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 847b283..8682830 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,1428 +1,65 @@ -import { useState, useEffect, useRef, Suspense, useCallback, useMemo } from 'react'; +import { Suspense, useCallback, useMemo, useState } from 'react'; import React from 'react'; import TorrentTableRaw from './TorrentTable'; import PipelineCardRaw from './PipelineCard'; import { SlskdCard as SlskdCardRaw } from './ActivityLog'; -import PosterImage from './PosterImage'; -// App polls constantly, so keep the largest child trees behind shallow prop-based memoization. +import { cleanName, getTorrentState } from './utils'; +import { useAppData } from './hooks/useAppData'; +import { useBandwidth } from './hooks/useBandwidth'; +import { useManualSearch } from './hooks/useManualSearch'; +import { useLayoutPrefs } from './hooks/useLayoutPrefs'; +import LoadingSkeleton from './components/app/LoadingSkeleton'; +import AppNavRail from './components/app/AppNavRail'; +import AppHeader from './components/app/AppHeader'; +import ServiceStrip from './components/app/ServiceStrip'; +import MobileBottomNav from './components/app/MobileBottomNav'; +import DownloadsView from './components/app/DownloadsView'; +import './App.css'; + const TorrentTable = React.memo(TorrentTableRaw); const PipelineCard = React.memo(PipelineCardRaw); const SlskdCard = React.memo(SlskdCardRaw); -import { formatSpeed, formatBytes, getTorrentState, cleanName, detectQualityLabel } from './utils'; -import { apiFetch, apiPost, apiDelete, getApiErrorDetails } from './api'; -import { BP, getServiceGradient, getServiceUrl } from './constants'; -import './App.css'; -// Code-split modal components const Library = React.lazy(() => import('./Library')); const SidePanel = React.lazy(() => import('./SidePanel')); const ManualSearchModal = React.lazy(() => import('./ManualSearchModal')); -/** - * Colored service chip with status indicator and optional link. - * @param {Object} container - qBittorrent/Docker container object - * @param {string} name - Cleaned service name (e.g. "sonarr") - * @param {string|null} href - URL to open when chip is clicked - */ -const ContainerChip = React.memo(function ContainerChip({ container, name, href }) { - const [c1, c2] = getServiceGradient(name); - return ( -
- - {name[0]?.toUpperCase()} - - - {name} - - {href && ( - open_in_new - )} -
- ); -}); - -/** - * Vertical navigation rail button with active indicator and badge. - * @param {ReactNode} icon - Icon element to display - * @param {boolean} active - Whether this item is the current active view - * @param {Function} onClick - Click handler - * @param {string} title - Tooltip text - * @param {number} [badge] - Optional badge count (shown as red pill) - */ -function RailItem({ icon, active, onClick, title, badge, disabled = false }) { - return ( -
{ if ((e.key === 'Enter' || e.key === ' ') && !disabled) onClick(); }} - className="rail-item" - style={{ - width: 44, height: 44, borderRadius: 12, - display: 'flex', alignItems: 'center', justifyContent: 'center', - cursor: 'pointer', position: 'relative', - background: active ? 'rgba(255,55,95,0.18)' : 'transparent', - color: active ? '#FF375F' : 'rgba(235,235,245,0.50)', - transition: 'background 0.2s, color 0.2s', - opacity: disabled ? 0.56 : 1, - pointerEvents: disabled ? 'none' : 'auto', - }} - > - {active && ( -
- )} - {icon} - {badge > 0 && ( -
- {badge > 99 ? '99+' : badge} -
- )} -
- ); -} - -const LibraryIcon = () => ( - - - - - - -); - -const DownloadsIcon = () => ( - - - - -); - -const SettingsIcon = () => ( - - - - -); - -const DownArrowIcon = () => ( - - - - -); - -const UpArrowIcon = () => ( - - - - -); - -const SearchBarIcon = () => ( - - - - -); - -function LoadingSkeleton() { - return ( -
-
- - - - -

Loading vibarr…

-
-
- ); -} - -const STATUS_COLOR = { searching: '#ff9f0a', grabbed: '#30d158', no_results: '#ff453a', error: '#ff453a' }; -const STATUS_LABEL = { searching: 'Searching…', grabbed: 'Grabbed', no_results: 'No results', error: 'Error' }; -const SVC_LABEL = { sonarr: 'TV', radarr: 'Movie', lidarr: 'Music' }; -const ADD_SUCCESS_REFRESH_STEPS_MS = [600, 1400, 2600, 4200]; -const ADD_SUCCESS_PLACEHOLDER_TTL_MS = 9000; - -async function apiFetchWithTimeout(url, options = {}, timeoutMs = 5000) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await apiFetch(url, { ...options, signal: controller.signal }); - return response; - } finally { - clearTimeout(timer); - } -} - - -function PendingSearchCard({ search }) { - const [elapsed, setElapsed] = useState(() => Math.round((Date.now() - search.startedAt) / 1000)); - useEffect(() => { - const t = setInterval(() => setElapsed(Math.round((Date.now() - search.startedAt) / 1000)), 1000); - return () => clearInterval(t); - }, [search.startedAt]); - const elapsedStr = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`; - const dot = STATUS_COLOR[search.status] || '#ff9f0a'; - const isSearching = search.status === 'searching'; - return ( -
- -
-
- {search.title} -
-
- {SVC_LABEL[search.service] || search.service} · {search.subtitle} -
-
-
- {isSearching && {elapsedStr}} -
- {isSearching && ( - - )} - {!isSearching && ( - - )} - {STATUS_LABEL[search.status] || search.status} -
-
-
- ); -} - -const ARR_STATUS_COLOR = { downloading: '#0a84ff', completed: '#0a84ff', warning: '#ff9f0a', error: '#ff453a', failed: '#ff453a', delay: '#ff9f0a' }; - -const SERVICE_META = { - radarr: { - label: 'Radarr', - short: 'Movies', - env: ['RADARR_HOST', 'RADARR_API_KEY'], - detail: 'Movie library, lookup, and automated search.', - }, - sonarr: { - label: 'Sonarr', - short: 'TV', - env: ['SONARR_HOST', 'SONARR_API_KEY'], - detail: 'Series library, episode tracking, and season searches.', - }, - lidarr: { - label: 'Lidarr', - short: 'Music', - env: ['LIDARR_HOST', 'LIDARR_API_KEY'], - detail: 'Artist library, album monitoring, and Soulseek handoff.', - }, - prowlarr: { - label: 'Prowlarr', - short: 'Indexers', - env: ['PROWLARR_HOST', 'PROWLARR_API_KEY'], - detail: 'Indexer sync for Radarr, Sonarr, and Lidarr.', - }, - qbittorrent: { - label: 'qBittorrent', - short: 'Downloads', - env: ['QBITTORRENT_HOST', 'QBITTORRENT_USER', 'QBITTORRENT_PASS'], - detail: 'Torrent client connection and download telemetry.', - }, - slskd: { - label: 'slskd', - short: 'Soulseek', - env: ['SLSKD_HOST', 'SLSKD_API_KEY'], - detail: 'Soulseek search and music fallback downloads.', - }, -}; - -const SERVICE_TONE = { - up: { label: 'Up', fg: '#30d158', bg: 'rgba(48,209,88,0.16)', border: 'rgba(48,209,88,0.28)' }, - ready: { label: 'Ready', fg: '#30d158', bg: 'rgba(48,209,88,0.16)', border: 'rgba(48,209,88,0.28)' }, - down: { label: 'Down', fg: '#ff453a', bg: 'rgba(255,69,58,0.16)', border: 'rgba(255,69,58,0.28)' }, - error: { label: 'Error', fg: '#ff453a', bg: 'rgba(255,69,58,0.16)', border: 'rgba(255,69,58,0.28)' }, - unconfigured: { label: 'Unconfigured', fg: '#ff9f0a', bg: 'rgba(255,159,10,0.16)', border: 'rgba(255,159,10,0.28)' }, - stale: { label: 'Checking', fg: '#8e8e93', bg: 'rgba(142,142,147,0.16)', border: 'rgba(142,142,147,0.28)' }, -}; - -function ServiceStatusPill({ status }) { - const tone = SERVICE_TONE[status] || SERVICE_TONE.stale; - return ( - - - {tone.label} - - ); -} - -const LIBRARY_SERVICE_NAMES = ['radarr', 'sonarr', 'lidarr']; -const PHASE_READY = new Set(['ready', 'running', 'complete', 'completed', 'healthy', 'online', 'installed']); -const PHASE_PROGRESS = new Set([ - 'installing', - 'provisioning', - 'bootstrapping', - 'starting', - 'configuring', - 'writing_config', - 'waiting_for_restart', - 'restarting', - 'waiting_for_readiness', - 'verifying', - 'recovering', - 'retrying', -]); -const PHASE_SETUP = new Set([ - 'setup_required', - 'needs_configuration', - 'manual_configuration', - 'manual_config', - 'unconfigured', - 'incomplete', - 'not_ready', - 'blocked', - 'error', -]); -function firstDefined(...values) { - for (const value of values) { - if (value !== undefined && value !== null && value !== '') return value; - } - return undefined; -} - -function firstBoolean(...values) { - for (const value of values) { - if (typeof value === 'boolean') return value; - } - return undefined; -} - -function toDisplayText(value) { - if (!value) return null; - if (typeof value === 'string') return value.trim() || null; - if (typeof value === 'object') { - const service = firstDefined(value.service, value.name); - const message = firstDefined(value.message, value.detail, value.error, value.warning, value.reason); - if (service && message) return `${SERVICE_META[service]?.label || service}: ${message}`; - if (message) return String(message).trim(); - } - return String(value).trim() || null; -} - -function collectTextEntries(...sources) { - return [...new Set( - sources - .flatMap((source) => { - if (!source) return []; - if (Array.isArray(source)) return source; - return [source]; - }) - .map(toDisplayText) - .filter(Boolean), - )]; -} - -function normalizePhase(value) { - return String(value || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); -} - -function humanizePhase(value) { - if (!value) return null; - return String(value) - .replace(/[_-]+/g, ' ') - .replace(/\b\w/g, (char) => char.toUpperCase()); -} - -function hasAnyLibraryServiceReady(status) { - const services = status?.services || {}; - return LIBRARY_SERVICE_NAMES.some((name) => ['up', 'ready'].includes(services[name]?.status)); -} - -function getSetupFlowState(statusData, setupState) { - const rawPhase = firstDefined( - setupState?.installPhase, - setupState?.phase, - setupState?.install?.phase, - setupState?.installer?.phase, - setupState?.readiness?.phase, - setupState?.readiness?.state, - statusData?.installPhase, - statusData?.phase, - statusData?.install?.phase, - statusData?.installer?.phase, - statusData?.readiness?.phase, - statusData?.readiness?.state, - ); - const normalizedPhase = normalizePhase(rawPhase); - const explicitReady = firstBoolean( - setupState?.ready, - setupState?.isReady, - setupState?.readiness?.ready, - statusData?.ready, - statusData?.isReady, - statusData?.readiness?.ready, - ); - const setupRequired = firstBoolean( - statusData?.setupRequired, - setupState?.setupRequired, - setupState?.needsSetup, - statusData?.needsSetup, - ) ?? false; - const restartPending = firstBoolean( - setupState?.restartPending, - statusData?.restartPending, - setupState?.restartScheduled, - statusData?.restartScheduled, - ) ?? false; - const phaseInProgress = PHASE_PROGRESS.has(normalizedPhase); - const phaseNeedsSetup = PHASE_SETUP.has(normalizedPhase); - const phaseReady = PHASE_READY.has(normalizedPhase); - const ready = explicitReady === true || ( - phaseReady && - explicitReady !== false && - !setupRequired && - !phaseNeedsSetup && - !restartPending - ); - const needsSetup = setupRequired || phaseNeedsSetup || restartPending || explicitReady === false; - return { - ready, - needsSetup, - shouldStayInSetup: !ready && (needsSetup || phaseInProgress), - phaseLabel: humanizePhase(rawPhase), - phaseInProgress, - restartPending, - manualConfigOnly: needsSetup && setupState?.installerEnabled === false, - }; -} - -function errorToneStyle(tone = 'error') { - if (tone === 'warning') { - return { border: 'rgba(255,159,10,0.28)', background: 'rgba(255,159,10,0.09)', color: '#ff9f0a' }; - } - return { border: 'rgba(255,69,58,0.26)', background: 'rgba(255,69,58,0.08)', color: '#ff453a' }; -} - -function ApiErrorNotice({ title, error, tone = 'error', style = {} }) { - const details = getApiErrorDetails(error); - if (!details) return null; - const palette = errorToneStyle(tone); - const meta = [ - details.method && details.endpoint ? `${details.method} ${details.endpoint}` : details.endpoint, - details.status ? `HTTP ${details.status}` : null, - details.durationMs ? `${details.durationMs}ms` : null, - details.attempt ? `attempt ${details.attempt}` : null, - details.retryAfterMs ? `retry in ${Math.max(1, Math.ceil(details.retryAfterMs / 1000))}s` : null, - details.requestId ? `request ${details.requestId}` : null, - details.clientRequestId ? `client ${details.clientRequestId}` : null, - ].filter(Boolean); - return ( -
- {title &&
{title}
} -
{details.message}
- {meta.length > 0 &&
{meta.join(' · ')}
} - {details.warnings.length > 0 && ( -
- Warnings: {details.warnings.join(' · ')} -
- )} -
- ); -} - -const setupInputStyle = { - height: 42, - borderRadius: 12, - border: '1px solid var(--border-subtle)', - background: 'var(--surface-subtle)', - color: 'var(--text-primary)', - padding: '0 12px', - fontSize: 13, - outline: 'none', -}; - -function buildSetupForm(setupState) { - const seed = setupState?.setup || setupState?.defaults || {}; - return { - basePath: seed.basePath || '/docker', - timezone: seed.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Etc/UTC', - puid: String(seed.puid ?? 1000), - pgid: String(seed.pgid ?? 1000), - services: { - radarr: seed.services?.radarr ?? true, - sonarr: seed.services?.sonarr ?? true, - lidarr: seed.services?.lidarr ?? true, - prowlarr: seed.services?.prowlarr ?? true, - qbittorrent: seed.services?.qbittorrent ?? true, - slskd: seed.services?.slskd ?? true, - }, - qbittorrent: { - username: seed.qbittorrent?.username || 'server', - password: '', - }, - slskd: { - username: seed.slskd?.username || '', - password: '', - webUsername: seed.slskd?.webUsername || 'slskd', - webPassword: '', - }, - }; -} - -function SetupPanel({ - statusData, - statusError, - setupState, - setupStateError, - onRefresh, - onContinue, - onInstall, - installing, - installError, - installResult, - awaitingBackendRestart, -}) { - const services = statusData?.services || {}; - const summary = statusData?.summary || { up: 0, down: 0, unconfigured: 0 }; - const setupRequired = Boolean(statusData?.setupRequired); - const hasIssues = Boolean(statusData?.hasIssues); - const setupFlow = useMemo(() => getSetupFlowState(statusData, setupState), [statusData, setupState]); - const installerEnabled = setupState?.installerEnabled !== false; - const serviceOrder = ['radarr', 'sonarr', 'lidarr', 'prowlarr', 'qbittorrent', 'slskd']; - const [form, setForm] = useState(() => buildSetupForm(setupState)); - const initializedRef = useRef(false); - const conflicts = firstDefined(setupState?.validation?.conflicts, setupState?.conflicts, []); - const showInstaller = setupFlow.shouldStayInSetup && installerEnabled && !setupState?.managed; - const canBootstrap = setupState?.canBootstrap !== false; - const selectedServices = useMemo(() => Object.entries(form.services).filter(([, enabled]) => enabled).map(([name]) => name), [form.services]); - const selectedLibraryServices = useMemo(() => LIBRARY_SERVICE_NAMES.filter((name) => form.services[name]), [form.services]); - const selectedLibraryCount = selectedLibraryServices.length; - const noLibrarySelected = selectedLibraryCount === 0; - const selectedServiceConflicts = useMemo(() => { - if (!Array.isArray(conflicts) || conflicts.length === 0) return []; - return conflicts.filter((conflict) => { - const names = [ - conflict?.service, - ...(Array.isArray(conflict?.services) ? conflict.services : []), - ...(Array.isArray(conflict?.relatedServices) ? conflict.relatedServices : []), - ] - .map((name) => String(name).toLowerCase()) - .filter(Boolean); - if (names.length > 0) { - return names.some((name) => selectedServices.includes(name)); - } - const value = String(toDisplayText(conflict) || conflict || '').toLowerCase(); - return selectedServices.some((service) => value.includes(service)); - }); - }, [conflicts, selectedServices]); - const selectedValidationIssues = useMemo(() => { - const rawIssues = [ - ...(Array.isArray(setupState?.validation?.errors) ? setupState.validation.errors : []), - ...(Array.isArray(setupState?.validation?.blocking) ? setupState.validation.blocking : []), - ...(Array.isArray(setupState?.blockingIssues) ? setupState.blockingIssues : []), - ]; - return collectTextEntries(...rawIssues.filter((issue) => { - const names = [ - issue?.service, - ...(Array.isArray(issue?.services) ? issue.services : []), - ...(Array.isArray(issue?.relatedServices) ? issue.relatedServices : []), - ] - .map((name) => String(name).toLowerCase()) - .filter(Boolean); - if (names.length > 0) return names.some((name) => selectedServices.includes(name)); - return true; - })); - }, [selectedServices, setupState]); - const persistedInstallError = firstDefined( - setupState?.lastInstallError, - setupState?.lastError, - setupState?.installer?.lastError, - statusData?.lastInstallError, - ); - const setupWarnings = collectTextEntries( - setupState?.warnings, - setupState?.warning, - setupState?.installer?.warnings, - statusData?.warnings, - statusData?.warning, - ); - const hasBlockingIssues = noLibrarySelected || selectedServiceConflicts.length > 0 || selectedValidationIssues.length > 0; - const continueDisabled = awaitingBackendRestart || installing || setupFlow.shouldStayInSetup; - - useEffect(() => { - if (initializedRef.current) return; - setForm(buildSetupForm(setupState)); - initializedRef.current = true; - }, [setupState]); - - const updateForm = (path, value) => { - setForm(prev => { - const next = structuredClone(prev); - if (path.length === 1) next[path[0]] = value; - if (path.length === 2) next[path[0]][path[1]] = value; - return next; - }); - }; - - const toggleService = (name) => { - setForm(prev => ({ - ...prev, - services: { - ...prev.services, - [name]: !prev.services[name], - }, - })); - }; - - const handleInstall = () => { - onInstall({ - ...form, - puid: Number(form.puid) || 1000, - pgid: Number(form.pgid) || 1000, - }); - }; - - return ( -
-
-
-
-
-

- Setup -

-

- {showInstaller - ? 'Install the media stack from here.' - : setupFlow.manualConfigOnly - ? 'Finish setup from the backend environment.' - : setupFlow.phaseInProgress || awaitingBackendRestart - ? 'Waiting for the API and services to become ready.' - : setupRequired - ? 'Connect the core services before using the dashboard.' - : hasIssues ? 'Some integrations need attention.' : 'Everything is connected.'} -

-

- {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.'} -

-
-
-
-
- Summary -
-
- {summary.up}/{summary.up + summary.down + summary.unconfigured} -
-
- services healthy -
-
- {setupFlow.phaseLabel && ( -
-
- Install Phase -
-
- {setupFlow.phaseLabel} -
-
- backend-reported state -
-
- )} - - -
-
-
- - {(statusError || setupStateError) && ( -
- - -
- )} - - {showInstaller && ( -
-
-
-
First-run installer
-

- 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. -

-
- -
- -
- - - - -
- -
-
Services
-
- {['radarr', 'sonarr', 'lidarr', 'prowlarr', 'qbittorrent', 'slskd'].map((name) => { - const active = form.services[name]; - return ( - - ); - })} -
-
- -
- - - - -
- - {(setupStateError || hasBlockingIssues || setupWarnings.length > 0 || persistedInstallError || installError || installResult?.success || awaitingBackendRestart) && ( -
- {noLibrarySelected && ( -
- Select at least one library service: Radarr, Sonarr, or Lidarr. -
- )} - {selectedServiceConflicts.length > 0 && ( -
- Existing unmanaged containers block the selected install set: {selectedServiceConflicts.map((conflict) => toDisplayText(conflict)).filter(Boolean).join(', ')}. -
- )} - {selectedValidationIssues.length > 0 && ( -
- {selectedValidationIssues.join(' ')} -
- )} - {setupWarnings.length > 0 && ( -
- {setupWarnings.join(' ')} -
- )} - {persistedInstallError && ( -
- Last installer failure: {persistedInstallError} -
- )} - - {installResult?.success && ( -
- qBittorrent: `{installResult.credentials?.qbittorrent?.username}` / `{installResult.credentials?.qbittorrent?.password}` at `{installResult.credentials?.qbittorrent?.url}`. - {installResult.credentials?.slskd && <> SLSKD web auth: `{installResult.credentials.slskd.username}` / `{installResult.credentials.slskd.password}`.} -
- )} - {awaitingBackendRestart && ( -
- The backend is restarting onto the newly provisioned stack. This screen will recover automatically once `/api/status` and `/api/setup/state` both report readiness. -
- )} -
- )} -
- )} - - {!showInstaller && setupFlow.manualConfigOnly && ( -
-
Manual configuration
-

- 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. -

-
- )} - -
- {serviceOrder.map((name) => { - const meta = SERVICE_META[name]; - const info = services[name] || { status: 'stale' }; - return ( -
-
-
-
{meta.label}
-
{meta.short}
-
- -
-

- {meta.detail} -

-
-
{(meta.env || []).join(' · ')}
- {info.url &&
{info.url}
} - {info.error &&
{info.error}
} -
-
- ); - })} -
-
-
- ); -} - -function ArrQueueCard({ item }) { - const hasError = item.trackedStatus === 'warning' || item.trackedStatus === 'error' || item.status === 'failed'; - const statusColor = hasError ? '#ff9f0a' : ARR_STATUS_COLOR.downloading; - const progress = item.progress || 0; - const sizeLeft = item.sizeleft > 0 ? formatBytes(item.sizeleft) + ' left' : null; - return ( -
- -
-
- {item.title}{item.episode ? ` — ${item.episode}` : ''} -
- {hasError && item.errorMessage ? ( -
- {item.errorMessage} -
- ) : ( -
-
-
-
- - {sizeLeft || `${progress}%`} - -
- )} -
-
- - {hasError ? 'Warning' : item.status === 'completed' ? 'Importing' : 'Queued'} - -
-
- ); -} - export default function App() { - const [containers, setContainers] = useState([]); - const [torrents, setTorrents] = useState([]); - const [torrentError, setTorrentError] = useState(null); - const [slskdDownloads, setSlskdDownloads] = useState([]); - const [pendingSearches, setPendingSearches] = useState([]); - const [pipeline, setPipeline] = useState([]); - const [startingSearch, setStartingSearch] = useState(false); + const { + containers, + torrents, + torrentError, + slskdDownloads, + pipeline, + arrQueue, + loading, + tailscaleIp, + mediaInfo, + fetchSlskd, + fetchTorrents, + fetchPendingSearches, + } = useAppData(); + + const { bwHistory, bwTotals, bwLifetime } = useBandwidth(); const [manualSearchTarget, setManualSearchTarget] = useState(null); - const [manualSearchResults, setManualSearchResults] = useState([]); - const [manualSearchLoading, setManualSearchLoading] = useState(false); - const [expandedPipelineKey, setExpandedPipelineKey] = useState(null); - const [arrQueue, setArrQueue] = useState([]); - const [loading, setLoading] = useState(true); - const [tailscaleIp, setTailscaleIp] = useState(null); - const [mediaInfo, setMediaInfo] = useState({}); - const [activeView, setActiveView] = useState('library'); - const [headerQuery, setHeaderQuery] = useState(''); - const [bwHistory, setBwHistory] = useState([]); - const [bwTotals, setBwTotals] = useState({ dl: 0, ul: 0 }); - const [bwLifetime, setBwLifetime] = useState({ dl: 0, ul: 0 }); - const [statusData, setStatusData] = useState(null); - const [statusError, setStatusError] = useState(null); - const [setupState, setSetupState] = useState(null); - const [setupStateError, setSetupStateError] = useState(null); - const [installingSetup, setInstallingSetup] = useState(false); - const [setupInstallError, setSetupInstallError] = useState(null); - const [setupInstallResult, setSetupInstallResult] = useState(null); - const [awaitingBackendRestart, setAwaitingBackendRestart] = useState(false); - const [isMobile, setIsMobile] = useState(() => window.innerWidth < BP.MOBILE); - const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth >= BP.MOBILE); - const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - const [mobileSearchValue, setMobileSearchValue] = useState(''); - const [lightMode, setLightMode] = useState(() => { - const saved = localStorage.getItem('theme') === 'light'; - // Apply the stored theme before first paint to avoid a dark/light flash on reload. - document.documentElement.setAttribute('data-theme', saved ? 'light' : 'dark'); - return saved; - }); - const mobileSearchDebounce = useRef(null); - // Pollers read the latest torrent list from here without recreating their intervals. - const torrentsRef = useRef([]); - const mediaInfoRef = useRef({}); - const mediaInfoInflightRef = useRef(new Set()); - const prevPipelineRef = useRef({}); - const startingSearchTimersRef = useRef([]); - const setupGateWasUsedRef = useRef(false); - const setupFlow = useMemo(() => getSetupFlowState(statusData, setupState), [statusData, setupState]); - const shouldPollRuntimeActivity = Boolean(statusData) && !setupFlow.shouldStayInSetup; - - const fetchContainers = useCallback(async () => { - try { - const data = await apiFetchWithTimeout('/api/containers', {}, 5000); - setContainers(data); - } catch (e) { - console.error('[fetchContainers]', e); - setContainers([]); - } - }, []); - - const fetchTorrents = useCallback(async () => { - try { - const data = await apiFetch('/api/qbittorrent/status'); - const parsed = (data.torrents || []).map(t => ({ ...t, progress: parseFloat(t.progress) || 0 })); - setTorrents(parsed); - torrentsRef.current = parsed; - setTorrentError(null); - } catch (e) { - console.error('[fetchTorrents]', e); - setTorrentError(e.message); - } - }, []); - - const fetchSlskd = useCallback(async () => { - try { - const data = await apiFetch('/api/slskd/downloads'); - setSlskdDownloads(data); - } catch (e) { console.error('[fetchSlskd]', e); } - }, []); - - const fetchPendingSearches = useCallback(async () => { - try { - const pending = await apiFetch('/api/pending-searches'); - setPendingSearches(pending); - const items = await apiFetch('/api/pipeline'); - if (Notification.permission === 'granted') { - const prev = prevPipelineRef.current; - for (const item of items) { - const prevStage = prev[item.key]; - const isNowComplete = item.stage === 'complete'; - const wasNotComplete = !prevStage || (prevStage !== 'complete'); - if (isNowComplete && wasNotComplete && prevStage !== undefined) { - new Notification(`Download complete: ${item.title}`, { - body: item.subtitle ? `${item.subtitle} — Successfully imported` : 'Successfully imported', - icon: '/favicon.ico', - }); - } - } - } - const newMap = {}; - for (const item of items) newMap[item.key] = item.stage; - prevPipelineRef.current = newMap; - setPipeline(items); - } catch (e) { console.error('[fetchPendingSearches]', e); } - }, []); - - const fetchArrQueue = useCallback(async () => { - try { - const data = await apiFetch('/api/arr-queue'); - setArrQueue(data); - } catch (e) { console.error('[fetchArrQueue]', e); } - }, []); - - const clearStartingSearchTimers = useCallback(() => { - startingSearchTimersRef.current.forEach((id) => clearTimeout(id)); - startingSearchTimersRef.current = []; - }, []); - - const refreshDownloadsState = useCallback(async () => { - await Promise.all([fetchPendingSearches(), fetchArrQueue(), fetchTorrents(), fetchSlskd()]); - }, [fetchPendingSearches, fetchArrQueue, fetchTorrents, fetchSlskd]); - - const fetchMediaInfo = useCallback((torrentList) => { - try { - const hashes = [...new Set((torrentList || []).map(t => t.hash).filter(Boolean))] - .filter((hash) => !mediaInfoRef.current[hash] && !mediaInfoInflightRef.current.has(hash)); - if (hashes.length === 0) return; - hashes.forEach((hash) => mediaInfoInflightRef.current.add(hash)); - apiFetch(`/api/media-info/batch?hashes=${hashes.join(',')}`) - .then((data) => { - setMediaInfo((prev) => { - const next = { ...prev, ...data }; - mediaInfoRef.current = next; - return next; - }); - }) - .finally(() => { - hashes.forEach((hash) => mediaInfoInflightRef.current.delete(hash)); - }) - .catch(e => console.error('[fetchMediaInfo]', e)); - } catch (e) { console.error('[fetchMediaInfo]', e); } - }, []); - - const fetchTailscaleIp = useCallback(async () => { - try { - const data = await apiFetch('/api/tailscale-ip'); - setTailscaleIp(data.ip); - } catch (e) { console.error('[fetchTailscaleIp]', e); } - }, []); - - const fetchStatus = useCallback(async () => { - try { - const data = await apiFetchWithTimeout('/api/status', {}, 5000); - setStatusData(data); - setStatusError(null); - } catch (e) { - const error = e.name === 'AbortError' - ? Object.assign(new Error('Status check timed out'), { endpoint: '/api/status', method: 'GET' }) - : e; - console.error('[fetchStatus]', error); - setStatusError(error); - } - }, []); - - const fetchSetupState = useCallback(async () => { - try { - const data = await apiFetchWithTimeout('/api/setup/state', {}, 5000); - setSetupState(data); - setSetupStateError(null); - } catch (e) { - const error = e.name === 'AbortError' - ? Object.assign(new Error('Setup state check timed out'), { endpoint: '/api/setup/state', method: 'GET' }) - : e; - console.error('[fetchSetupState]', error); - setSetupStateError(error); - } - }, []); - - useEffect(() => { - if (shouldPollRuntimeActivity) return; - clearStartingSearchTimers(); - torrentsRef.current = []; - prevPipelineRef.current = {}; - setTorrents([]); - setTorrentError(null); - setSlskdDownloads([]); - setPendingSearches([]); - setPipeline([]); - setStartingSearch(false); - setArrQueue([]); - setMediaInfo({}); - mediaInfoRef.current = {}; - mediaInfoInflightRef.current.clear(); - setBwHistory([]); - setBwTotals({ dl: 0, ul: 0 }); - setBwLifetime({ dl: 0, ul: 0 }); - setTailscaleIp(null); - }, [clearStartingSearchTimers, shouldPollRuntimeActivity]); - - useEffect(() => { - if (!manualSearchTarget) { setManualSearchResults([]); return; } - setManualSearchLoading(true); - const controller = new AbortController(); - const { service, title, retryId, seriesId, movieId, seasonNumbers } = manualSearchTarget; - const sn = seasonNumbers?.length === 1 ? seasonNumbers[0] : null; - const id = service === 'sonarr' ? (seriesId || retryId) : (movieId || retryId); - let url; - if (id) { - url = `/api/manual-search?service=${service}&id=${id}${sn ? `&seasonNumber=${sn}` : ''}`; - } else if (title) { - let q = title; - if (sn && service === 'sonarr') q += ` S${String(sn).padStart(2, '0')}`; - url = `/api/fast-search?query=${encodeURIComponent(q)}&service=${service}`; - } - apiFetch(url, { signal: controller.signal }) - .then(results => { - setManualSearchResults(Array.isArray(results) ? results : []); - setManualSearchLoading(false); - }).catch(e => { if (e.name !== 'AbortError') setManualSearchLoading(false); }); - return () => controller.abort(); - }, [manualSearchTarget]); - - useEffect(() => { - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission().catch(() => {}); - } - - const init = async () => { - setLoading(true); - const safePromise = shouldPollRuntimeActivity - ? Promise.all([fetchContainers(), fetchTorrents(), fetchTailscaleIp(), fetchSlskd(), fetchPendingSearches(), fetchArrQueue(), fetchStatus(), fetchSetupState()]) - : Promise.all([fetchContainers(), fetchStatus(), fetchSetupState()]); - await safePromise.finally(() => { - setLoading(false); - if (shouldPollRuntimeActivity) fetchMediaInfo(torrentsRef.current); - }); - }; - const refresh = async () => { - if (shouldPollRuntimeActivity) { - await Promise.all([fetchContainers(), fetchTorrents(), fetchSlskd(), fetchPendingSearches(), fetchArrQueue(), fetchStatus()]); - return; - } - await Promise.all([fetchContainers(), fetchStatus(), fetchSetupState()]); - }; - init().catch((e) => console.error('[init]', e.message)); - const t1 = setInterval(refresh, 5000); - const t2 = shouldPollRuntimeActivity ? setInterval(() => fetchTailscaleIp(), 60000) : null; - const t3 = shouldPollRuntimeActivity ? setInterval(() => fetchMediaInfo(torrentsRef.current), 30000) : null; - const t4 = setInterval(() => { - fetchStatus(); - fetchSetupState(); - }, 60000); - return () => { - clearInterval(t1); - if (t2) clearInterval(t2); - if (t3) clearInterval(t3); - clearInterval(t4); - }; - }, [fetchArrQueue, fetchContainers, fetchMediaInfo, fetchPendingSearches, fetchSetupState, fetchSlskd, fetchStatus, fetchTailscaleIp, fetchTorrents, shouldPollRuntimeActivity]); - - const torrentHashKey = torrents.map(t => t.hash).sort().join(','); - useEffect(() => { - if (!shouldPollRuntimeActivity) return; - const missing = torrents.filter(t => t.hash && !mediaInfo[t.hash]); - if (missing.length > 0) fetchMediaInfo(missing); - }, [torrentHashKey, mediaInfo, fetchMediaInfo, shouldPollRuntimeActivity]); - - useEffect(() => { - if (!shouldPollRuntimeActivity) return undefined; - const poll = async () => { - try { - const { dlSpeed, ulSpeed, dlTotal, ulTotal, lifetimeDl, lifetimeUl } = await apiFetch('/api/bandwidth'); - setBwHistory(prev => { - const next = [...prev, { dl: dlSpeed, ul: ulSpeed }]; - return next.length > 60 ? next.slice(-60) : next; - }); - setBwTotals({ dl: dlTotal, ul: ulTotal }); - setBwLifetime({ dl: lifetimeDl || 0, ul: lifetimeUl || 0 }); - } catch (e) { console.error('[fetchBandwidth]', e); } - }; - poll(); - const t = setInterval(poll, 3000); - return () => clearInterval(t); - }, [shouldPollRuntimeActivity]); - - useEffect(() => { - document.documentElement.setAttribute('data-theme', lightMode ? 'light' : 'dark'); - localStorage.setItem('theme', lightMode ? 'light' : 'dark'); - }, [lightMode]); - - useEffect(() => { - let resizeTimer; - const onResize = () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { - const mobile = window.innerWidth < BP.MOBILE; - setIsMobile(mobile); - setSidebarOpen(prev => { - if (mobile) return false; - if (prev === false) return true; - return prev; - }); - }, 100); - }; - window.addEventListener('resize', onResize); - return () => { window.removeEventListener('resize', onResize); clearTimeout(resizeTimer); }; - }, []); - - useEffect(() => { - if (setupFlow.shouldStayInSetup) { - setupGateWasUsedRef.current = true; - if (activeView !== 'settings') setActiveView('settings'); - return; - } - - if (!setupGateWasUsedRef.current || activeView !== 'settings' || !statusData) return; - setupGateWasUsedRef.current = false; - setActiveView(hasAnyLibraryServiceReady(statusData) ? 'library' : 'downloads'); - }, [activeView, setupFlow.shouldStayInSetup, statusData]); - - useEffect(() => { - if (awaitingBackendRestart) return; - if (activeView !== 'settings' && !setupFlow.shouldStayInSetup) return; - const timer = setInterval(() => { - fetchSetupState(); - }, 5000); - return () => clearInterval(timer); - }, [activeView, awaitingBackendRestart, fetchSetupState, setupFlow.shouldStayInSetup]); - - useEffect(() => { - if (!awaitingBackendRestart) return; - let cancelled = false; - const poll = async () => { - try { - const [status, setup] = await Promise.all([ - apiFetch('/api/status'), - apiFetch('/api/setup/state'), - ]); - if (cancelled) return; - setStatusData(status); - setSetupState(setup); - setStatusError(null); - setSetupStateError(null); - const nextSetupFlow = getSetupFlowState(status, setup); - if (!nextSetupFlow.shouldStayInSetup) { - setAwaitingBackendRestart(false); - setInstallingSetup(false); - setupGateWasUsedRef.current = false; - setActiveView(hasAnyLibraryServiceReady(status) ? 'library' : 'downloads'); - } - } catch (e) { - if (!cancelled) setStatusError(e); - } - }; - poll(); - const timer = setInterval(poll, 5000); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [awaitingBackendRestart]); - - const isSetupLocked = installingSetup || awaitingBackendRestart || setupFlow.shouldStayInSetup; - const requestView = (nextView) => { - if (!nextView || nextView === activeView) return; - if (isSetupLocked && activeView === 'settings' && nextView !== 'settings') return; - setActiveView(nextView); - }; - - const runAddSuccessRefreshBurst = useCallback(() => { - requestView('downloads'); - setStartingSearch(true); - clearStartingSearchTimers(); - refreshDownloadsState().catch((e) => console.error('[refreshDownloadsState]', e)); - ADD_SUCCESS_REFRESH_STEPS_MS.forEach((delayMs) => { - const timer = setTimeout(() => { - refreshDownloadsState().catch((e) => console.error('[refreshDownloadsState]', e)); - }, delayMs); - startingSearchTimersRef.current.push(timer); - }); - }, [clearStartingSearchTimers, refreshDownloadsState, requestView]); - - const handleLibraryMediaAdded = useCallback(() => { - runAddSuccessRefreshBurst(); - const stopTimer = setTimeout(() => setStartingSearch(false), ADD_SUCCESS_PLACEHOLDER_TTL_MS); - startingSearchTimersRef.current.push(stopTimer); - }, [runAddSuccessRefreshBurst]); - + const { manualSearchResults, manualSearchLoading } = useManualSearch(manualSearchTarget); + + const { + activeView, + setActiveView, + headerQuery, + setHeaderQuery, + isMobile, + sidebarOpen, + setSidebarOpen, + mobileSearchOpen, + mobileSearchValue, + lightMode, + setLightMode, + handleMobileSearchChange, + toggleMobileSearch, + clearMobileSearch, + } = useLayoutPrefs(); const { running, allHealthy, sortedContainers } = useMemo(() => { const running = containers.filter(c => c.running); @@ -1434,282 +71,91 @@ export default function App() { return { running, allHealthy, sortedContainers }; }, [containers]); - useEffect(() => () => clearStartingSearchTimers(), [clearStartingSearchTimers]); - - useEffect(() => { - if (!startingSearch) return; - const hasVisibleWork = pipeline.length + arrQueue.length + torrents.length + slskdDownloads.length + pendingSearches.length > 0; - if (hasVisibleWork) { - setStartingSearch(false); - clearStartingSearchTimers(); - } - }, [startingSearch, pipeline.length, arrQueue.length, torrents.length, slskdDownloads.length, pendingSearches.length, clearStartingSearchTimers]); - - const { downloading, totalDl, totalUl } = useMemo(() => ({ - downloading: torrents.filter(t => getTorrentState(t) === 'downloading'), - totalDl: torrents.reduce((s, t) => s + (t.downloadSpeed || 0), 0), - totalUl: torrents.reduce((s, t) => s + (t.uploadSpeed || 0), 0), - }), [torrents]); - - const serviceStatuses = statusData?.services || {}; - const libraryServicesReady = useMemo(() => ({ - movie: ['up', 'ready'].includes(serviceStatuses.radarr?.status), - series: ['up', 'ready'].includes(serviceStatuses.sonarr?.status), - music: ['up', 'ready'].includes(serviceStatuses.lidarr?.status), - }), [serviceStatuses]); - const anyLibraryServiceReady = libraryServicesReady.movie || libraryServicesReady.series || libraryServicesReady.music; - const searchDisabled = activeView === 'settings' || !anyLibraryServiceReady; + const { downloading, totalDl, totalUl } = useMemo( + () => ({ + downloading: torrents.filter(t => getTorrentState(t) === 'downloading'), + totalDl: torrents.reduce((s, t) => s + (t.downloadSpeed || 0), 0), + totalUl: torrents.reduce((s, t) => s + (t.uploadSpeed || 0), 0), + }), + [torrents], + ); - // Memoized callback handlers for child components const handleSlskdUpdate = useCallback(() => fetchSlskd(), [fetchSlskd]); const handleTorrentRefresh = useCallback(() => fetchTorrents(), [fetchTorrents]); - const handleSidebarClose = useCallback(() => setSidebarOpen(false), []); - const handleManualSearchClose = useCallback(() => setManualSearchTarget(null), []); - const handleStatusRefresh = useCallback(() => fetchStatus(), [fetchStatus]); - const handleSetupInstall = useCallback(async (payload) => { - setInstallingSetup(true); - setSetupInstallError(null); - setSetupInstallResult(null); - try { - const data = await apiPost('/api/setup/install', payload); - setSetupInstallResult(data); - if (data.restartScheduled) setAwaitingBackendRestart(true); - else setInstallingSetup(false); - } catch (e) { - setSetupInstallError(e); - setInstallingSetup(false); - } - }, []); - - const pipelineStages = useMemo(() => ({ - searching: pipeline.filter(p => p.stage === 'searching'), - downloading: pipeline.filter(p => p.stage === 'downloading'), - stuck: pipeline.filter(p => p.stage === 'stuck'), - }), [pipeline]); - const hasDownloadsOrSearchWork = pipeline.length + arrQueue.length + torrents.length + slskdDownloads.length + pendingSearches.length > 0; - const showStartingSearchMessage = startingSearch && !hasDownloadsOrSearchWork; + const handleSidebarClose = useCallback(() => setSidebarOpen(false), [setSidebarOpen]); + const handleSidebarOpen = useCallback(() => setSidebarOpen(true), [setSidebarOpen]); + const handleSidebarToggle = useCallback(() => setSidebarOpen(o => !o), [setSidebarOpen]); if (loading) return ; return ( -
- - {/* ── Left Rail ── */} - +
+ {!isMobile && ( + + )} - {/* ── Main Column ── */}
- - {/* Header */} -
- - vibarr - - - {!isMobile && ( -
-
- - { setHeaderQuery(e.target.value); requestView('library'); }} - onFocus={() => { if (!searchDisabled) requestView('library'); }} - style={{ - width: '100%', - background: 'var(--surface)', - border: '1px solid var(--border-subtle)', - borderRadius: 10, - padding: '8px 14px 8px 36px', - color: searchDisabled ? 'var(--text-disabled)' : 'var(--text-primary)', - fontSize: 13.5, - outline: 'none', - fontFamily: 'inherit', - transition: 'border-color 0.2s, background 0.2s', - cursor: searchDisabled ? 'not-allowed' : 'text', - }} - /> -
-
- )} - - {isMobile && ( - - )} - -
- -
- {!isMobile && ( - 0 ? 1 : 0.28, transition: 'opacity 0.4s' }}> - - {totalDl > 0 ? formatSpeed(totalDl) : '—'} - - )} - {!isMobile && ( - 0 ? 1 : 0.28, transition: 'opacity 0.4s' }}> - - {totalUl > 0 ? formatSpeed(totalUl) : '—'} - - )} -
-
- - {running.length}/{containers.length} - -
- - {isMobile && ( - - )} -
-
- - {isMobile && mobileSearchOpen && !searchDisabled && ( -
+ + {isMobile && mobileSearchOpen && ( +
+ borderBottom: '1px solid var(--border-subtle)', + flexShrink: 0, + }} + >
- - - + + +
)} - {/* Service Strip */} -
- {sortedContainers.map(c => { - const name = cleanName(c.name); - const port = c.ports?.find(p => p.host) || c.ports?.[0]; - const href = c.running - ? (tailscaleIp && port - ? `http://${tailscaleIp}:${port.host}` - : getServiceUrl(name)) - : null; - return href ? ( - - - - ) : ( -
- ); - })} -
- - {/* Content Area */} -
+ {!isMobile && } - {/* Main Content Pane */} -
+
+
{activeView === 'library' && ( - requestView('settings')} - onAdded={handleLibraryMediaAdded} - /> + )} {activeView === 'downloads' && ( -
- {pipeline.length > 0 && ( -
-
-

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

-
-
- {pipeline.map(item => ( - setExpandedPipelineKey(prev => prev === item.key ? null : item.key)} - onRetry={async (key) => { - await apiPost(`/api/pipeline/${encodeURIComponent(key)}/retry`); - }} - onCancel={async (key) => { - await apiDelete(`/api/pipeline/${encodeURIComponent(key)}/cancel`); - fetchPendingSearches(); - }} - onMonitor={async (key) => { - await apiPost(`/api/pipeline/${encodeURIComponent(key)}/monitor`); - fetchPendingSearches(); - }} - onManualSearch={(item) => setManualSearchTarget(item)} - onDismiss={async (key) => { - await apiDelete(`/api/pipeline/${encodeURIComponent(key)}`); - fetchPendingSearches(); - }} - /> - ))} -
-
- )} - {pendingSearches.length > 0 && ( -
-
-

Recent Searches

- - {pendingSearches.length} - -
-
- {pendingSearches.map((search) => ( - - ))} -
-
- )} - {torrents.length > 0 && } - {slskdDownloads.length > 0 && ( -
-

Soulseek

-
- {slskdDownloads.map((dl, i) => ( - { - await apiDelete(`/api/slskd/downloads/${encodeURIComponent(u)}`); - handleSlskdUpdate(); - }} - onRetry={async (u, fid) => { - await apiPost('/api/slskd/retry', { username: u, fileId: fid }); - setTimeout(handleSlskdUpdate, 1000); - }} - /> - ))} -
-
- )} - {manualSearchTarget && ( - - { - await apiPost('/api/grab', { - service: manualSearchTarget.service, - guid: release.guid, - indexerId: release.indexerId, - pipelineKey: manualSearchTarget.key, - downloadUrl: release.downloadUrl || undefined, - title: release.title, - }); - setTimeout(() => { setManualSearchTarget(null); fetchPendingSearches(); }, 800); - }} - /> - - )} - {!hasDownloadsOrSearchWork && ( -
- {torrentError ? ( -
-

qBittorrent unavailable

-

{torrentError}

-
- ) : ( -
- download_done -

- {showStartingSearchMessage ? 'Starting search…' : 'All clear'} -

-

- {showStartingSearchMessage ? 'Watch for the new search to appear in active items' : 'No active transfers'} -

-
- )} -
- )} -
- )} - - {activeView === 'settings' && ( - { - handleStatusRefresh(); - fetchSetupState(); - }} - onContinue={() => requestView(anyLibraryServiceReady ? 'library' : 'downloads')} - onInstall={handleSetupInstall} - installing={installingSetup} - installError={setupInstallError} - installResult={setupInstallResult} - awaitingBackendRestart={awaitingBackendRestart} + )}
- {/* Side Panel */} -
-
- - {isMobile && ( - + )} +
+
+ + {isMobile && ( + )}
); diff --git a/frontend/src/Library.jsx b/frontend/src/Library.jsx index 3fac836..a9210b1 100644 --- a/frontend/src/Library.jsx +++ b/frontend/src/Library.jsx @@ -1,2630 +1 @@ -import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; - -// Inject shimmer + pill keyframes once (CSS-only, no new deps, no App.css edits) -if (typeof document !== 'undefined' && !document.getElementById('library-shimmer-style')) { - const _s = document.createElement('style'); - _s.id = 'library-shimmer-style'; - _s.textContent = `@keyframes posterShimmer { 0% { background-position: -150% 0, 0 0; } 100% { background-position: 150% 0, 0 0; } } .pill-springy { transition: background-color 180ms ease, color 180ms ease, box-shadow 180ms ease, transform 220ms cubic-bezier(0.34,1.56,0.64,1); } .pill-springy:active { transform: scale(0.94); } .library-grid-fade { animation: gridFadeIn 220ms ease-out; } @keyframes gridFadeIn { from { opacity: 0; } to { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .library-grid-fade { animation: none !important; } .pill-springy { transition: none !important; } }`; - document.head.appendChild(_s); -} - -import { formatBytes, gradientFor, extractRating, detectQualityLabel } from './utils'; -import { apiFetch, apiPost, getApiErrorDetails } from './api'; -import PosterImage from './PosterImage'; - -function useAnimatedClose(onClose, duration = 200) { - const [closing, setClosing] = useState(false); - const timerRef = useRef(null); - const close = useCallback(() => { - setClosing(true); - timerRef.current = setTimeout(onClose, duration); - }, [onClose, duration]); - useEffect(() => () => clearTimeout(timerRef.current), []); - return { closing, close }; -} - -const TYPE_FILTERS = [ - { key: 'all', label: 'All', icon: 'apps' }, - { key: 'series', label: 'TV', icon: 'tv' }, - { key: 'movie', label: 'Movies', icon: 'movie' }, - { key: 'music', label: 'Music', icon: 'album' }, - { key: 'missing', label: 'Missing', icon: 'warning' }, -]; - -function isServiceIssueStatus(status) { - return !['ready', 'up', 'unconfigured', null, undefined].includes(status); -} - -function isSetupAdjacentError(error) { - const details = getApiErrorDetails(error); - if (!details) return false; - const text = `${details.message || ''} ${details.endpoint || ''}`.toLowerCase(); - return ( - [409, 423, 424, 429, 500, 502, 503, 504].includes(details.status) || - /setup|install|token|auth|not ready|not configured|unconfigured|unavailable|restart|recover/.test(text) - ); -} - -function LibraryErrorNotice({ error, title, tone = 'error' }) { - const details = getApiErrorDetails(error); - if (!details) return null; - const accent = tone === 'warning' ? 'text-accent-orange' : 'text-accent-red'; - const meta = [ - details.method && details.endpoint ? `${details.method} ${details.endpoint}` : details.endpoint, - details.status ? `HTTP ${details.status}` : null, - details.durationMs ? `${details.durationMs}ms` : null, - details.attempt ? `attempt ${details.attempt}` : null, - details.retryAfterMs ? `retry in ${Math.max(1, Math.ceil(details.retryAfterMs / 1000))}s` : null, - details.requestId ? `request ${details.requestId}` : null, - details.clientRequestId ? `client ${details.clientRequestId}` : null, - ].filter(Boolean); - return ( -
- {title &&

{title}

} -

{details.message}

- {meta.length > 0 && ( -

{meta.join(' · ')}

- )} - {details.warnings.length > 0 && ( -

Warnings: {details.warnings.join(' · ')}

- )} -
- ); -} - -// ── Shared Poster Component ───────────────────────────────────────────────── - -function PosterImg({ url, fallbackIcon, title }) { - return ( - - ); -} - - - - -function Select({ label, value, onChange, options, className }) { - return ( -
- - -
- ); -} - -// ── Manual Search View ────────────────────────────────────────────────────── - - -const QUALITY_ORDER = { '4K': 0, '1080p': 1, '720p': 2, '480p': 3, 'HDTV': 4, 'WEB': 5, 'BluRay': 6, 'CAM': 7, 'Other': 8 }; - -function normalizeProfileName(name) { - return String(name || '').toLowerCase(); -} - -function selectDefaultQualityProfileId(qualityProfiles, mediaType) { - if (!Array.isArray(qualityProfiles) || qualityProfiles.length === 0) return null; - const normalized = qualityProfiles.map(profile => ({ - ...profile, - _name: normalizeProfileName(profile?.name), - })); - const findByPatterns = (patterns) => normalized.find(({ _name }) => patterns.some(pattern => pattern.test(_name)))?.id; - - if (mediaType === 'music') { - return findByPatterns([/\blossless\b/]) || normalized[0].id; - } - - if (mediaType === 'series' || mediaType === 'movie') { - const ultra = findByPatterns([/\b2160p\b/, /\b4k\b/, /\bultra[-\s]?hd\b/, /\buhd\b/]); - if (ultra) return ultra; - const sense = findByPatterns([/\bhd - 720p\/1080p\b/, /\bhd[-\s]?1080p\b/, /\b1080p\b/, /\b1080\b/]); - if (sense) return sense; - const fallback = findByPatterns([/\b720p\b/, /\bhd\b/]); - if (fallback) return fallback; - return normalized[0].id; - } - - return normalized[0].id; -} - -function detectResolution(text) { - if (/2160p|4k(?!\w)|uhd/i.test(text)) return '2160p'; - if (/1080p/i.test(text)) return '1080p'; - if (/720p/i.test(text)) return '720p'; - if (/576p|480p/i.test(text)) return '480p'; - return null; -} - -function detectSource(text) { - if (/remux/i.test(text)) return 'Remux'; - if (/blu-?ray|bdrip|brrip|bdremux/i.test(text)) return 'Bluray'; - if (/web-?dl|webdl/i.test(text)) return 'WEBDL'; - if (/webrip/i.test(text)) return 'WEBRip'; - if (/hdtv/i.test(text)) return 'HDTV'; - if (/dvdrip|dvdscr|dvd/i.test(text)) return 'DVD'; - if (/\bcam\b|telesync|\bts\b|telecine/i.test(text)) return 'CAM'; - return 'Unknown'; -} - -function detectCodec(title) { - const t = title || ''; - if (/\bAV1\b/i.test(t)) return 'AV1'; - if (/\bHEVC\b|\bx265\b|h\.?265/i.test(t)) return 'x265'; - if (/\bx264\b|\bh\.?264\b|\bAVC\b/i.test(t)) return 'x264'; - return null; -} - -function ManualSearchView({ service, id, seasonNumber, title, onGrabbed }) { - const [releases, setReleases] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [sortBy, setSortBy] = useState('smart'); - const [sortDir, setSortDir] = useState('desc'); - const [exactMatchOnly, setExactMatchOnly] = useState(false); - const [qualityFilters, setQualityFilters] = useState([]); - const [minSeeders, setMinSeeders] = useState(0); - const [showCount, setShowCount] = useState(50); - const [grabbing, setGrabbing] = useState(null); - const [grabResult, setGrabResult] = useState(null); - - useEffect(() => { - setLoading(true); setError(null); setReleases([]); - setQualityFilters([]); setMinSeeders(0); setShowCount(50); - let url; - if (id) { - url = `/api/manual-search?service=${service}&id=${id}${seasonNumber ? `&seasonNumber=${seasonNumber}` : ''}`; - } else if (title) { - let q = title; - if (seasonNumber && service === 'sonarr') q += ` S${String(seasonNumber).padStart(2, '0')}`; - url = `/api/fast-search?query=${encodeURIComponent(q)}&service=${service}`; - } - if (!url) { - setError('Missing manual-search target'); - setLoading(false); - return; - } - apiFetch(url) - .then(data => { setReleases(Array.isArray(data) ? data : []); setLoading(false); }) - .catch(e => { setError(String(e)); setLoading(false); }); - }, [service, id, seasonNumber, title]); - - const toggleSort = (col) => { - if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc'); - else { setSortBy(col); setSortDir('desc'); } - }; - - const toggleQuality = (q) => { - setQualityFilters(prev => prev.includes(q) ? prev.filter(x => x !== q) : [...prev, q]); - setShowCount(50); - }; - - // Build sorted+filtered set - const { sorted, allQualities } = useMemo(() => { - let filtered = exactMatchOnly ? releases.filter(r => !r.rejected) : releases; - if (qualityFilters.length > 0) filtered = filtered.filter(r => qualityFilters.includes(detectQualityLabel(r))); - if (minSeeders > 0) filtered = filtered.filter(r => (r.seeders || 0) >= minSeeders); - - const _maxSeeders = Math.max(1, ...filtered.map(r => r.seeders || 0)); - const RES_SCORE = { '2160p': 4, '1080p': 3, '720p': 2, '480p': 1 }; - const SRC_SCORE = { Remux: 5, Bluray: 4, WEBDL: 3, WEBRip: 2, HDTV: 1, DVD: 1, CAM: 0, Unknown: 1 }; - const smartScore = r => { - const combined = (r.quality || '') + ' ' + (r.title || ''); - const resScore = RES_SCORE[detectResolution(combined)] ?? 0; - const srcScore = SRC_SCORE[detectSource(combined)] ?? 1; - // resolution is primary (5x weight), source is secondary tiebreaker - const qualNorm = (resScore * 5 + srcScore) / 25; - // penalize files >40 GB (remux overkill) or suspiciously tiny <300 MB - const sizeGB = (r.size || 0) / 1073741824; - const sizePenalty = sizeGB > 40 ? Math.max(0.4, 1 - (sizeGB - 40) / 100) - : sizeGB > 0 && sizeGB < 0.3 ? 0.65 : 1.0; - // seeder floor: dead torrents rank last regardless of quality - const seeders = r.seeders || 0; - const seederFloor = seeders < 5 ? 0.2 : seeders < 15 ? 0.6 : seeders < 30 ? 0.85 : 1.0; - const seedNorm = seeders / _maxSeeders; - return (qualNorm * sizePenalty * 0.65 + seedNorm * 0.35) * seederFloor; - }; - const sorted = [...filtered].sort((a, b) => { - let cmp = 0; - if (sortBy === 'smart') cmp = smartScore(b) - smartScore(a); - else if (sortBy === 'seeders') cmp = (b.seeders || 0) - (a.seeders || 0); - else if (sortBy === 'size') cmp = (b.size || 0) - (a.size || 0); - else if (sortBy === 'quality') cmp = (QUALITY_ORDER[detectQualityLabel(a)] ?? 99) - (QUALITY_ORDER[detectQualityLabel(b)] ?? 99); - else cmp = (b.seeders || 0) - (a.seeders || 0) || (b.size || 0) - (a.size || 0); - return sortDir === 'asc' ? -cmp : cmp; - }); - // Unique quality labels across ALL releases (not filtered) for the chips - const allQualities = [...new Set(releases.map(r => detectQualityLabel(r)))] - .sort((a, b) => (QUALITY_ORDER[a] ?? 99) - (QUALITY_ORDER[b] ?? 99)); - return { sorted, allQualities }; - }, [releases, exactMatchOnly, qualityFilters, minSeeders, sortBy, sortDir]); - - const visible = sorted.slice(0, showCount); - const hasMore = sorted.length > showCount; - - const handleGrab = async (r) => { - setGrabbing(r.guid); setGrabResult(null); - try { - await apiPost('/api/grab', { service, guid: r.guid, indexerId: r.indexerId, downloadUrl: r.downloadUrl || undefined, title: r.title }); - setGrabResult({ success: true, message: `Grabbed "${r.title}"` }); - onGrabbed?.(); - } catch (e) { setGrabResult({ success: false, message: e.message }); } - setGrabbing(null); - }; - - return ( -
- {/* ── Top control bar: Exact Match + Sort ── */} -
- -
- Sort: - {[['smart', 'Smart'], ['seeders', 'Seeds'], ['size', 'Size'], ['quality', 'Quality']].map(([val, label]) => ( - - ))} -
-
- - {/* ── Quality chips + Min seeders (only when results are loaded) ── */} - {!loading && releases.length > 0 && ( -
- {allQualities.length > 1 && ( -
- Q: - {allQualities.map(q => ( - - ))} - {qualityFilters.length > 0 && ( - - )} -
- )} -
- Seeds: - {[[0,'Any'],[10,'10+'],[25,'25+'],[50,'50+']].map(([val, label]) => ( - - ))} -
-
- )} - - {loading ? ( -
- progress_activity - Searching indexers... -
- ) : error ? ( -

{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 ( -
-
Why rejected
- {entries.map(([cat, count]) => ( -
- {count}× - {cat} -
- ))} -
- ); - })()} -
- ) : ( - <> - {/* ── Result count ── */} -
- - Showing {visible.length} of {sorted.length} result{sorted.length !== 1 ? 's' : ''} - {releases.length > sorted.length ? ` · ${releases.length - sorted.length} hidden by filters` : ''} - - {sorted.length > 0 && !grabResult?.success && ( - - )} -
-
- {visible.map((r, i) => ( -
-
-
- {i === 0 && sortBy === 'smart' && ( - - )} -
{r.title}
-
-
- {r.indexer} - {r.quality} - {detectCodec(r.title) && ( - {detectCodec(r.title)} - )} - {formatBytes(r.size)} - = 10 ? 'text-accent-green' : r.seeders >= 3 ? 'text-accent-orange' : 'text-accent-red'}`}>{r.seeders}↑ {r.leechers}↓ - {r.ageHours < 24 ? `${Math.round(r.ageHours)}h` : `${Math.round(r.ageHours / 24)}d`} -
-
- -
- ))} -
- {hasMore && ( - - )} - - )} - {grabResult && ( -
- {grabResult.message} -
- )} -
- ); -} - -// ── Season Selector ───────────────────────────────────────────────────────── - -function SeasonSelector({ seasons, selected, onChange }) { - if (!seasons?.length) return null; - const selectableSeasons = seasons.filter(s => s.seasonNumber > 0); - if (!selectableSeasons.length) return null; - const toggleSeason = (sn) => { - onChange(selected.includes(sn) ? selected.filter(s => s !== sn) : [...selected, sn]); - }; - const selectableSeasonNumbers = selectableSeasons.map(s => s.seasonNumber); - const allSelected = selectableSeasonNumbers.every(sn => selected.includes(sn)); - const toggleAll = () => { - onChange(allSelected - ? selected.filter(sn => !selectableSeasonNumbers.includes(sn)) - : Array.from(new Set([...selected, ...selectableSeasonNumbers])) - ); - }; - return ( -
-
- - -
-
- {selectableSeasons.map(s => ( - - ))} -
-
- ); -} - -// ── Album Selector (for library download) ─────────────────────────────────── - -function AlbumSelector({ albums, selected, onChange }) { - if (!albums?.length) return null; - const toggle = (id) => { - onChange(selected.includes(id) ? selected.filter(a => a !== id) : [...selected, id]); - }; - const missing = albums.filter(a => a.trackFileCount < a.trackCount); - const selectMissing = () => onChange(missing.map(a => a.id)); - return ( -
-
- - {missing.length > 0 && ( - - )} -
-
- {albums.map(a => ( - - ))} -
-
- ); -} - -// ── Series Detail Modal ───────────────────────────────────────────────────── - -function SeriesDetail({ seriesId, onClose, onDelete }) { - const { closing, close } = useAnimatedClose(onClose); - const [episodes, setEpisodes] = useState(null); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [expandedSeason, setExpandedSeason] = useState(null); - const [expandedEpisode, setExpandedEpisode] = useState(null); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [searching, setSearching] = useState(false); - const [searchResult, setSearchResult] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [manualMode, setManualMode] = useState(false); - - const fetchEpisodes = (isRefresh = false) => { - if (isRefresh) setRefreshing(true); else setLoading(true); - fetch(`/api/library/series/${seriesId}/episodes`, { cache: 'no-store' }) - .then(r => r.ok ? r.json() : null) - .then(data => { setEpisodes(data?.seasons || {}); }) - .catch(() => {}) - .finally(() => { setLoading(false); setRefreshing(false); }); - }; - - useEffect(() => { fetchEpisodes(); }, [seriesId]); - - const seasonNums = episodes ? Object.keys(episodes).map(Number).sort((a, b) => a - b) : []; - const missingSeason = (sn) => (episodes[sn] || []).filter(e => !e.hasFile).length; - const totalMissing = seasonNums.reduce((acc, sn) => acc + missingSeason(sn), 0); - const allComplete = !loading && seasonNums.length > 0 && totalMissing === 0; - - const selectedComplete = selectedSeasons.filter(sn => missingSeason(sn) === 0); - const selectedStillMissing = selectedSeasons.filter(sn => missingSeason(sn) > 0); - - const handleDownload = async () => { - if (selectedSeasons.length === 0) return; - setSearching(true); - setSearchResult(null); - try { - const resp = await fetch('/api/command/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service: 'sonarr', id: seriesId, seasonNumbers: selectedSeasons }), - }); - const data = await resp.json(); - setSearchResult(resp.ok ? { success: true, message: `Search triggered for ${selectedSeasons.length} season(s) — check back soon` } : { success: false, message: data.error }); - } catch (err) { setSearchResult({ success: false, message: err.message }); } - setSearching(false); - }; - - const handleDelete = async (deleteFiles) => { - setDeleting(true); - try { - const resp = await fetch(`/api/delete/series/${seriesId}?deleteFiles=${deleteFiles}`, { method: 'DELETE' }); - if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.error?.substring(0, 100) || 'Delete failed'); } - onDelete?.(); - onClose(); - } catch (err) { setSearchResult({ success: false, message: `Delete failed: ${err.message}` }); } - setDeleting(false); - setConfirmDelete(false); - }; - - return ( -
-
e.stopPropagation()}> -
-

Seasons & Episodes

-
- - - -
-
- {confirmDelete && ( -
-

Delete this series?

-
- - - -
-
- )} -
- {loading ? ( -
- progress_activity -
- ) : seasonNums.length === 0 ? ( -

No episodes found

- ) : ( -
- {seasonNums.map(sn => { - const eps = episodes[sn]; - const downloaded = eps.filter(e => e.hasFile).length; - const missing = eps.length - downloaded; - const isOpen = expandedSeason === sn; - const isSelected = selectedSeasons.includes(sn); - const seasonComplete = missing === 0; - return ( -
-
- {missing > 0 ? ( - setSelectedSeasons(prev => isSelected ? prev.filter(s => s !== sn) : [...prev, sn])} - className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer" - /> - ) : ( - check_circle - )} - - - {downloaded}/{eps.length} - -
-
0 ? (downloaded / eps.length) * 100 : 0}%`, background: seasonComplete ? '#16a34a' : '#d97706' }} /> -
-
- {isOpen && ( -
- {eps.map(ep => { - const epExpanded = expandedEpisode === ep.id; - return ( -
-
ep.hasFile && setExpandedEpisode(epExpanded ? null : ep.id)} - > -
- {ep.imageUrl && ( - - )} - E{String(ep.episodeNumber).padStart(2, '0')} - - {ep.title || 'TBA'} - {ep.runTime && {ep.runTime}} - {!ep.runTime && ep.runtime && {ep.runtime}m} - {ep.quality && {ep.quality}} - {ep.size > 0 && {formatBytes(ep.size)}} - {ep.hasFile && {epExpanded ? 'expand_less' : 'expand_more'}} -
-
- {epExpanded && ep.hasFile && ( -
-
-
- {ep.resolution && {ep.resolution}} - {ep.videoCodec && {ep.videoCodec}} - {ep.dynamicRange && ep.dynamicRange !== 'SDR' && {ep.dynamicRange}} - {ep.audioCodec && {ep.audioCodec}{ep.audioChannels ? ` ${ep.audioChannels}ch` : ''}} - {ep.audioLanguages && {ep.audioLanguages}} - {ep.subtitles && Sub: {ep.subtitles}} -
- {ep.filePath && ( -
- folder - {ep.filePath} -
- )} -
-
- )} -
- ); - })} -
- )} -
- ); - })} -
- )} -
- {/* Footer: completion status or download controls */} - {!loading && seasonNums.length > 0 && ( -
- {allComplete ? ( -
- check_circle -
-

All content downloaded

-

{seasonNums.filter(sn => sn > 0).length} season(s) · {seasonNums.reduce((a, sn) => a + (episodes[sn]?.length || 0), 0)} episodes

-
-
- ) : ( - <> - {selectedSeasons.length > 0 && selectedComplete.length > 0 && ( -
- Season(s) {selectedComplete.map(sn => sn === 0 ? 'Specials' : `S${String(sn).padStart(2, '0')}`).join(', ')} — complete - {selectedStillMissing.length > 0 && · {selectedStillMissing.map(sn => sn === 0 ? 'Specials' : `S${String(sn).padStart(2, '0')}`).join(', ')} still downloading ({selectedStillMissing.reduce((a, sn) => a + missingSeason(sn), 0)} ep missing)} -
- )} - {searchResult && ( -
- {searchResult.message} -
- )} -
- - {totalMissing} episode{totalMissing !== 1 ? 's' : ''} missing · {selectedSeasons.length} season(s) selected - {selectedSeasons.length === 0 && ( - - )} - -
- - -
-
- {manualMode && selectedSeasons.length > 0 && ( - setSearchResult({ success: true, message: 'Release grabbed — downloading shortly' })} - /> - )} - - )} -
- )} -
-
- ); -} -// ── Artist Detail Modal ───────────────────────────────────────────────────── - -function ArtistDetail({ artistId, onClose, onDelete }) { - const { closing, close } = useAnimatedClose(onClose); - const [albums, setAlbums] = useState(null); - const [artistInfo, setArtistInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [selectedAlbums, setSelectedAlbums] = useState([]); - const [searching, setSearching] = useState(false); - const [searchResult, setSearchResult] = useState(null); - const [fileTree, setFileTree] = useState(null); - const [showFiles, setShowFiles] = useState(false); - const [expandedFolder, setExpandedFolder] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [confirmDeleteAlbum, setConfirmDeleteAlbum] = useState(null); - // Discover-more state - const [discoverAlbums, setDiscoverAlbums] = useState(null); - const [discoverLoading, setDiscoverLoading] = useState(false); - const [showDiscover, setShowDiscover] = useState(false); - const [discoverFilter, setDiscoverFilter] = useState('all'); // all|Album|EP|Single - const [discoverSelected, setDiscoverSelected] = useState([]); - const [addingExtras, setAddingExtras] = useState(false); - const [discoverTextFilter, setDiscoverTextFilter] = useState(''); - - useEffect(() => { - setLoading(true); - Promise.all([ - fetch(`/api/library/artists/${artistId}/albums`, { cache: 'no-store' }).then(r => r.ok ? r.json() : null), - fetch(`/api/library/artists/${artistId}/files`, { cache: 'no-store' }).then(r => r.ok ? r.json() : null), - ]).then(([albumData, fileData]) => { - setAlbums(albumData?.albums || []); - setArtistInfo(albumData?.artist || null); - setFileTree(fileData); - setLoading(false); - }).catch(() => setLoading(false)); - }, [artistId]); - - const loadDiscover = async () => { - if (!artistInfo?.artistName) return; - setShowDiscover(true); - if (discoverAlbums) return; - setDiscoverLoading(true); - try { - const url = `/api/lookup/music/albums?artistName=${encodeURIComponent(artistInfo.artistName)}&foreignArtistId=${encodeURIComponent(artistInfo.foreignArtistId || '')}`; - const data = await fetch(url, { cache: 'no-store' }).then(r => r.ok ? r.json() : []); - // Dedupe against albums already in Lidarr (normalized title match) - const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') - .replace(/\s*\(.*?\)\s*/g, ' ').replace(/\s*\[.*?\]\s*/g, ' ') - .replace(/ - (single|ep)$/i, '').replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ').trim(); - const existing = new Set((albums || []).map(a => norm(a.title))); - const filtered = (Array.isArray(data) ? data : []).filter(a => !existing.has(norm(a.title))); - setDiscoverAlbums(filtered); - } catch (e) { - setDiscoverAlbums([]); - } - setDiscoverLoading(false); - }; - - const submitExtras = async () => { - if (discoverSelected.length === 0 || !discoverAlbums) return; - setAddingExtras(true); - setSearchResult(null); - try { - const titles = discoverAlbums - .filter(a => discoverSelected.includes(a.id)) - .map(a => a.title.replace(/ - Single$/, '')); - const resp = await fetch(`/api/library/artists/${artistId}/albums/add`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ selectedAlbumTitles: titles }), - }); - const data = await resp.json(); - if (resp.ok) { - setSearchResult({ success: true, - message: `Adding ${titles.length} album(s) — ${data.monitored} matched in Lidarr, ${data.unmatched?.length || 0} via Soulseek` }); - setDiscoverSelected([]); - // Refresh library albums after a delay - setTimeout(() => { - fetch(`/api/library/artists/${artistId}/albums`, { cache: 'no-store' }) - .then(r => r.ok ? r.json() : null) - .then(d => { if (d?.albums) setAlbums(d.albums); }); - // Force re-fetch discover (some moved to library) - setDiscoverAlbums(null); - }, 4000); - } else { - setSearchResult({ success: false, message: data.error || 'Add failed' }); - } - } catch (e) { - setSearchResult({ success: false, message: e.message }); - } - setAddingExtras(false); - }; - - const handleDownload = async () => { - if (selectedAlbums.length === 0) return; - setSearching(true); - setSearchResult(null); - try { - const resp = await fetch('/api/command/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service: 'lidarr', id: artistId, albumIds: selectedAlbums }), - }); - const data = await resp.json(); - setSearchResult(resp.ok ? { success: true, message: `Search triggered for ${selectedAlbums.length} album(s)` } : { success: false, message: data.error }); - } catch (err) { setSearchResult({ success: false, message: err.message }); } - setSearching(false); - }; - - const isAlbumComplete = a => a.trackFileCount > 0 && a.trackFileCount >= a.trackCount && a.trackCount > 0; - const missingAlbums = albums?.filter(a => !isAlbumComplete(a)) || []; - const downloadedAlbums = albums?.filter(a => isAlbumComplete(a)) || []; - const allAlbumsComplete = !loading && albums?.length > 0 && missingAlbums.length === 0; - - const handleDeleteAlbumFiles = async (albumId) => { - try { - await fetch(`/api/delete/album/${albumId}/files`, { method: 'DELETE' }); - // Refresh album list - const data = await fetch(`/api/library/artists/${artistId}/albums`, { cache: 'no-store' }).then(r => r.json()); - setAlbums(data?.albums || []); - setSearchResult({ success: true, message: 'Album files removed from disk' }); - } catch (err) { setSearchResult({ success: false, message: err.message }); } - }; - - return ( -
-
e.stopPropagation()}> -
-

Albums

-
- - -
-
- {confirmDelete && ( -
-

Delete this artist?

-
- - - -
-
- )} -
- {loading ? ( -
- progress_activity -
- ) : albums.length === 0 ? ( -

No albums found

- ) : ( -
- {albums.map(a => { - const isMissing = a.trackFileCount < a.trackCount; - const isSelected = selectedAlbums.includes(a.id); - return ( -
- {!isAlbumComplete(a) ? ( - setSelectedAlbums(prev => isSelected ? prev.filter(id => id !== a.id) : [...prev, a.id])} - className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer flex-none" - /> - ) : ( - check_circle - )} - -
-

{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)}`} -

-
- {a.percentOfTracks > 0 && ( -
-
-
= 100 ? '#16a34a' : '#d97706' }} /> -
- {Math.round(a.percentOfTracks)}% -
- )} - {isAlbumComplete(a) && ( - confirmDeleteAlbum === a.id ? ( -
- - -
- ) : ( - - ) - )} -
- ); - })} -
- )} -
- {/* Discover more section */} - {!loading && artistInfo?.artistName && ( -
- - {showDiscover && ( -
- {discoverLoading ? ( -
- progress_activity -
- ) : !discoverAlbums || discoverAlbums.length === 0 ? ( -

No additional releases found from iTunes/MusicBrainz.

- ) : ( - <> -
- -
- - - - -
-
-
- {['all', 'Album', 'EP', 'Single'].map(t => { - const count = t === 'all' ? discoverAlbums.length : discoverAlbums.filter(a => a.albumType === t).length; - return ( - - ); - })} -
-
- search - setDiscoverTextFilter(e.target.value)} - placeholder="Filter releases..." - className="w-full pl-7 pr-2 py-1.5 text-[11px] bg-[#e8e8ed]/50 border-none rounded-xl text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-[#007AFF]/30" - /> -
-
- {discoverAlbums - .filter(a => discoverFilter === 'all' || a.albumType === discoverFilter) - .filter(a => !discoverTextFilter || a.title.toLowerCase().includes(discoverTextFilter.toLowerCase())) - .map(a => { - const isSel = discoverSelected.includes(a.id); - return ( - - ); - })} -
-
- - -
- - )} -
- )} -
- )} - {/* File tree section */} - {!loading && fileTree && ( -
- - {showFiles && ( -
- {fileTree.folders?.length === 0 ? ( -

No files found on disk

- ) : ( -
- {fileTree.folders.map(folder => ( -
- - {expandedFolder === folder.name && ( -
- {folder.files.map((f, fi) => ( -
- - {f.name.endsWith('.flac') ? 'audio_file' : f.name.endsWith('.mp3') ? 'audio_file' : 'description'} - - {f.name} - {formatBytes(f.size)} -
- ))} -
- )} -
- ))} -
- )} -
- )} -
- )} - {/* Footer: completion or download controls */} - {!loading && albums?.length > 0 && ( -
- {allAlbumsComplete ? ( -
- check_circle -
-

All albums downloaded

-

{downloadedAlbums.length} album{downloadedAlbums.length !== 1 ? 's' : ''} · click trash icon to remove files

-
-
- ) : ( - <> - {searchResult && ( -
- {searchResult.message} -
- )} -
-
- {selectedAlbums.length} album(s) selected · {missingAlbums.length} missing - {selectedAlbums.length === 0 && } -
- -
- - )} -
- )} -
-
- ); -} - -// ── Add Media Panel ───────────────────────────────────────────────────────── - -function AddPanel({ item, mediaType, onClose, onAdded }) { - const { closing, close } = useAnimatedClose(onClose); - const [profiles, setProfiles] = useState(null); - const [profileError, setProfileError] = useState(null); - const [loading, setLoading] = useState(true); - const [adding, setAdding] = useState(false); - const [result, setResult] = useState(null); - const [config, setConfig] = useState({}); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [lookupAlbums, setLookupAlbums] = useState(null); - const [albumsLoading, setAlbumsLoading] = useState(false); - const [selectedAlbumIds, setSelectedAlbumIds] = useState([]); - const [albumFilter, setAlbumFilter] = useState(""); - // Manual add is a two-step flow: create the arr record first, then browse releases against that new id. - const [manualStep, setManualStep] = useState(null); // null | 'adding' | 'browse' - const [addedId, setAddedId] = useState(null); - const handlePanelClose = useCallback(() => { - if (manualStep === 'browse' && addedId && onAdded) onAdded({ mediaType, id: addedId, title: item?.title || item?.artistName }); - close(); - }, [manualStep, addedId, onAdded, item, close, mediaType]); - - useEffect(() => { - apiFetch(`/api/profiles/${mediaType}`) - .then(data => { - setProfiles(data); - setProfileError(null); - if (data) { - const defaults = {}; - if (data.qualityProfiles?.length) { - defaults.qualityProfileId = selectDefaultQualityProfileId(data.qualityProfiles, mediaType); - } - if (data.rootFolders?.length) defaults.rootFolderPath = data.rootFolders[0].path; - if (data.metadataProfiles?.length) defaults.metadataProfileId = data.metadataProfiles[0].id; - if (data.minimumAvailabilities?.length) defaults.minimumAvailability = data.minimumAvailabilities[2]?.value || data.minimumAvailabilities[0].value; - if (data.seriesTypes?.length) defaults.seriesType = data.seriesTypes[0].value; - if (data.monitorOptions?.length) defaults.monitorOption = data.monitorOptions[0].value; - setConfig(defaults); - } - setLoading(false); - }) - .catch((err) => { setProfileError(err.message); setLoading(false); }); - // Pre-select all seasons - if (item.seasons) setSelectedSeasons(item.seasons.filter(s => s.seasonNumber > 0).map(s => s.seasonNumber)); - }, [mediaType, item]); - - const handleManualAdd = async () => { - setManualStep('adding'); - setResult(null); - try { - let body = { ...config, monitored: true }; - if (mediaType === 'movie') { - body.tmdbId = item.tmdbId; - body.searchForMovie = false; - } else { - body.tvdbId = item.tvdbId; - body.searchForMissingEpisodes = false; - if (selectedSeasons.length > 0 && item.seasons?.length) body.selectedSeasons = selectedSeasons; - } - const data = await apiPost(`/api/add/${mediaType}`, body); - if (data.success) { - setAddedId(data.id); - setManualStep('browse'); - } else { - setResult({ success: false, message: data.error || 'Failed to add' }); - setManualStep(null); - } - } catch (err) { - setResult({ success: false, message: err.message }); - setManualStep(null); - } - }; - - // Fetch albums for music artists - useEffect(() => { - if (mediaType !== 'music' || !item.foreignArtistId) return; - setAlbumsLoading(true); - apiFetch(`/api/lookup/music/albums?artistName=${encodeURIComponent(item.artistName)}&foreignArtistId=${encodeURIComponent(item.foreignArtistId || "")}`) - .then(albums => { - setLookupAlbums(albums); - setSelectedAlbumIds(albums.filter(a => a.albumType !== 'Single').map(a => a.id)); - setAlbumsLoading(false); - }) - .catch(() => { setLookupAlbums([]); setAlbumsLoading(false); }); - }, [mediaType, item.foreignArtistId, item.artistName]); - - const handleAdd = async () => { - setAdding(true); - setResult(null); - try { - let body = { ...config, monitored: true }; - if (mediaType === 'movie') { - body.tmdbId = item.tmdbId; - body.searchForMovie = true; - } else if (mediaType === 'series') { - body.tvdbId = item.tvdbId; - body.searchForMissingEpisodes = true; - if (selectedSeasons.length > 0 && item.seasons?.length) { - body.selectedSeasons = selectedSeasons; - } - } else { - body.foreignArtistId = item.foreignArtistId; - body.artistName = item.artistName; - body.searchForMissingAlbums = true; - if (lookupAlbums?.length > 0) { - body.selectedAlbumTitles = lookupAlbums - .filter(a => selectedAlbumIds.includes(a.id)) - .map(a => a.title.replace(/ - Single$/, '')); - } - } - const data = await apiPost(`/api/add/${mediaType}`, body); - if (data.success) { - setResult({ success: true, message: data.albumsMonitored ? `Added "${data.artistName}" — ${data.albumsMonitored}/${data.totalAlbums} albums monitored, searching Soulseek...` : `Added "${data.title || data.artistName}" — searching for downloads...` }); - setAdding(false); - if (onAdded) onAdded({ mediaType, id: data.id, title: data.artistName || data.title || title }); - return; - } else { - setResult({ success: false, message: data.error || 'Failed to add' }); - } - } catch (err) { setResult({ success: false, message: err.message }); } - setAdding(false); - }; - - const rating = extractRating(item.ratings); - const title = mediaType === 'music' ? item.artistName : item.title; - const fallbackIcon = mediaType === 'movie' ? 'movie' : mediaType === 'series' ? 'tv' : 'album'; - const albumsAndEPs = lookupAlbums?.filter(a => a.albumType !== 'Single') || []; - const albumsAndEPIds = albumsAndEPs.map(a => a.id); - const allAlbumsAndEPsSelected = albumsAndEPIds.length > 0 && albumsAndEPIds.every(id => selectedAlbumIds.includes(id)); - const toggleAlbumsAndEPsSelection = () => { - setSelectedAlbumIds(current => ( - allAlbumsAndEPsSelected ? current.filter(id => !albumsAndEPIds.includes(id)) : Array.from(new Set([...current, ...albumsAndEPIds])) - )); - }; - - return ( -
-
e.stopPropagation()}> - {/* Header */} -
-
- -
-
-

{title}

-
- {item.year && {item.year}} - {item.network && · {item.network}} - {item.studio && · {item.studio}} - {item.disambiguation && · {item.disambiguation}} - {rating && ( - - star{rating} - - )} -
- {item.overview &&

{item.overview}

} - {item.genres?.length > 0 && ( -
- {item.genres.slice(0, 4).map(g => {g})} -
- )} -
- -
- - {/* Config */} -
- {loading ? ( -
- progress_activity -
- ) : !profiles ? ( -

{profileError || 'Failed to load profiles'}

- ) : ( - <> -
- {profiles.qualityProfiles?.length > 0 && ( - setConfig(c => ({ ...c, rootFolderPath: v }))} options={profiles.rootFolders.map(f => ({ value: f.path, label: `${f.path}${f.freeSpace ? ` (${formatBytes(f.freeSpace)} free)` : ''}` }))} /> - )} -
-
- {profiles.minimumAvailabilities && ( - setConfig(c => ({ ...c, seriesType: v }))} options={profiles.seriesTypes} /> - )} - {profiles.monitorOptions && !item.seasons && ( - setConfig(c => ({ ...c, metadataProfileId: parseInt(v) }))} options={profiles.metadataProfiles.map(p => ({ value: p.id, label: p.name }))} /> - )} -
- {/* Season selection for TV */} - {item.seasons && ( - - )} - {/* Album selection for Music */} - {mediaType === 'music' && albumsLoading && ( -
- progress_activity - Loading discography... -
- )} - {mediaType === 'music' && !albumsLoading && lookupAlbums && lookupAlbums.length > 0 && ( -
-
- -
- - -
-
-
- search - setAlbumFilter(e.target.value)} - placeholder="Filter albums..." - className="w-full pl-7 pr-2 py-1.5 text-[11px] bg-[#e8e8ed]/50 border-none rounded-xl text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-[#007AFF]/30" - /> -
-
- {lookupAlbums.filter(a => !albumFilter || a.title.toLowerCase().includes(albumFilter.toLowerCase())).map(a => ( - - ))} -
-
- )} - - )} - - {result && ( -
-
- {result.success ? 'check_circle' : 'error'} - {result.message} -
-
- )} - - {!result?.success && manualStep !== 'browse' && ( -
- - {mediaType !== 'music' && ( - - )} -
- )} - {manualStep === 'browse' && addedId && ( -
-
- Manual Search - -
-

This title is already added to your library. Closing this panel keeps it monitored so you can come back later.

- { - setResult({ success: true, message: `Added "${title}" — release grabbed, downloading shortly` }); - if (onAdded) onAdded({ mediaType, id: addedId, title }); - setManualStep(null); - }} - /> -
- )} -
-
-
- ); -} - -// ── Library Card Components ───────────────────────────────────────────────── - -function _SeriesCard({ series, onClick, queued }) { - const total = series.totalEpisodeCount || 0; - const have = series.episodeFileCount || 0; - const isComplete = total > 0 && have >= total; - const pct = total > 0 ? Math.min(100, Math.round((have / total) * 100)) : 0; - const badgeLabel = total === 0 ? null : isComplete ? 'Complete' : `${have}/${total} eps`; - const barColor = isComplete ? '#30D158' : '#FF9F0A'; - - return ( -
-
- -
-
- {/* QUEUED badge */} - {queued && ( -
- QUEUED -
- )} -
- {/* Completion bar */} - {total > 0 && ( -
-
-
- )} -
-
- {series.title.toUpperCase()} -
-
- {series.seasonCount > 0 && ( - - {series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''} - - )} - {badgeLabel && ( - - {badgeLabel} - - )} -
-
-
-
-
- ); -} -const SeriesCard = memo(_SeriesCard, (a, b) => a.series === b.series && a.queued === b.queued); - - -function _MovieCard({ movie, onClick, queued }) { - return ( -
-
- -
-
- {/* QUEUED badge (top-left) */} - {queued && ( -
- QUEUED -
- )} - {/* Downloaded / Downloading / Missing badge (top-right) */} -
- {movie.hasFile ? 'DOWNLOADED' : queued ? 'DOWNLOADING' : 'MISSING'} -
-
-
- {movie.title.toUpperCase()} -
- - {[movie.quality, movie.year && String(movie.year)].filter(Boolean).join(' · ') || (movie.year && String(movie.year)) || ''} - -
-
-
- ); -} -const MovieCard = memo(_MovieCard, (a, b) => a.movie === b.movie && a.queued === b.queued); - - - -function _ArtistCard({ artist, onClick }) { - return ( -
-
- -
-
-
-
- {artist.artistName.toUpperCase()} -
- {artist.albumCount > 0 && ( - 0 && artist.downloadedAlbumCount < artist.albumCount ? 'rgba(255,159,10,0.9)' : 'rgba(235,235,245,0.55)', letterSpacing: '0.03em' }}> - {artist.downloadedAlbumCount > 0 ? `${artist.downloadedAlbumCount}/${artist.albumCount}` : artist.albumCount} album{artist.albumCount !== 1 ? 's' : ''} - - )} -
-
-
- ); -} -const ArtistCard = memo(_ArtistCard, (a, b) => a.artist === b.artist); - - - -// ── Lookup Result Card ────────────────────────────────────────────────────── - -const ResultCard = memo(function ResultCard({ item, mediaType, onClick }) { - const rating = extractRating(item.ratings); - const isAlbum = mediaType === 'music-album'; - const isMusic = mediaType === 'music' || isAlbum; - const title = isAlbum ? item.title : (mediaType === 'music' ? item.artistName : item.title); - const subtitle = isAlbum ? item.artistName : null; - const fallbackIcon = mediaType === 'movie' ? 'movie' : mediaType === 'series' ? 'tv' : 'album'; - const imgHeight = isMusic ? 160 : 280; - return ( -
-
- -
- {item.inLibrary && !isAlbum && ( -
- - checkIN LIBRARY - -
- )} - {isAlbum && item.albumType && ( -
- {item.albumType.toUpperCase()} -
- )} -
-
-

{title}

- {subtitle &&

{subtitle}

} -
- {item.releaseDate && {new Date(item.releaseDate).getFullYear()}} - {item.year && !item.releaseDate && {item.year}} - {item.network && · {item.network}} - {item.studio && · {item.studio}} - {item.seasonCount && · {item.seasonCount}S} - {item.disambiguation && · {item.disambiguation}} - {rating && ( - - star{rating} - - )} -
- {!isAlbum && item.overview &&

{item.overview}

} -
-
- ); -}); - -// ── Movie Download Trigger ────────────────────────────────────────────────── - -function MovieDownloadPanel({ movie, onClose, onDelete }) { - const { closing, close } = useAnimatedClose(onClose); - const [searching, setSearching] = useState(false); - const [result, setResult] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const [deleting, setDeleting] = useState(false); - const [manualMode, setManualMode] = useState(false); - const [fileInfo, setFileInfo] = useState(null); - - useEffect(() => { - if (!movie.hasFile) return; - fetch(`/api/library/movie/${movie.id}/file`, { cache: 'no-store' }) - .then(r => r.ok ? r.json() : null) - .then(data => setFileInfo(data)) - .catch(() => {}); - }, [movie.id, movie.hasFile]); - - const handleSearch = async () => { - setSearching(true); - setResult(null); - try { - const resp = await fetch('/api/command/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service: 'radarr', id: movie.id }), - }); - const data = await resp.json(); - setResult(resp.ok ? { success: true, message: 'Radarr is now searching for this movie' } : { success: false, message: data.error }); - } catch (err) { setResult({ success: false, message: err.message }); } - setSearching(false); - }; - - return ( -
-
e.stopPropagation()}> -
-
- -
-
-

{movie.title} {movie.year && `(${movie.year})`}

-

{movie.hasFile ? `Downloaded · ${movie.quality || 'Unknown quality'}` : 'Missing from disk'}

- {movie.sizeOnDisk > 0 &&

{formatBytes(movie.sizeOnDisk)}

} - {fileInfo && ( -
- {fileInfo.resolution && ( -
- {fileInfo.resolution && {fileInfo.resolution}} - {fileInfo.videoCodec && {fileInfo.videoCodec}} - {fileInfo.dynamicRange && {fileInfo.dynamicRange}} - {fileInfo.audioCodec && {fileInfo.audioCodec}{fileInfo.audioChannels ? ` ${fileInfo.audioChannels}ch` : ''}} - {fileInfo.runTime && {fileInfo.runTime}} -
- )} - {fileInfo.path && ( -

- 📁 {fileInfo.path} -

- )} -
- )} -
-
- - -
-
- {confirmDelete && ( -
-

Delete this movie?

-
- - - -
-
- )} -
- {result && ( -
- {result.message} -
- )} -
- - -
- {manualMode && ( - setResult({ success: true, message: 'Release grabbed — downloading shortly' })} - /> - )} -
-
-
- ); -} - -// ── Main Library Component ────────────────────────────────────────────────── - -export default function Library({ - externalQuery, - onExternalQueryChange, - serviceStatus = {}, - onOpenSettings, - onAdded, -}) { - const [mode, setMode] = useState('library'); // 'library' or 'add' - const [query, setQuery] = useState(externalQuery || ''); - const [activeType, setActiveType] = useState('all'); - const [results, setResults] = useState({ series: [], movies: [], artists: [] }); - const [lookupResults, setLookupResults] = useState([]); - const [musicSections, setMusicSections] = useState(null); // { artists, albums, singles, topCategory } - const [loading, setLoading] = useState(false); - const [initialLoaded, setInitialLoaded] = useState(false); - const [libraryError, setLibraryError] = useState(null); - const [lookupError, setLookupError] = useState(null); - const [libraryServiceStates, setLibraryServiceStates] = useState(null); - const [detailView, setDetailView] = useState(null); - const [addPanel, setAddPanel] = useState(null); - const [addType, setAddType] = useState('movie'); - const [queuedSeriesIds, setQueuedSeriesIds] = useState(new Set()); - const [queuedMovieIds, setQueuedMovieIds] = useState(new Set()); - const [refreshing, setRefreshing] = useState(false); - const autoOpenedAddOnEmptyLibrary = useRef(false); - const addPanelHandledRef = useRef(false); - const debounceRef = useRef(null); - const inputRef = useRef(null); - const libraryRequestRef = useRef(0); - const lookupRequestRef = useRef(0); - const libraryFailureRef = useRef({ count: 0, lastGoodAt: null }); - const latestSearchRef = useRef({ query: externalQuery || '', activeType: 'all' }); - - // Sync external header query → local query (when header search bar is typed into) - useEffect(() => { - if (externalQuery !== undefined && externalQuery !== query) { - setQuery(externalQuery); - setMode('library'); - } - }, [externalQuery]); - - const serviceAvailability = useMemo(() => ({ - series: ['up', 'ready'].includes(serviceStatus.sonarr?.status), - movie: ['up', 'ready'].includes(serviceStatus.radarr?.status), - music: ['up', 'ready'].includes(serviceStatus.lidarr?.status), - }), [serviceStatus]); - const hasLibraryServices = serviceAvailability.series || serviceAvailability.movie || serviceAvailability.music; - - const availableLibraryFilters = useMemo(() => TYPE_FILTERS.filter((filter) => { - if (filter.key === 'all') return serviceAvailability.series || serviceAvailability.movie || serviceAvailability.music; - if (filter.key === 'series') return serviceAvailability.series; - if (filter.key === 'movie') return serviceAvailability.movie; - if (filter.key === 'music') return serviceAvailability.music; - if (filter.key === 'missing') return serviceAvailability.series || serviceAvailability.movie; - return true; - }), [serviceAvailability]); - - const availableAddTypes = useMemo(() => ( - [{ key: 'movie', label: 'Movies', icon: 'movie' }, { key: 'series', label: 'TV', icon: 'tv' }, { key: 'music', label: 'Music', icon: 'album' }] - .filter((filter) => serviceAvailability[filter.key]) - ), [serviceAvailability]); - - useEffect(() => { - latestSearchRef.current = { query, activeType }; - }, [query, activeType]); - - useEffect(() => { - if (availableLibraryFilters.some(filter => filter.key === activeType)) return; - setActiveType(availableLibraryFilters[0]?.key || 'all'); - }, [availableLibraryFilters, activeType]); - - useEffect(() => { - if (availableAddTypes.some(filter => filter.key === addType)) return; - setAddType(availableAddTypes[0]?.key || 'movie'); - }, [availableAddTypes, addType]); - - // Library search - const doLibrarySearch = useCallback(async (q, type) => { - if (!hasLibraryServices) { - setLoading(false); - setInitialLoaded(true); - return; - } - const requestId = ++libraryRequestRef.current; - setLoading(true); - setLibraryError(null); - try { - const data = await apiFetch(`/api/library/search?q=${encodeURIComponent(q)}&type=${type}`); - if (requestId !== libraryRequestRef.current) return; - setResults({ - series: Array.isArray(data.series) ? data.series : [], - movies: Array.isArray(data.movies) ? data.movies : [], - artists: Array.isArray(data.artists) ? data.artists : [], - }); - setLibraryServiceStates(data.serviceStates || null); - libraryFailureRef.current = { count: 0, lastGoodAt: Date.now() }; - } catch (err) { - if (requestId !== libraryRequestRef.current) return; - const message = String(err?.message || 'Library request failed'); - const transientFailure = - message.startsWith('backoff:') || - message.includes('Failed to fetch') || - message.includes('NetworkError') || - message.includes('Load failed'); - const shouldRetryMessage = transientFailure || isSetupAdjacentError(err); - const nextFailureCount = libraryFailureRef.current.count + 1; - const hasLoadedSuccessfully = Boolean(libraryFailureRef.current.lastGoodAt); - libraryFailureRef.current = { - ...libraryFailureRef.current, - count: nextFailureCount, - }; - if (transientFailure && (initialLoaded || hasLoadedSuccessfully)) { - setLibraryError(null); - return; - } - if (transientFailure && nextFailureCount < 3) { - setLibraryError(null); - return; - } - setLibraryError(shouldRetryMessage - ? Object.assign(new Error('Reconnecting to the library…'), err, { message: 'Reconnecting to the library…' }) - : err); - } finally { - if (requestId === libraryRequestRef.current) { - setLoading(false); - setInitialLoaded(true); - } - } - }, [hasLibraryServices, initialLoaded]); - - // Lookup search (add mode) - const doLookupSearch = useCallback(async (q, type) => { - if (!q.trim()) { - setLookupResults([]); - setMusicSections(null); - setLookupError(null); - return; - } - const requestId = ++lookupRequestRef.current; - setLoading(true); - setLookupError(null); - const endpoint = type === 'movie' ? 'movie' : type === 'series' ? 'series' : 'music'; - try { - const data = await apiFetch(`/api/lookup/${endpoint}?term=${encodeURIComponent(q)}`); - if (requestId !== lookupRequestRef.current) return; - if (type === 'music' && data && !Array.isArray(data)) { - // Preserve grouped sections for rendering, but flatten them for shared empty/loading handling. - setMusicSections(data); - setLookupResults([...(data.artists || []), ...(data.albums || []), ...(data.singles || [])]); - } else { - setMusicSections(null); - setLookupResults(Array.isArray(data) ? data : []); - } - } catch (err) { - if (requestId !== lookupRequestRef.current) return; - setLookupResults([]); - setMusicSections(null); - setLookupError(err); - } finally { - if (requestId === lookupRequestRef.current) setLoading(false); - } - }, []); - - // Fetch download queue for badge display - const fetchQueue = useCallback(async () => { - try { - const items = await apiFetch('/api/arr-queue'); - const sIds = new Set(); - const mIds = new Set(); - // Materialize queue membership once so library cards can do cheap badge lookups. - for (const item of items) { - if (item.service === 'sonarr' && item.seriesId) sIds.add(item.seriesId); - if (item.service === 'radarr' && item.movieId) mIds.add(item.movieId); - } - setQueuedSeriesIds(sIds); - setQueuedMovieIds(mIds); - } catch {} - }, []); - - // Manual refresh handler - const handleRefresh = useCallback(() => { - if (!hasLibraryServices) return; - setRefreshing(true); - apiFetch('/api/library/refresh') - .then(() => { - doLibrarySearch(query, activeType); - fetchQueue(); - }) - .catch((err) => setLibraryError(err)) - .finally(() => setRefreshing(false)); - }, [query, activeType, hasLibraryServices, doLibrarySearch, fetchQueue]); - - // Initial load - useEffect(() => { - if (!hasLibraryServices) { - setLibraryError(null); - setResults({ series: [], movies: [], artists: [] }); - setLibraryServiceStates(null); - setInitialLoaded(true); - setLoading(false); - return; - } - doLibrarySearch(latestSearchRef.current.query, latestSearchRef.current.activeType); - fetchQueue(); - const poll = setInterval(() => { - doLibrarySearch(latestSearchRef.current.query, latestSearchRef.current.activeType); - fetchQueue(); - }, 15000); - return () => clearInterval(poll); - }, [doLibrarySearch, fetchQueue, hasLibraryServices]); - - // Debounced search - useEffect(() => { - clearTimeout(debounceRef.current); - if (mode === 'library') { - if (!hasLibraryServices) return; - // The mount effect already loads the default library view; skip that duplicate fetch. - if (!initialLoaded) return; - debounceRef.current = setTimeout(() => doLibrarySearch(query, activeType), 300); - } else { - if (!query.trim()) { setLookupResults([]); setLookupError(null); return; } - debounceRef.current = setTimeout(() => doLookupSearch(query, addType), 400); - } - return () => clearTimeout(debounceRef.current); - }, [query, activeType, mode, addType, initialLoaded, doLibrarySearch, doLookupSearch, hasLibraryServices]); - - // Reset on mode change — going to 'add' keeps the query so it pre-fills the lookup - useEffect(() => { - setLookupResults([]); - setMusicSections(null); - setLookupError(null); - if (mode === 'library') { - setQuery(''); - setLibraryError(null); - if (hasLibraryServices) doLibrarySearch('', activeType); - else { - setInitialLoaded(true); - setLoading(false); - setResults({ series: [], movies: [], artists: [] }); - } - } - }, [mode, activeType, doLibrarySearch, hasLibraryServices]); - - // Compute missing / wanted items for the "Missing" filter - const { missingSeries, missingMovies } = useMemo(() => ({ - missingSeries: results.series - .filter(s => s.monitored && s.totalEpisodeCount > 0 && s.episodeFileCount < s.totalEpisodeCount) - .sort((a, b) => (b.totalEpisodeCount - b.episodeFileCount) - (a.totalEpisodeCount - a.episodeFileCount)), - missingMovies: results.movies.filter(m => m.monitored && !m.hasFile), - }), [results.series, results.movies]); - - const isMissingFilter = activeType === 'missing'; - - // Determine what to show given the active filter - const visibleSeries = isMissingFilter ? missingSeries : (activeType === 'all' || activeType === 'series' ? results.series : []); - const visibleMovies = isMissingFilter ? missingMovies : (activeType === 'all' || activeType === 'movie' ? results.movies : []); - const visibleArtists = isMissingFilter ? [] : (activeType === 'all' || activeType === 'music' ? results.artists : []); - - const totalLibrary = visibleSeries.length + visibleMovies.length + visibleArtists.length; - const effectiveServiceStates = { - series: libraryServiceStates?.series || { status: serviceAvailability.series ? 'ready' : 'unconfigured', error: null }, - movies: libraryServiceStates?.movies || { status: serviceAvailability.movie ? 'ready' : 'unconfigured', error: null }, - artists: libraryServiceStates?.artists || { status: serviceAvailability.music ? 'ready' : 'unconfigured', error: null }, - }; - const activeLibraryIssue = activeType === 'series' - ? effectiveServiceStates.series - : activeType === 'movie' - ? effectiveServiceStates.movies - : activeType === 'music' - ? effectiveServiceStates.artists - : null; - const libraryModeUnavailable = availableLibraryFilters.length === 0; - const addModeUnavailable = availableAddTypes.length === 0; - const unavailableServices = [ - !serviceAvailability.series && 'TV', - !serviceAvailability.movie && 'Movies', - !serviceAvailability.music && 'Music', - ].filter(Boolean); - const isLibraryEmptyWelcome = initialLoaded && !loading && !query.trim() && !isMissingFilter && activeType === 'all' && totalLibrary === 0 && !libraryModeUnavailable && !libraryError && !isServiceIssueStatus(activeLibraryIssue?.status) && hasLibraryServices && availableAddTypes.length > 0; - - const jumpToAddMode = useCallback(() => { - if (addModeUnavailable) { - onOpenSettings?.(); - return; - } - const typeMap = { series: 'series', movie: 'movie', music: 'music' }; - if (typeMap[activeType]) setAddType(typeMap[activeType]); - setMode('add'); - }, [addModeUnavailable, activeType, onOpenSettings]); - - useEffect(() => { - if (!isLibraryEmptyWelcome || autoOpenedAddOnEmptyLibrary.current || mode !== 'library') return; - const typeMap = { series: 'series', movie: 'movie', music: 'music' }; - if (typeMap[activeType]) setAddType(typeMap[activeType]); - else if (availableAddTypes[0]?.key) setAddType(availableAddTypes[0].key); - setMode('add'); - autoOpenedAddOnEmptyLibrary.current = true; - }, [activeType, isLibraryEmptyWelcome, mode, availableAddTypes]); - - useEffect(() => { - if (addPanel) addPanelHandledRef.current = false; - }, [addPanel]); - - const handlePanelAdded = useCallback((payload) => { - if (addPanelHandledRef.current) return; - addPanelHandledRef.current = true; - setAddPanel(null); - doLibrarySearch('', 'all'); - onAdded?.(payload); - }, [doLibrarySearch, onAdded]); - - return ( -
- {/* Search header */} -
- {/* Mode toggle */} -
- - -
- - {/* Search + filters */} -
-
- search - { setQuery(e.target.value); onExternalQueryChange?.(e.target.value); }} - placeholder={mode === 'library' - ? (libraryModeUnavailable ? 'Configure Radarr, Sonarr, or Lidarr in backend .env to browse the library' : 'Search your library...') - : (addModeUnavailable ? 'No add services are configured in backend .env' : `Search for ${addType === 'movie' ? 'movies' : addType === 'series' ? 'TV shows' : 'artists'} to add...`)} - className="search-input w-full pl-10 pr-10 py-2.5 bg-bg-card border border-border-subtle rounded-xl text-[13px] text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-blue focus:ring-1 focus:ring-accent-blue/30" - style={{ opacity: mode === 'library' ? (libraryModeUnavailable ? 0.6 : 1) : (addModeUnavailable ? 0.6 : 1) }} - /> - {query && ( - - )} -
- {mode === 'library' && ( - - )} - - {/* Type filter */} -
- {mode === 'library' ? ( - availableLibraryFilters.map(f => ( - - )) - ) : ( - availableAddTypes.map(f => ( - - )) - )} -
-
- {(((unavailableServices.length > 0) && !(mode === 'library' && initialLoaded && totalLibrary === 0 && libraryModeUnavailable) && !(mode === 'add' && addModeUnavailable)) - || libraryError || lookupError || isServiceIssueStatus(activeLibraryIssue?.status)) && ( -
- {unavailableServices.length > 0 && ( -

- Unavailable right now: {unavailableServices.join(', ')}. - {' '}This stack still reads manual service config from backend `.env` values. - -

- )} - {libraryError && mode === 'library' && } - {lookupError && mode === 'add' && } - {isServiceIssueStatus(activeLibraryIssue?.status) && activeLibraryIssue?.error && mode === 'library' && ( -

{activeLibraryIssue.error}

- )} -
- )} - {mode === 'library' && initialLoaded && ( -

- {loading ? 'Searching...' : isMissingFilter - ? `${totalLibrary} item${totalLibrary !== 1 ? 's' : ''} need attention` - : `${totalLibrary} result${totalLibrary !== 1 ? 's' : ''}`} -

- )} -
- - {/* Library Results */} - {mode === 'library' && ( -
- {!initialLoaded && loading && ( -
- progress_activity -

Loading library…

-
- )} - {visibleSeries.length > 0 && ( -
-
-

{isMissingFilter ? 'Incomplete TV Shows' : 'TV Shows'}

- {visibleSeries.length} -
-
- {visibleSeries.map(s => setDetailView({ type: 'series', id: s.id })} queued={queuedSeriesIds.has(s.id)} />)} -
-
- )} - {visibleMovies.length > 0 && ( -
-
-

{isMissingFilter ? 'Missing Films' : 'Films'}

- {visibleMovies.length} -
-
- {visibleMovies.map(m => setDetailView({ type: 'movie', data: m })} queued={queuedMovieIds.has(m.id)} />)} -
-
- )} - {visibleArtists.length > 0 && ( -
-
-

Music

- {visibleArtists.length} -
-
- {visibleArtists.map(a => setDetailView({ type: 'artist', id: a.id })} />)} -
-
- )} - {initialLoaded && !loading && totalLibrary === 0 && ( -
- {libraryModeUnavailable || activeLibraryIssue?.status === 'unconfigured' ? ( -
-
-
- tune -
-
-
-

Connect your library services

- - Radarr / Sonarr / Lidarr - -
-

- 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 ? 'playlist_add' : libraryError || isServiceIssueStatus(activeLibraryIssue?.status) ? 'cloud_off' : isMissingFilter ? 'task_alt' : 'search_off'} - -

- {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. - -

- ) : isLibraryEmptyWelcome ? ( -

- Get started by searching and adding something to your library. - -

- ) : !isMissingFilter && ( -

Try a different search, or switch to

- )} -
- )} -
- )} -
- )} - - {/* Add New Results */} - {mode === 'add' && ( -
- {addModeUnavailable && ( -
-
-
-
- settings -
-
-
-

Enable acquisition services

- - Required for Add New - -
-

- 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.

-
-
-
- -
-
-
-
-
- )} - {loading && ( -
- progress_activity -
- )} - {!addModeUnavailable && !loading && query.trim() && lookupResults.length === 0 && ( -
- search_off -

No results for "{query}"

-
- )} - {!addModeUnavailable && !loading && !query.trim() && ( -
-
-
-
- - {addType === 'movie' ? 'movie' : addType === 'series' ? 'tv' : 'album'} - -
-
-
-

- Add {addType === 'movie' ? 'films' : addType === 'series' ? 'TV shows' : 'music'} -

- - Powered by {addType === 'movie' ? 'Radarr' : addType === 'series' ? 'Sonarr' : 'Lidarr'} - -
-

- 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.

-
-
-
-
-
-
- )} - {!addModeUnavailable && !loading && lookupResults.length > 0 && ( -
- {addType === 'music' && musicSections ? (() => { - // Smart section ordering based on topCategory - const sectionOrder = musicSections.topCategory === 'albums' - ? ['albums', 'artists', 'singles'] - : musicSections.topCategory === 'singles' - ? ['singles', 'artists', 'albums'] - : ['artists', 'albums', 'singles']; - const sectionDefs = { - artists: { label: 'Artists', items: musicSections.artists, icon: 'person' }, - albums: { label: 'Albums', items: musicSections.albums, icon: 'album' }, - singles: { label: 'Singles & EPs', items: musicSections.singles, icon: 'music_note' }, - }; - return sectionOrder.map(key => { - const { label, items, icon } = sectionDefs[key]; - if (!items || items.length === 0) return null; - return ( -
-
- {icon} -

{label}

- ({items.length}) -
-
- {items.map((item, i) => ( - key === 'artists' ? setAddPanel(item) : setAddPanel({ ...item, _isAlbum: true, _albumType: key })} - /> - ))} -
-
- ); - }); - })() : ( -
-
-

{lookupResults.length} result{lookupResults.length !== 1 ? 's' : ''}

- - Source: {addType === 'movie' ? 'Radarr' : addType === 'series' ? 'Sonarr' : 'Lidarr'} - -
-
- {lookupResults.map((item, i) => ( - setAddPanel(item)} /> - ))} -
-
- )} -
- )} -
- )} - - {/* Modals */} - {detailView?.type === 'series' && setDetailView(null)} onDelete={() => { setDetailView(null); doLibrarySearch('', activeType); }} />} - {detailView?.type === 'artist' && setDetailView(null)} onDelete={() => { setDetailView(null); doLibrarySearch('', activeType); }} />} - {detailView?.type === 'movie' && setDetailView(null)} onDelete={() => { setDetailView(null); doLibrarySearch('', activeType); }} />} - {addPanel && setAddPanel(null)} onAdded={handlePanelAdded} />} -
- ); -} +export { default } from './library/LibraryView'; diff --git a/frontend/src/ManualSearchModal.jsx b/frontend/src/ManualSearchModal.jsx index 65fd041..a0aa39e 100644 --- a/frontend/src/ManualSearchModal.jsx +++ b/frontend/src/ManualSearchModal.jsx @@ -1,7 +1,6 @@ import { useState, useEffect, useRef, useCallback, memo } from 'react'; import { formatBytes, detectQualityLabel } from './utils'; - -const QUALITY_ORDER = { '4K': 0, '1080p': 1, '720p': 2, '480p': 3, 'HDTV': 4, 'WEB': 5, 'BluRay': 6, 'CAM': 7, 'Other': 8 }; +import { QUALITY_ORDER } from './constants'; const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; @@ -10,13 +9,38 @@ const ResultRow = memo(function ResultRow({ r, isGrabbing, isGrabbed, anyGrabbin return ( -
{r.title}
-
{r.indexer}{r.rejections?.length > 0 && ` · ⚠ ${r.rejections[0]}`}
+
+ {r.title} +
+
+ {r.indexer} + {r.rejections?.length > 0 && ` · ⚠ ${r.rejections[0]}`} +
{r.quality} - {formatBytes(r.size)} + + {formatBytes(r.size)} + - = 10 ? '#30d158' : r.seeders >= 3 ? '#ff9f0a' : '#ff453a', fontWeight: 700, fontFamily: 'monospace' }}>{r.seeders} + = 10 ? '#30d158' : r.seeders >= 3 ? '#ff9f0a' : '#ff453a', + fontWeight: 700, + fontFamily: 'monospace', + }} + > + {r.seeders} + / {r.leechers} @@ -26,7 +50,19 @@ const ResultRow = memo(function ResultRow({ r, isGrabbing, isGrabbed, anyGrabbin @@ -34,14 +70,6 @@ const ResultRow = memo(function ResultRow({ r, isGrabbing, isGrabbed, anyGrabbin ); }); -/** - * Full-screen modal for manual torrent/release searching and grabbing. - * @param {Object} target - { service, title, retryId, seriesId, movieId, seasonNumbers } - * @param {Array} results - Release objects from the search API - * @param {boolean} loading - Whether a search is in progress - * @param {Function} onClose - Called when the modal should close - * @param {Function} onGrab - Called with a release object to grab - */ export default function ManualSearchModal({ target, results, loading, onClose, onGrab }) { const [sortBy, setSortBy] = useState('seeders'); const [sortDir, setSortDir] = useState('desc'); @@ -58,7 +86,7 @@ export default function ManualSearchModal({ target, results, loading, onClose, o const closeTimerRef = useRef(null); const reduced = prefersReducedMotion(); - // Kick open one frame later so the enter transition has a closed starting point. + // Kick the open state one frame later so the enter transition has a closed starting point. useEffect(() => { const id = requestAnimationFrame(() => setOpen(true)); return () => cancelAnimationFrame(id); @@ -66,45 +94,68 @@ export default function ManualSearchModal({ target, results, loading, onClose, o const requestClose = useCallback(() => { if (closing) return; - if (reduced) { onClose(); return; } + if (reduced) { + onClose(); + return; + } setClosing(true); setOpen(false); closeTimerRef.current = setTimeout(() => onClose(), 200); }, [closing, onClose, reduced]); - useEffect(() => () => { if (closeTimerRef.current) clearTimeout(closeTimerRef.current); }, []); + useEffect( + () => () => { + if (closeTimerRef.current) clearTimeout(closeTimerRef.current); + }, + [], + ); // Keep focus inside the dialog while it is mounted. useEffect(() => { - const handleKey = (e) => { - if (e.key === 'Escape') { e.stopPropagation(); requestClose(); return; } + const handleKey = e => { + if (e.key === 'Escape') { + e.stopPropagation(); + requestClose(); + return; + } if (e.key === 'Tab' && dialogRef.current) { const focusables = dialogRef.current.querySelectorAll( - 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusables.length === 0) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; - if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } - else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } } }; document.addEventListener('keydown', handleKey); - // initial focus const t = setTimeout(() => dialogRef.current?.focus(), 50); - return () => { document.removeEventListener('keydown', handleKey); clearTimeout(t); }; + return () => { + document.removeEventListener('keydown', handleKey); + clearTimeout(t); + }; }, [requestClose]); - const allQualities = [...new Set(results.map(r => detectQualityLabel(r)))] - .sort((a, b) => (QUALITY_ORDER[a] ?? 99) - (QUALITY_ORDER[b] ?? 99)); + const allQualities = [...new Set(results.map(r => detectQualityLabel(r)))].sort( + (a, b) => (QUALITY_ORDER[a] ?? 99) - (QUALITY_ORDER[b] ?? 99), + ); - const toggleSort = (col) => { - if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc'); - else { setSortBy(col); setSortDir('desc'); } + const toggleSort = col => { + if (sortBy === col) setSortDir(d => (d === 'desc' ? 'asc' : 'desc')); + else { + setSortBy(col); + setSortDir('desc'); + } }; - const toggleQuality = (q) => { - setQualityFilters(prev => prev.includes(q) ? prev.filter(x => x !== q) : [...prev, q]); + const toggleQuality = q => { + setQualityFilters(prev => (prev.includes(q) ? prev.filter(x => x !== q) : [...prev, q])); setShowCount(50); }; @@ -117,7 +168,8 @@ export default function ManualSearchModal({ target, results, loading, onClose, o let cmp = 0; if (sortBy === 'seeders') cmp = (b.seeders || 0) - (a.seeders || 0); else if (sortBy === 'size') cmp = (b.size || 0) - (a.size || 0); - else if (sortBy === 'quality') cmp = (QUALITY_ORDER[detectQualityLabel(a)] ?? 99) - (QUALITY_ORDER[detectQualityLabel(b)] ?? 99); + else if (sortBy === 'quality') + cmp = (QUALITY_ORDER[detectQualityLabel(a)] ?? 99) - (QUALITY_ORDER[detectQualityLabel(b)] ?? 99); else cmp = (b.seeders || 0) - (a.seeders || 0) || (b.size || 0) - (a.size || 0); return sortDir === 'asc' ? -cmp : cmp; }); @@ -125,21 +177,46 @@ export default function ManualSearchModal({ target, results, loading, onClose, o const visible = sorted.slice(0, showCount); const hasMore = sorted.length > showCount; - const handleGrabClick = useCallback((r) => { - // Serialize grabs so repeat clicks cannot submit multiple releases at once. - if (grabbingGuid) return; - setGrabbingGuid(r.guid); - setGrabResult(null); - onGrab(r) - .then(() => { setGrabResult({ success: true, title: r.title }); setGrabbingGuid(null); }) - .catch((err) => { setGrabError({ title: r.title, message: err?.message || 'Grab failed' }); setTimeout(() => setGrabError(null), 5000); setGrabbingGuid(null); }); - }, [grabbingGuid, onGrab]); + // Serialize grabs so repeated clicks cannot submit multiple releases at once. + const handleGrabClick = useCallback( + r => { + if (grabbingGuid) return; + setGrabbingGuid(r.guid); + setGrabResult(null); + setGrabError(null); + onGrab(r) + .then(() => { + setGrabError(null); + setGrabResult({ success: true, title: r.title }); + setGrabbingGuid(null); + }) + .catch(err => { + setGrabError({ title: r.title, message: err?.message || 'Grab failed' }); + setTimeout(() => setGrabError(null), 5000); + setGrabbingGuid(null); + }); + }, + [grabbingGuid, onGrab], + ); const SortBtn = ({ col, label }) => { const active = sortBy === col; return ( - + style={{ + fontSize: 10, + fontWeight: 600, + padding: '4px 10px', + borderRadius: 6, + cursor: 'pointer', + border: `1px solid ${exactMatchOnly ? 'rgba(10,132,255,0.4)' : 'rgba(255,255,255,0.15)'}`, + background: exactMatchOnly ? 'rgba(10,132,255,0.15)' : 'transparent', + color: exactMatchOnly ? '#0a84ff' : 'rgba(235,235,245,0.5)', + }} + > + Exact Match + - +
- {/* Quality chips + Seeders filter bar */} {!loading && results.length > 0 && ( -
+
{allQualities.length > 1 && (
- Quality: + + Quality: + {allQualities.map(q => ( - + ))} {qualityFilters.length > 0 && ( - + )}
)}
- Seeds: - {[[0,'Any'],[5,'5+'],[10,'10+'],[25,'25+']].map(([val, label]) => ( - + + Seeds: + + {[ + [0, 'Any'], + [5, '5+'], + [10, '10+'], + [25, '25+'], + ].map(([val, label]) => ( + ))}
)} {grabResult && ( -
- {grabResult.success ? 'check_circle' : 'error'} - {grabResult.success ? `Grabbed — downloading shortly` : `Grab failed`} +
+ + {grabResult.success ? 'check_circle' : 'error'} + + + {grabResult.success ? `Grabbed — downloading shortly` : `Grab failed`} +
)} {loading ? (
- {[0,1,2,3,4,5].map(i => ( -
+ {[0, 1, 2, 3, 4, 5].map(i => ( +
))}
) : sorted.length === 0 ? ( @@ -247,30 +487,71 @@ export default function ManualSearchModal({ target, results, loading, onClose, o
{(() => { const rejected = results.filter(r => r.rejected); - // Collapse backend-specific rejection strings into one short empty-state summary. if (!exactMatchOnly || rejected.length === 0) return null; const counts = {}; + // Collapse backend-specific rejection strings into a short empty-state summary. 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'; + 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); + const entries = Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); if (entries.length === 0) return null; return ( -
-
Why rejected
+
+
+ Why rejected +
{entries.map(([cat, count]) => (
- {count}× + + {count}× + {cat}
))} @@ -281,7 +562,17 @@ export default function ManualSearchModal({ target, results, loading, onClose, o ) : (
{grabError && ( -
+
{grabError.title}: {grabError.message}
)} @@ -289,7 +580,21 @@ export default function ManualSearchModal({ target, results, loading, onClose, o {['Release', 'Quality', 'Size', 'Seeds', 'Age', ''].map(h => ( - {h} + + {h} + ))} @@ -308,8 +613,18 @@ export default function ManualSearchModal({ target, results, loading, onClose, o {hasMore && (
- diff --git a/frontend/src/PipelineCard.jsx b/frontend/src/PipelineCard.jsx index 0cc12e7..60d0ade 100644 --- a/frontend/src/PipelineCard.jsx +++ b/frontend/src/PipelineCard.jsx @@ -1,16 +1,15 @@ import React, { useState, useMemo, useEffect } from 'react'; import { formatSpeed } from './utils'; -import PosterImage from './PosterImage'; +import { SERVICE_COLOR } from './constants'; -/** Reactive prefers-reduced-motion hook. */ function useReducedMotion() { - const [reduced, setReduced] = useState(() => - typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + const [reduced, setReduced] = useState( + () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, ); useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return; const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); - const handler = (e) => setReduced(e.matches); + const handler = e => setReduced(e.matches); mq.addEventListener?.('change', handler); return () => mq.removeEventListener?.('change', handler); }, []); @@ -19,13 +18,15 @@ function useReducedMotion() { const PIPELINE_STAGE_ORDER = ['queued', 'searching', 'grabbed', 'downloading', 'importing', 'complete']; const PIPELINE_STAGE_LABELS = { - queued: 'Queued', searching: 'Searching', grabbed: 'Grabbed', - downloading: 'Downloading', importing: 'Importing', complete: 'Done', + queued: 'Queued', + searching: 'Searching', + grabbed: 'Grabbed', + downloading: 'Downloading', + importing: 'Importing', + complete: 'Done', }; -const SVC_COLOR = { sonarr: '#3498db', radarr: '#e8b34b', lidarr: '#1db954' }; const SVC_LABEL_SHORT = { sonarr: 'TV', radarr: 'Movie', lidarr: 'Music' }; -/** Formats a timestamp into relative time string (e.g. "3m ago"). */ function formatRelTime(ts) { const s = Math.round((Date.now() - ts) / 1000); if (s < 60) return `${s}s ago`; @@ -33,7 +34,6 @@ function formatRelTime(ts) { return `${Math.floor(s / 3600)}h ago`; } -/** Formats milliseconds into a duration string (e.g. "2m 34s"). */ function formatDuration(ms) { const s = Math.round(ms / 1000); if (s < 60) return `${s}s`; @@ -41,17 +41,6 @@ function formatDuration(ms) { return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; } -/** - * Collapsible card showing the auto-search/download lifecycle for one pipeline item. - * @param {Object} item - Pipeline item from /api/pipeline - * @param {boolean} expanded - Whether the step log is visible - * @param {Function} onToggle - Toggle expanded state - * @param {Function} onRetry - Retry the pipeline search - * @param {Function} onCancel - Cancel and remove from pipeline - * @param {Function} onMonitor - Mark item as monitored in the arr service - * @param {Function} onManualSearch - Open manual search modal for this item - * @param {Function} onDismiss - Dismiss completed/failed item - */ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, onManualSearch, onDismiss }) { const [actionBusy, setActionBusy] = useState(null); const [actionMsg, setActionMsg] = useState(null); @@ -62,7 +51,11 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, setActionMsg(null); try { await fn(); - setActionMsg({ ok: true, text: name === 'retry' ? 'Retrying…' : name === 'cancel' ? 'Removed' : name === 'monitor' ? 'Monitored' : 'Done' }); + setActionMsg({ + ok: true, + text: + name === 'retry' ? 'Retrying…' : name === 'cancel' ? 'Removed' : name === 'monitor' ? 'Monitored' : 'Done', + }); } catch (e) { setActionMsg({ ok: false, text: e.message || 'Error' }); } @@ -76,10 +69,9 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, const isSearching = item.stage === 'searching'; const isDownloading = item.stage === 'downloading'; - // Stuck/failed render against the searching node so the trail still points at a real stage. - const activeStageIdx = isStuck || isFailed - ? PIPELINE_STAGE_ORDER.indexOf('searching') - : PIPELINE_STAGE_ORDER.indexOf(item.stage); + // Stuck/failed are rendered as stalled in search so the trail still points at a real stage. + const activeStageIdx = + isStuck || isFailed ? PIPELINE_STAGE_ORDER.indexOf('searching') : PIPELINE_STAGE_ORDER.indexOf(item.stage); const now = Date.now(); const stageElapsedMs = now - item.stageStartedAt; @@ -87,58 +79,112 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, const stageElapsedStr = formatDuration(stageElapsedMs); const totalElapsedStr = formatDuration(totalElapsedMs); - const svcColor = SVC_COLOR[item.service] || '#888'; + const svcColor = SERVICE_COLOR[item.service] || '#888'; - // Get most recent step message for prominent display // Steps append oldest -> newest, so the tail becomes the collapsed summary line. - const latestStep = useMemo(() => item.steps?.length > 0 ? item.steps[item.steps.length - 1] : null, [item.steps]); - const activityMessage = latestStep?.message || item.statusDetail || (isSearching ? 'Preparing search…' : null); - - // Stage dots for compact trail + const latestStep = useMemo(() => (item.steps?.length > 0 ? item.steps[item.steps.length - 1] : null), [item.steps]); const stageDots = PIPELINE_STAGE_ORDER.filter(s => s !== 'complete'); return ( -
- {/* Local keyframes (subtle stuck pulse) — kept inline to avoid new files */} +
+ {/* Keep the stuck pulse local to this component instead of introducing shared CSS. */} - {/* ── Main clickable row ── */}
- {/* Poster */} - +
+ {item.posterUrl ? ( + {item.title} + ) : ( + + {item.service === 'sonarr' ? 'tv' : item.service === 'radarr' ? 'movie' : 'album'} + + )} +
- {/* Content */}
- {/* Row 1: title + dismiss */} -
+
-
+
{item.title}
{item.subtitle && (
- {SVC_LABEL_SHORT[item.service] || item.service}{item.subtitle ? ` · ${item.subtitle}` : ''} + {SVC_LABEL_SHORT[item.service] || item.service} + {item.subtitle ? ` · ${item.subtitle}` : ''}
)}
{isComplete && ( - - check_circle + + + check_circle + Done )} @@ -148,38 +194,79 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, )} + style={{ + background: 'none', + border: 'none', + color: 'rgba(255,255,255,0.22)', + cursor: 'pointer', + fontSize: 14, + lineHeight: 1, + padding: '0 2px', + display: 'flex', + alignItems: 'center', + transition: 'color 0.15s', + }} + onMouseEnter={e => (e.target.style.color = 'rgba(255,255,255,0.5)')} + onMouseLeave={e => (e.target.style.color = 'rgba(255,255,255,0.22)')} + > + × +
- {/* Row 2: Stage trail */}
{stageDots.map((stage, i) => { const stageIdx = PIPELINE_STAGE_ORDER.indexOf(stage); const isPast = !isStuck && stageIdx < activeStageIdx; const isActive = !isStuck && stage === item.stage; const isStuckHere = isStuck && stageIdx <= activeStageIdx; - const dotColor = isComplete ? '#30d158' - : isPast ? '#30d158' - : isActive ? svcColor - : isStuckHere && stageIdx === activeStageIdx ? '#ff9f0a' - : 'rgba(255,255,255,0.18)'; - const textColor = isComplete ? '#30d158' - : isPast ? 'rgba(48,209,88,0.7)' - : isActive ? svcColor - : isStuckHere && stageIdx === activeStageIdx ? '#ff9f0a' - : 'rgba(255,255,255,0.22)'; + const dotColor = isComplete + ? '#30d158' + : isPast + ? '#30d158' + : isActive + ? svcColor + : isStuckHere && stageIdx === activeStageIdx + ? '#ff9f0a' + : 'rgba(255,255,255,0.18)'; + const textColor = isComplete + ? '#30d158' + : isPast + ? 'rgba(48,209,88,0.7)' + : isActive + ? svcColor + : isStuckHere && stageIdx === activeStageIdx + ? '#ff9f0a' + : 'rgba(255,255,255,0.22)'; return ( {isActive && ( - + )} - + {PIPELINE_STAGE_LABELS[stage]} {i < stageDots.length - 1 && ( @@ -190,61 +277,153 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, })}
- {/* Row 3: Current activity message — most informative part */} - {activityMessage && !isComplete && !isStuck && ( -
+ {latestStep && !isComplete && !isStuck && ( +
{isSearching && ( - + )} - {activityMessage} + {latestStep.message}
)} - {/* Download progress bar */} {isDownloading && item.progress != null && (
-
+
- {item.progress}% - {item.speed && {formatSpeed(item.speed)}} - {item.eta && ETA {item.eta}} + + {item.progress}% + + {item.speed && ( + + {formatSpeed(item.speed)} + + )} + {item.eta && ( + + ETA {item.eta} + + )}
)} - {/* Stuck reason + actions */} {isStuck && item.stuckReason && ( -
e.stopPropagation()}> -

{item.stuckReason}

+
e.stopPropagation()} + > +

+ {item.stuckReason} +

{actionMsg && ( -

{actionMsg.text}

+

+ {actionMsg.text} +

)}
{item.canRetry && ( - )} - - -
@@ -252,83 +431,255 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, )}
- {/* Expand chevron */} {item.steps?.length > 0 && ( - + expand_more )}
- {/* ── Expanded detail drawer ── */} {expanded && (
- {/* Stats row */} -
+
-
Stage
-
+
+ Stage +
+
{isStuck ? 'Stuck' : PIPELINE_STAGE_LABELS[item.stage] || item.stage}
-
In Stage
-
{formatDuration(stageElapsedMs)}
+
+ In Stage +
+
+ {formatDuration(stageElapsedMs)} +
-
Total
-
{totalElapsedStr}
+
+ Total +
+
+ {totalElapsedStr} +
{item.service && (
-
Service
+
+ Service +
{item.service}
)} {isDownloading && item.speed && (
-
Speed
-
{formatSpeed(item.speed)}
+
+ Speed +
+
+ {formatSpeed(item.speed)} +
)} {isDownloading && item.eta && (
-
ETA
-
{item.eta}
+
+ ETA +
+
+ {item.eta} +
)}
- {/* Step log timeline */} {item.steps?.length > 0 && (
-
Activity Log
+
+ Activity Log +
+ {/* Reverse for display only; callers keep item.steps in append order. */} {[...item.steps].reverse().map((step, i) => { const isLatest = i === 0; const relTime = formatRelTime(step.ts); - const absTime = new Date(step.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const absTime = new Date(step.ts).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); return (
- {/* Dot + line */} -
-
+
+
{i < item.steps.length - 1 && ( -
+
)}
- {/* Message + timestamp */}
-
+
{step.message}
-
+
{relTime} · {absTime}
@@ -344,12 +695,11 @@ function PipelineCard({ item, expanded, onToggle, onRetry, onCancel, onMonitor, ); } -/** - * Re-render only when visible card state changes; ignore stable callback churn from parent polling. - */ +// Parent polling churns object identity, so this uses a cheap visible-state signature. function arePropsEqual(prev, next) { if (prev.expanded !== next.expanded) return false; - const a = prev.item, b = next.item; + const a = prev.item, + b = next.item; if (a === b) return true; if (!a || !b) return false; if ( @@ -364,13 +714,13 @@ function arePropsEqual(prev, next) { a.posterUrl !== b.posterUrl || a.stuckReason !== b.stuckReason || a.canRetry !== b.canRetry || - a.statusDetail !== b.statusDetail || - a.statusUpdatedAt !== b.statusUpdatedAt || a.stageStartedAt !== b.stageStartedAt || a.startedAt !== b.startedAt - ) return false; - // steps: compare length + last ts (cheap signature) - const al = a.steps?.length || 0, bl = b.steps?.length || 0; + ) + return false; + // Length + newest timestamp catches appended log entries without deep-comparing the array. + const al = a.steps?.length || 0, + bl = b.steps?.length || 0; if (al !== bl) return false; if (al > 0 && a.steps[al - 1]?.ts !== b.steps[bl - 1]?.ts) return false; return true; diff --git a/frontend/src/SidePanel.jsx b/frontend/src/SidePanel.jsx index b291dd4..9dea192 100644 --- a/frontend/src/SidePanel.jsx +++ b/frontend/src/SidePanel.jsx @@ -1,627 +1,104 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback, useId } from 'react'; -import { formatSpeed, getTorrentState, formatBytes, timeAgo } from './utils'; -import PosterImage from './PosterImage'; +import React, { useRef, useMemo, useCallback, useId } from 'react'; +import { getTorrentState } from './utils'; +import { useSidePanelData } from './hooks/useSidePanelData'; +import { useMobilePanelFocus } from './hooks/useMobilePanelFocus'; +import PanelBody from './components/sidePanel/PanelBody'; +import { PANEL_WIDTH, PANEL_SLIDE_MS, PANEL_FADE_MS, PANEL_EASING } from './components/sidePanel/constants'; -const DOT_COLOR = { - success: '#30d158', - error: '#ff375f', - pending: '#ff9f0a', - info: '#0a84ff', -}; - -const SERVICE_ICON = { sonarr: 'tv', radarr: 'movie', lidarr: 'album', slskd: 'cloud_download' }; - -const PANEL_TITLE_STYLE = { - fontSize: 11, fontWeight: 700, letterSpacing: '0.06em', - textTransform: 'uppercase', - color: 'var(--text-muted)', - marginBottom: 14, -}; - -const SEPARATOR = '1px solid var(--border-subtle)'; -const ACTIVE_DOWNLOAD_BLUE = '#0a84ff'; -const ACTIVE_DOWNLOAD_BLUE_SOFT = '#5ac8fa'; - -const Thumb = React.memo(function Thumb({ url, title, square }) { - const size = square ? { width: 44, height: 44 } : { width: 36, height: 50 }; - const initials = (title || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase(); - return ( - - {initials} -
- )} - /> +/** + * Right-side panel showing active downloads, activity log, bandwidth sparkline, and storage. + */ +function SidePanel({ + torrents, + slskdDownloads, + mediaInfo, + onSlskdUpdate, + onTorrentRefresh, + bwHistory = [], + bwTotals = {}, + bwLifetime = {}, + isMobile = false, + isOpen = true, + onClose, +}) { + const panelRef = useRef(null); + const titleId = useId(); + const { activity, storage, dismissActivity, clearActivity } = useSidePanelData(); + + useMobilePanelFocus(isMobile, isOpen, onClose, panelRef); + + const activeDownloads = useMemo( + () => + (torrents || []) + .filter(t => { + const s = getTorrentState(t); + return s === 'downloading' || s === 'paused'; + }) + .sort((a, b) => (b.downloadSpeed || 0) - (a.downloadSpeed || 0)), + [torrents], ); -}); -const IconButton = React.memo(function IconButton({ onClick, title, danger, disabled, children }) { - const [hover, setHover] = useState(false); - return ( - + const activeSlskd = useMemo( + () => (slskdDownloads || []).filter(d => d.inProgress > 0 || d.queued > 0 || d.failed > 0), + [slskdDownloads], ); -}); - -// Clean up ugly activity messages like "Title S01E02: long file name blob" -function formatActivity(item) { - const title = item.context?.title || item.context?.artistName || null; - const raw = item.message || ''; - const svc = item.context?.service; - - // Pattern: " SxxEyy: <rest>" → extract episode + reason - const epMatch = raw.match(/^(.*?)\s+(S\d{1,2}E\d{1,3}):?\s*(.*)$/i); - if (epMatch) { - const [, , ep, rest] = epMatch; - const subject = title ? `${title} · ${ep}` : ep; - const reason = cleanReason(rest, svc); - return { subject, reason }; - } - // Pattern: "<Title>: <rest>" - const colonIdx = raw.indexOf(':'); - if (title && colonIdx > 0) { - const subject = title; - const reason = cleanReason(raw.slice(colonIdx + 1).trim(), svc); - return { subject, reason }; - } - if (title) return { subject: title, reason: cleanReason(raw, svc) }; - return { subject: raw, reason: '' }; -} - -function cleanReason(s, svc) { - if (!s) return ''; - let r = s.trim(); - // Strip the release-name noise: "Show.Name.S01E02.1080p.WEB..." - r = r.replace(/\b[A-Z0-9][\w.-]+\.(mkv|mp4|avi|flac|mp3|srt|scr)\b/gi, ''); - r = r.replace(/\b\d{3,4}p\b/gi, ''); - r = r.replace(/\b(WEB-?DL|WEB|BluRay|REMUX|HDTV|WEBRip|DVDRip|x265|x264|h264|h265|HEVC|FLAC|DTS|AAC|DD5\.1|Atmos|DV|HDR|AMZN|NF)\b/gi, ''); - r = r.replace(/-[A-Z0-9]{2,}$/gi, ''); - r = r.replace(/\.(scr|exe)\b/gi, ''); - r = r.replace(/\s+/g, ' ').trim(); - - const friendly = { - 'qbittorrent is reporting missing files': 'Missing files in qBittorrent', - 'qbittorrent is reporting completed download': 'Download completed', - 'unable to parse': 'Could not identify release', - 'no files found': 'No matching files', - }; - const lower = r.toLowerCase(); - for (const [k, v] of Object.entries(friendly)) if (lower.includes(k)) return v; - if (!r) return svc ? `${svc} update` : ''; - return r.length > 60 ? r.slice(0, 58) + '…' : r; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Now Downloading — per-torrent card -// ═══════════════════════════════════════════════════════════════════════════ -function DownloadMoreMenu({ onClose, onPause, onDelete, onDeleteWithFiles, paused, anchorRef }) { - const menuRef = useRef(null); - useEffect(() => { - const handler = (e) => { - if (menuRef.current?.contains(e.target)) return; - if (anchorRef?.current?.contains(e.target)) return; - onClose(); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [onClose, anchorRef]); - return ( - <div - ref={menuRef} - style={{ - position: 'absolute', right: 0, top: 'calc(100% + 4px)', zIndex: 100, - background: 'var(--surface-elevated)', - border: '1px solid var(--border-medium)', - borderRadius: 10, padding: 4, minWidth: 180, - boxShadow: '0 12px 32px rgba(0,0,0,0.3)', - backdropFilter: 'blur(20px)', - }} - > - <MenuItem icon={paused ? 'play_arrow' : 'pause'} label={paused ? 'Resume' : 'Pause'} onClick={() => { onPause(); onClose(); }} /> - <MenuItem icon="delete_outline" label="Remove from list" onClick={() => { onDelete(); onClose(); }} /> - <MenuItem icon="delete_forever" label="Remove + delete files" danger onClick={() => { onDeleteWithFiles(); onClose(); }} /> - </div> - ); -} - -const MenuItem = React.memo(function MenuItem({ icon, label, onClick, danger }) { - const [hover, setHover] = useState(false); - return ( - <button - onClick={onClick} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 10, - width: '100%', padding: '7px 10px', borderRadius: 7, - background: hover ? (danger ? 'rgba(255,55,95,0.14)' : 'var(--border-subtle)') : 'transparent', - color: danger ? '#ff6b8a' : 'var(--text-primary)', - fontSize: 12, fontWeight: 500, border: 'none', cursor: 'pointer', - textAlign: 'left', - }} - > - <span className="material-symbols-rounded" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>{icon}</span> - {label} - </button> + const handleTorrentAction = useCallback( + async (hash, action) => { + const routes = { + pause: { url: `/api/qbittorrent/torrents/${hash}/pause`, method: 'POST' }, + resume: { url: `/api/qbittorrent/torrents/${hash}/resume`, method: 'POST' }, + delete: { url: `/api/qbittorrent/torrents/${hash}?deleteFiles=false`, method: 'DELETE' }, + deleteFiles: { url: `/api/qbittorrent/torrents/${hash}?deleteFiles=true`, method: 'DELETE' }, + }; + const route = routes[action]; + if (!route) return; + const r = await fetch(route.url, { method: route.method }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + setTimeout(() => onTorrentRefresh?.(), 400); + }, + [onTorrentRefresh], ); -}); - -const QbDownloadItem = React.memo(function QbDownloadItem({ torrent, info, onAction, onOpenDetail, isLast }) { - const [menuOpen, setMenuOpen] = useState(false); - const [actionBusy, setActionBusy] = useState(false); - const [actionErr, setActionErr] = useState(null); - const [confirmCancel, setConfirmCancel] = useState(false); - const cancelTimerRef = useRef(null); - const moreBtnRef = useRef(null); - useEffect(() => () => clearTimeout(cancelTimerRef.current), []); - const pct = Math.round(torrent.progress || 0); - const displayTitle = info?.title || torrent.name; - const sub = [info?.year, info?.network, info?.episodeNumber].filter(Boolean).join(' · ') - || (torrent.size > 0 ? formatBytes(torrent.size) : ''); - const state = getTorrentState(torrent); - const paused = state === 'paused'; - const speed = torrent.downloadSpeed || 0; - const isMusic = info?.mediaType === 'music' || /lidarr|music/i.test(torrent.category || ''); - const isLiveDownload = !paused && speed > 0; - - const handle = async (action) => { - setActionBusy(true); - setActionErr(null); - try { - await onAction(torrent.hash, action); - } catch (e) { - setActionErr(e.message || 'Action failed'); - setTimeout(() => setActionErr(null), 3000); - } - setActionBusy(false); - }; - return ( - <div style={{ - paddingBottom: isLast ? 0 : 12, marginBottom: isLast ? 0 : 12, - borderBottom: isLast ? 'none' : SEPARATOR, - position: 'relative', - }}> - <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 7 }}> - <Thumb url={info?.posterUrl} title={displayTitle} square={isMusic} /> - <div style={{ flex: 1, minWidth: 0 }}> - <button - onClick={() => onOpenDetail?.(torrent, info)} - title={displayTitle} - style={{ - display: 'block', width: '100%', textAlign: 'left', padding: 0, - background: 'transparent', border: 'none', cursor: 'pointer', - fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, - whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - }} - > - {displayTitle} - </button> - {sub && ( - <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> - {sub} - </div> - )} - <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}> - {speed > 0 ? ( - <span style={{ - fontSize: 11, - fontWeight: 600, - color: ACTIVE_DOWNLOAD_BLUE_SOFT, - fontVariantNumeric: 'tabular-nums', - animation: isLiveDownload ? 'downloadPulse 1.8s ease-in-out infinite' : 'none', - }}> - ↓ {formatSpeed(speed)} - </span> - ) : ( - <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-disabled)' }}> - {paused ? 'Paused' : 'Stalled'} - </span> - )} - <span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}> - {pct}% - </span> - </div> - </div> - <div style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}> - <IconButton - title={paused ? 'Resume' : 'Pause'} - onClick={() => handle(paused ? 'resume' : 'pause')} - > - <span className="material-symbols-rounded" style={{ fontSize: 15, fontVariationSettings: "'FILL' 1" }}>{paused ? 'play_arrow' : 'pause'}</span> - </IconButton> - {confirmCancel ? ( - <button - onClick={() => { clearTimeout(cancelTimerRef.current); setConfirmCancel(false); handle('delete'); }} - style={{ height: 30, padding: '0 8px', borderRadius: 8, display: 'flex', alignItems: 'center', gap: 4, - background: 'rgba(255,69,58,0.85)', border: 'none', cursor: 'pointer', - fontSize: 11, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }} - > - <span className="material-symbols-rounded" style={{ fontSize: 13, fontVariationSettings: "'FILL' 1" }}>delete</span> - Remove? - </button> - ) : ( - <IconButton title="Cancel download" danger - onClick={() => { setConfirmCancel(true); clearTimeout(cancelTimerRef.current); cancelTimerRef.current = setTimeout(() => setConfirmCancel(false), 2500); }} - > - <span className="material-symbols-rounded" style={{ fontSize: 15, fontVariationSettings: "'FILL' 1" }}>close</span> - </IconButton> - )} - <div ref={moreBtnRef}> - <IconButton title="More" onClick={() => setMenuOpen(!menuOpen)}> - <span className="material-symbols-rounded" style={{ fontSize: 16 }}>more_vert</span> - </IconButton> - </div> - {menuOpen && ( - <DownloadMoreMenu - anchorRef={moreBtnRef} - onClose={() => setMenuOpen(false)} - paused={paused} - onPause={() => handle(paused ? 'resume' : 'pause')} - onDelete={() => handle('delete')} - onDeleteWithFiles={() => handle('deleteFiles')} - /> - )} - </div> - </div> - <div style={{ height: 3, background: 'var(--progress-bar)', borderRadius: 2, overflow: 'hidden' }}> - <div style={{ - height: '100%', width: `${pct}%`, borderRadius: 2, - background: paused - ? 'linear-gradient(90deg, #636366 0%, #8e8e93 100%)' - : `linear-gradient(90deg, ${ACTIVE_DOWNLOAD_BLUE} 0%, ${ACTIVE_DOWNLOAD_BLUE_SOFT} 100%)`, - boxShadow: paused ? 'none' : '0 0 8px rgba(10,132,255,0.35)', - transition: 'width 0.4s ease', - animation: isLiveDownload ? 'downloadPulse 1.8s ease-in-out infinite' : 'none', - }} /> - </div> - {actionErr && ( - <p style={{ fontSize: 10, color: '#ff453a', marginTop: 4, textAlign: 'right' }}>{actionErr}</p> - )} - </div> + const handleSlskdDelete = useCallback( + async username => { + await fetch(`/api/slskd/downloads/${encodeURIComponent(username)}`, { method: 'DELETE' }); + onSlskdUpdate?.(); + }, + [onSlskdUpdate], ); -}); - -const SlskdDownloadItem = React.memo(function SlskdDownloadItem({ dl, onDelete, onRetry, isLast }) { - const [menuOpen, setMenuOpen] = useState(false); - const [confirmCancel, setConfirmCancel] = useState(false); - const cancelTimerRef = useRef(null); - const moreBtnRef = useRef(null); - useEffect(() => () => clearTimeout(cancelTimerRef.current), []); - const pct = dl.percentComplete || 0; - const speed = dl.files?.find(f => f.state === 'InProgress')?.averageSpeed || 0; - const hasFailed = dl.failed > 0; - return ( - <div style={{ - paddingBottom: isLast ? 0 : 12, marginBottom: isLast ? 0 : 12, - borderBottom: isLast ? 'none' : SEPARATOR, - position: 'relative', - }}> - <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 7 }}> - <Thumb url={dl.posterUrl} title={`${dl.artistName} ${dl.albumName}`} square /> - <div style={{ flex: 1, minWidth: 0 }}> - <div title={`${dl.artistName} — ${dl.albumName}`} style={{ - fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, - whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - }}> - {dl.albumName} - </div> - <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> - {dl.artistName} · Soulseek - </div> - <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}> - {speed > 0 ? ( - <span style={{ fontSize: 11, fontWeight: 600, color: '#30d158', fontVariantNumeric: 'tabular-nums' }}> - ↓ {formatSpeed(speed)} - </span> - ) : ( - <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-disabled)' }}> - {hasFailed ? `${dl.failed} failed` : 'Queued'} - </span> - )} - <span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}> - {dl.completed}/{dl.fileCount} - </span> - </div> - </div> - <div style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}> - {hasFailed && ( - <IconButton title="Retry failed" onClick={() => onRetry(dl.username)}> - <span className="material-symbols-rounded" style={{ fontSize: 15 }}>refresh</span> - </IconButton> - )} - {confirmCancel ? ( - <button - onClick={() => { clearTimeout(cancelTimerRef.current); setConfirmCancel(false); onDelete(dl.username); }} - style={{ height: 30, padding: '0 8px', borderRadius: 8, display: 'flex', alignItems: 'center', gap: 4, - background: 'rgba(255,69,58,0.85)', border: 'none', cursor: 'pointer', - fontSize: 11, fontWeight: 700, color: '#fff', whiteSpace: 'nowrap' }} - > - <span className="material-symbols-rounded" style={{ fontSize: 13, fontVariationSettings: "'FILL' 1" }}>delete</span> - Remove? - </button> - ) : ( - <IconButton title="Cancel download" danger - onClick={() => { setConfirmCancel(true); clearTimeout(cancelTimerRef.current); cancelTimerRef.current = setTimeout(() => setConfirmCancel(false), 2500); }} - > - <span className="material-symbols-rounded" style={{ fontSize: 15, fontVariationSettings: "'FILL' 1" }}>close</span> - </IconButton> - )} - </div> - </div> - <div style={{ height: 3, background: 'var(--progress-bar)', borderRadius: 2, overflow: 'hidden' }}> - <div style={{ - height: '100%', width: `${pct}%`, borderRadius: 2, - background: hasFailed - ? 'linear-gradient(90deg, #ff9f0a 0%, #ffd60a 100%)' - : 'linear-gradient(90deg, #30d158 0%, #5ad67e 100%)', - transition: 'width 0.4s ease', - }} /> - </div> - </div> + const handleSlskdRetry = useCallback( + async (username, fileId) => { + await fetch('/api/slskd/retry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, fileId }), + }); + setTimeout(() => onSlskdUpdate?.(), 1000); + }, + [onSlskdUpdate], ); -}); -// ═══════════════════════════════════════════════════════════════════════════ -// Activity row -// ═══════════════════════════════════════════════════════════════════════════ - -const ActivityRow = React.memo(function ActivityRow({ item, onDismiss, isLast, isMobile = false }) { - const [hover, setHover] = useState(false); - const { subject, reason } = formatActivity(item); - const svcIcon = SERVICE_ICON[item.context?.service]; - const color = DOT_COLOR[item.status] || DOT_COLOR.info; - - return ( - <div - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - display: 'flex', alignItems: 'flex-start', gap: 10, - padding: '8px 4px', borderBottom: isLast ? 'none' : SEPARATOR, - position: 'relative', - }} - > - <div style={{ - width: 6, height: 6, borderRadius: '50%', flexShrink: 0, marginTop: 6, - background: color, - boxShadow: item.status === 'pending' ? `0 0 6px ${color}` : 'none', - }} /> - <div style={{ flex: 1, minWidth: 0 }}> - <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> - {svcIcon && ( - <span className="material-symbols-rounded" style={{ - fontSize: 11, color: 'var(--text-muted)', - fontVariationSettings: "'FILL' 1", flexShrink: 0, - }}>{svcIcon}</span> - )} - <span title={subject} style={{ - fontSize: 11.5, color: 'var(--text-primary)', fontWeight: 500, - lineHeight: 1.3, flex: 1, minWidth: 0, - whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - }}>{subject}</span> - </div> - {reason && ( - <div title={reason} style={{ - fontSize: 10.5, color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.3, - whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', - }}>{reason}</div> - )} - </div> - <div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, marginTop: 3 }}> - <span style={{ fontSize: 10, color: 'var(--text-disabled)', fontVariantNumeric: 'tabular-nums' }}> - {timeAgo(item.timestamp)} - </span> - {(hover || isMobile) && ( - <button - onClick={(e) => { e.stopPropagation(); onDismiss(item.id); }} - title="Dismiss" - style={{ - width: 18, height: 18, borderRadius: 4, - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - border: 'none', background: 'transparent', cursor: 'pointer', - color: 'var(--text-muted)', - }} - onMouseEnter={e => { e.currentTarget.style.background = 'var(--border-medium)'; e.currentTarget.style.color = 'var(--text-primary)'; }} - onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; }} - > - <span className="material-symbols-rounded" style={{ fontSize: 12 }}>close</span> - </button> - )} - </div> - </div> - ); -}); - - -const Sparkline = React.memo(function Sparkline({ data, height = 38 }) { - if (!data || data.length < 2) return <div style={{ height }} />; - const w = 280; - const h = height; - const maxVal = Math.max(1, ...data.map(d => Math.max(d.dl, d.ul))); - const pts = (key) => data.map((d, i) => { - const x = (i / (data.length - 1)) * w; - const y = h - (d[key] / maxVal) * (h - 4) - 2; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }).join(' '); - return ( - <svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ display: 'block' }}> - <polyline points={pts('dl')} fill="none" stroke="#30d158" strokeWidth="1.5" strokeLinejoin="round" opacity="0.9"/> - <polyline points={pts('ul')} fill="none" stroke="#FF375F" strokeWidth="1.5" strokeLinejoin="round" opacity="0.65"/> - </svg> - ); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Main panel -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Right-side panel showing active downloads, activity log, bandwidth sparkline, and storage. - * On desktop: fixed 320px inline sidebar. - * On mobile: slide-in overlay from the right edge. - * @param {Array} torrents - qBittorrent torrent list - * @param {Array} slskdDownloads - Soulseek download list - * @param {Object} mediaInfo - Hash → media metadata map for poster/title lookup - * @param {Function} onSlskdUpdate - Refresh Soulseek downloads - * @param {Function} onTorrentRefresh - Refresh torrent list - * @param {Array} bwHistory - Rolling 60-point bandwidth history [{dl, ul}] - * @param {Object} bwTotals - Session totals { dl: bytes, ul: bytes } - * @param {boolean} isMobile - Whether the viewport is mobile-width (<768px) - * @param {boolean} isOpen - Whether the panel is visible (mobile overlay mode) - * @param {Function} onClose - Called when backdrop or close button is tapped (mobile) - */ -function SidePanel({ torrents, slskdDownloads, mediaInfo, onSlskdUpdate, onTorrentRefresh, bwHistory = [], bwTotals = {}, bwLifetime = {}, isMobile = false, isOpen = true, onClose }) { - const [activity, setActivity] = useState([]); - const [storage, setStorage] = useState(null); - const prevActivityIdRef = useRef(null); - const panelRef = useRef(null); - const prevFocusRef = useRef(null); - const titleId = useId(); - - // Focus management + Esc to close (mobile overlay) - useEffect(() => { - if (!isMobile) return; - if (isOpen) { - prevFocusRef.current = document.activeElement; - // Move focus into panel - const node = panelRef.current; - if (node) { - const focusable = node.querySelector('button, [href], input, [tabindex]:not([tabindex="-1"])'); - (focusable || node).focus?.(); - } - const onKey = (e) => { - if (e.key === 'Escape') { e.stopPropagation(); onClose?.(); } - }; - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); - } else if (prevFocusRef.current) { - try { prevFocusRef.current.focus?.(); } catch {} - prevFocusRef.current = null; - } - }, [isMobile, isOpen, onClose]); - - const fetchActivity = async () => { - try { - const r = await fetch('/api/activity-log?limit=20', { cache: 'no-store' }); - if (r.ok) { - const items = await r.json(); - // The first successful poll only seeds the ref; later polls notify on new import/success head items. - if (items.length > 0 && typeof Notification !== 'undefined' && Notification.permission === 'granted') { - const newestId = items[0].id; - if (prevActivityIdRef.current !== null && newestId !== prevActivityIdRef.current) { - const newItem = items[0]; - const msg = (newItem.message || '').toLowerCase(); - const isImport = msg.includes('import') || msg.includes('added to library') || newItem.status === 'success'; - if (isImport) { - const title = newItem.context?.title || newItem.context?.artistName || 'Media imported'; - new Notification(`Imported: ${title}`, { - body: newItem.message || 'New activity', - icon: '/favicon.ico', - }); - } - } - if (prevActivityIdRef.current === null || items[0].id !== prevActivityIdRef.current) { - prevActivityIdRef.current = items[0].id; - } - } else if (items.length > 0 && prevActivityIdRef.current === null) { - prevActivityIdRef.current = items[0].id; - } - setActivity(items); - } - } catch {} - }; - const fetchStorage = async () => { - try { - const r = await fetch('/api/storage', { cache: 'no-store' }); - if (r.ok) setStorage(await r.json()); - } catch {} + const bodyProps = { + titleId, + isMobile, + onClose, + activeDownloads, + activeSlskd, + mediaInfo, + onTorrentAction: handleTorrentAction, + onSlskdDelete: handleSlskdDelete, + onSlskdRetry: handleSlskdRetry, + activity, + onDismissActivity: dismissActivity, + onClearActivity: clearActivity, + bwHistory, + bwTotals, + bwLifetime, + storage, }; - useEffect(() => { - fetchActivity(); - fetchStorage(); - const t1 = setInterval(fetchActivity, 15000); - const t2 = setInterval(fetchStorage, 60000); - return () => { clearInterval(t1); clearInterval(t2); }; - }, []); - - // Keep the download stack focused on items the operator can still act on. - const activeDownloads = useMemo(() => (torrents || []) - .filter(t => { const s = getTorrentState(t); return s === 'downloading' || s === 'paused'; }) - .sort((a, b) => (b.downloadSpeed || 0) - (a.downloadSpeed || 0)), [torrents]); - const activeSlskd = useMemo(() => (slskdDownloads || []).filter(d => d.inProgress > 0 || d.queued > 0 || d.failed > 0), [slskdDownloads]); - - const handleTorrentAction = useCallback(async (hash, action) => { - let url, opts; - if (action === 'pause') { url = `/api/qbittorrent/torrents/${hash}/pause`; opts = { method: 'POST' }; } - else if (action === 'resume') { url = `/api/qbittorrent/torrents/${hash}/resume`; opts = { method: 'POST' }; } - else if (action === 'delete') { url = `/api/qbittorrent/torrents/${hash}?deleteFiles=false`; opts = { method: 'DELETE' }; } - else if (action === 'deleteFiles') { url = `/api/qbittorrent/torrents/${hash}?deleteFiles=true`; opts = { method: 'DELETE' }; } - else return; - const r = await fetch(url, opts); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - setTimeout(() => onTorrentRefresh?.(), 400); - }, [onTorrentRefresh]); - - const handleSlskdDelete = useCallback(async (username) => { - await fetch(`/api/slskd/downloads/${encodeURIComponent(username)}`, { method: 'DELETE' }); - onSlskdUpdate?.(); - }, [onSlskdUpdate]); - const handleSlskdRetry = useCallback(async (username, fileId) => { - await fetch('/api/slskd/retry', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, fileId }), - }); - setTimeout(() => onSlskdUpdate?.(), 1000); - }, [onSlskdUpdate]); - - const handleDismissActivity = useCallback(async (id) => { - try { - await fetch(`/api/activity-log/${id}`, { method: 'DELETE' }); - setActivity(prev => prev.filter(a => a.id !== id)); - } catch {} - }, []); - - const totalItems = activeDownloads.length + activeSlskd.length; - return ( <> {isMobile && ( @@ -637,226 +114,78 @@ function SidePanel({ torrents, slskdDownloads, mediaInfo, onSlskdUpdate, onTorre WebkitBackdropFilter: 'blur(2px)', opacity: isOpen ? 1 : 0, pointerEvents: isOpen ? 'auto' : 'none', - transition: 'opacity 240ms cubic-bezier(0.22, 1, 0.36, 1)', + transition: `opacity ${PANEL_FADE_MS}ms ${PANEL_EASING}`, willChange: 'opacity', }} /> )} - <aside - ref={panelRef} - role={isMobile ? 'dialog' : undefined} - aria-modal={isMobile ? 'true' : undefined} - aria-labelledby={isMobile ? titleId : undefined} - aria-hidden={isMobile && !isOpen ? 'true' : undefined} - tabIndex={isMobile ? -1 : undefined} - style={isMobile ? { - position: 'fixed', - top: 0, - right: 0, - bottom: 0, - width: '85%', - maxWidth: 360, - background: 'var(--bg-nav)', - borderLeft: '1px solid var(--border-subtle)', - display: 'flex', - flexDirection: 'column', - overflowY: 'auto', - WebkitOverflowScrolling: 'touch', - overscrollBehavior: 'contain', - zIndex: 150, - transform: isOpen ? 'translate3d(0,0,0)' : 'translate3d(100%,0,0)', - opacity: isOpen ? 1 : 0, - willChange: 'transform, opacity', - transition: 'transform 360ms cubic-bezier(0.22, 1, 0.36, 1), opacity 240ms cubic-bezier(0.22, 1, 0.36, 1)', - pointerEvents: isOpen ? 'auto' : 'none', - boxShadow: isOpen ? '-8px 0 32px rgba(0,0,0,0.3)' : 'none', - paddingBottom: 'calc(56px + env(safe-area-inset-bottom))', - } : { - width: 320, - flexShrink: 0, - background: 'var(--bg-nav)', - borderLeft: '1px solid var(--border-subtle)', - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - }}> - - {isMobile && ( - <div style={{ - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '16px 16px 8px', - borderBottom: '1px solid var(--border-subtle)', - flexShrink: 0, - }}> - <span id={titleId} style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-secondary)', letterSpacing: '-0.01em' }}>Panel</span> - <button - onClick={onClose} + {isMobile ? ( + <aside + ref={panelRef} + role="dialog" + aria-modal="true" + aria-labelledby={titleId} + aria-hidden={!isOpen ? 'true' : undefined} + tabIndex={-1} + style={{ + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + width: '85%', + maxWidth: 360, + background: 'var(--bg-nav)', + borderLeft: '1px solid var(--border-subtle)', + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + WebkitOverflowScrolling: 'touch', + overscrollBehavior: 'contain', + zIndex: 150, + transform: isOpen ? 'translate3d(0,0,0)' : 'translate3d(100%,0,0)', + opacity: isOpen ? 1 : 0, + willChange: 'transform, opacity', + transition: `transform ${PANEL_SLIDE_MS}ms ${PANEL_EASING}, opacity ${PANEL_FADE_MS}ms ${PANEL_EASING}`, + pointerEvents: isOpen ? 'auto' : 'none', + boxShadow: isOpen ? '-8px 0 32px rgba(0,0,0,0.3)' : 'none', + paddingBottom: 'calc(56px + env(safe-area-inset-bottom))', + }} + > + <PanelBody {...bodyProps} /> + </aside> + ) : ( + <div + aria-hidden={!isOpen ? 'true' : undefined} + style={{ + width: isOpen ? PANEL_WIDTH : 0, + flexShrink: 0, + overflow: 'hidden', + height: '100%', + transition: `width ${PANEL_SLIDE_MS}ms ${PANEL_EASING}`, + willChange: 'width', + }} + > + <aside + ref={panelRef} style={{ - width: 30, height: 30, borderRadius: 8, - background: 'var(--border-subtle)', - border: 'none', cursor: 'pointer', - display: 'flex', alignItems: 'center', justifyContent: 'center', - color: 'var(--text-secondary)', + width: PANEL_WIDTH, + height: '100%', + background: 'var(--bg-nav)', + borderLeft: isOpen ? '1px solid var(--border-subtle)' : '1px solid transparent', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + opacity: isOpen ? 1 : 0, + transform: isOpen ? 'translate3d(0,0,0)' : 'translate3d(16px,0,0)', + pointerEvents: isOpen ? 'auto' : 'none', + transition: `opacity ${PANEL_FADE_MS}ms ${PANEL_EASING}, transform ${PANEL_SLIDE_MS}ms ${PANEL_EASING}, border-color ${PANEL_FADE_MS}ms ${PANEL_EASING}`, + willChange: 'opacity, transform', }} > - <span className="material-symbols-rounded" style={{ fontSize: 18 }}>close</span> - </button> - </div> - )} - - {/* ── Now Downloading ── */} - <div style={{ padding: '16px 16px 16px', borderBottom: SEPARATOR, flexShrink: 0, willChange: 'transform', maxHeight: '45%', overflowY: 'auto', overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch', scrollbarWidth: 'thin' }} className="scroll-area"> - <div style={{ ...PANEL_TITLE_STYLE, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> - <span>Now Downloading</span> - {totalItems > 0 && ( - <span style={{ color: 'var(--text-secondary)', fontSize: 10, letterSpacing: 0 }}> - {totalItems} - </span> - )} - </div> - - {totalItems === 0 ? ( - <p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: '6px 0' }}> - No active downloads - </p> - ) : ( - <div> - {activeDownloads.map((t, idx) => { - const info = mediaInfo?.[t.hash] || mediaInfo?.[t.hash?.toLowerCase()]; - const isLast = idx === activeDownloads.length - 1 && activeSlskd.length === 0; - return ( - <QbDownloadItem - key={t.hash} - torrent={t} - info={info} - onAction={handleTorrentAction} - isLast={isLast} - /> - ); - })} - {activeSlskd.map((dl, i) => ( - <SlskdDownloadItem - key={dl.username + dl.directory} - dl={dl} - onDelete={handleSlskdDelete} - onRetry={handleSlskdRetry} - isLast={i === activeSlskd.length - 1} - /> - ))} - </div> - )} - </div> - - {/* ── Activity Log ── */} - <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', padding: '16px 16px 16px', borderBottom: SEPARATOR }}> - <div style={{ ...PANEL_TITLE_STYLE, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> - <span>Activity</span> - {activity.length > 0 && ( - <button - onClick={async () => { await fetch('/api/activity-log', { method: 'DELETE' }); setActivity([]); }} - title="Clear all" - style={{ - fontSize: 10, color: 'var(--text-muted)', - background: 'transparent', border: 'none', cursor: 'pointer', - padding: 0, letterSpacing: 0, textTransform: 'none', - }} - >Clear</button> - )} - </div> - <div style={{ willChange: 'transform', overflowY: 'auto', flex: 1, scrollbarWidth: 'thin', overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }} className="scroll-area"> - {activity.length === 0 ? ( - <p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: '6px 0' }}> - No recent activity - </p> - ) : ( - activity.map((item, i) => ( - <ActivityRow - key={item.id} - item={item} - onDismiss={handleDismissActivity} - isLast={i === activity.length - 1} - isMobile={isMobile} - /> - )) - )} - </div> - </div> - - {/* ── Bandwidth ── */} - {bwHistory.length > 0 && ( - <div style={{ padding: '16px 16px 16px', borderBottom: SEPARATOR, flexShrink: 0 }}> - <div style={{ ...PANEL_TITLE_STYLE, marginBottom: 8 }}>Bandwidth</div> - <div style={{ borderRadius: 6, overflow: 'hidden', background: 'var(--surface-subtle)' }}> - <Sparkline data={bwHistory} /> - </div> - <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, fontSize: 11.5, fontVariantNumeric: 'tabular-nums' }}> - <span style={{ color: '#30d158', fontWeight: 600 }}> - ↓ {formatSpeed(bwHistory[bwHistory.length - 1]?.dl || 0)} - </span> - <span style={{ color: '#FF375F', opacity: 0.85, fontWeight: 600 }}> - ↑ {formatSpeed(bwHistory[bwHistory.length - 1]?.ul || 0)} - </span> - </div> - {(bwTotals.dl > 0 || bwTotals.ul > 0) && ( - <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 5, fontSize: 10.5, color: 'var(--text-disabled)', fontVariantNumeric: 'tabular-nums' }}> - <span>↓ {formatBytes(bwTotals.dl)} session</span> - <span>↑ {formatBytes(bwTotals.ul)} session</span> - </div> - )} - {(bwLifetime.dl > 0 || bwLifetime.ul > 0) && ( - <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 3, fontSize: 10.5, fontVariantNumeric: 'tabular-nums' }}> - <span style={{ color: 'rgba(48,209,88,0.6)' }}>↓ {formatBytes(bwLifetime.dl)} lifetime</span> - <span style={{ color: 'rgba(255,55,95,0.5)' }}>↑ {formatBytes(bwLifetime.ul)} lifetime</span> - </div> - )} - </div> - )} - - {/* ── Storage ── */} - {storage && ( - <div style={{ padding: '16px 16px 24px', flexShrink: 0 }}> - <div style={{ ...PANEL_TITLE_STYLE, marginBottom: 12 }}>Storage</div> - <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> - {storage.breakdown?.filter(d => d.size > 0).slice(0, 3).map(d => ( - <div key={d.path}> - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 5 }}> - <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-secondary)' }}>{d.name}</span> - <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{formatBytes(d.size)}</span> - </div> - <div style={{ height: 4, background: 'var(--progress-bar)', borderRadius: 3, overflow: 'hidden' }}> - <div style={{ - height: '100%', - width: storage.disk ? `${Math.min(100, Math.round((d.size / storage.disk.total) * 100))}%` : '50%', - borderRadius: 3, - background: 'linear-gradient(90deg, #0A84FF 0%, #5AC8FA 100%)', - transition: 'width 0.5s ease', - }} /> - </div> - </div> - ))} - {storage.disk && (() => { - const pct = Math.round((storage.disk.used / storage.disk.total) * 100); - const bg = pct > 85 - ? 'linear-gradient(90deg, #FF375F 0%, #FF6B8A 100%)' - : pct > 75 - ? 'linear-gradient(90deg, #FF9F0A 0%, #FFD60A 100%)' - : 'linear-gradient(90deg, #0A84FF 0%, #5AC8FA 100%)'; - return ( - <div style={{ marginTop: 2, paddingTop: 10, borderTop: SEPARATOR }}> - <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 5 }}> - <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-secondary)' }}>Total</span> - <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}>{formatBytes(storage.disk.available)} free</span> - </div> - <div style={{ height: 4, background: 'var(--progress-bar)', borderRadius: 3, overflow: 'hidden' }}> - <div style={{ height: '100%', width: `${pct}%`, borderRadius: 3, background: bg, transition: 'width 0.5s ease' }} /> - </div> - </div> - ); - })()} - </div> + <PanelBody {...bodyProps} /> + </aside> </div> )} - </aside> </> ); } diff --git a/frontend/src/TorrentTable.jsx b/frontend/src/TorrentTable.jsx index 923f56e..6cbd703 100644 --- a/frontend/src/TorrentTable.jsx +++ b/frontend/src/TorrentTable.jsx @@ -1,426 +1,44 @@ import { useState, useRef, useEffect, memo } from 'react'; -import { formatBytes, formatSpeed, formatETA, getTorrentState, gradientFor, extractRating } from './utils'; -import { getServiceUrl } from './constants'; -import PosterImage from './PosterImage'; +import { getTorrentState } from './utils'; +import { TORRENT_RAIL_SCROLL_PX } from './constants'; +import { groupByShow } from './components/torrent/torrentGrouping'; +import { DownloadCard, GroupedDownloadCard } from './components/torrent/TorrentDownloadCards'; -const STATE_COLOR = { downloading: '#30d158', seeding: '#ff9f0a', paused: '#636366', completed: '#0a84ff', error: '#ff453a' }; -const STATE_LABEL = { downloading: 'Downloading', seeding: 'Seeding', paused: 'Paused', completed: 'Completed', error: 'Error' }; - -function formatDate(ts) { - if (!ts) return null; - const d = new Date(ts); - return isNaN(d.getTime()) ? null : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); -} - -function formatRuntime(mins) { - if (!mins) return null; - const h = Math.floor(mins / 60); - const m = mins % 60; - return h > 0 ? `${h}h ${m}m` : `${m}m`; -} - -// ── Poster ────────────────────────────────────────────────────────────────── - -function PosterPlaceholder({ category, title }) { - let icon = 'movie'; - if (category?.includes('sonarr')) icon = 'tv'; - else if (category?.includes('lidarr')) icon = 'album'; - const displayText = (title || '').toUpperCase().slice(0, 40); - return ( - <div className="absolute inset-0 flex items-end" style={{ background: gradientFor(title) }}> - <div style={{ position: 'absolute', inset: 0, background: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.04) 2px, rgba(0,0,0,0.04) 4px)', pointerEvents: 'none' }} /> - <span className="material-symbols-rounded absolute" style={{ top: 12, right: 12, fontSize: 22, fontVariationSettings: "'FILL' 1", color: 'rgba(255,255,255,0.35)' }}> - {icon} - </span> - {displayText && ( - <div style={{ - position: 'relative', padding: '0 14px 18px', zIndex: 2, - fontSize: 15, fontWeight: 800, lineHeight: 1.15, letterSpacing: '-0.01em', - color: 'rgba(255,255,255,0.88)', textShadow: '0 2px 14px rgba(0,0,0,0.8)', - wordBreak: 'break-word', - }}> - {displayText} - </div> - )} - </div> - ); -} - -const CardPoster = memo(function CardPoster({ url, category, title }) { - return ( - <PosterImage - url={url} - title={title} - icon={category?.includes('sonarr') ? 'tv' : category?.includes('lidarr') ? 'album' : 'movie'} - loading="eager" - className="absolute inset-0" - fallback={<PosterPlaceholder category={category} title={title} />} - /> - ); -}); - -// ── Sort History ──────────────────────────────────────────────────────────── - -function SortLine({ sortData }) { - if (!sortData || Array.isArray(sortData)) return null; - const { status, dest } = sortData; - if (status === 'unknown') return null; - - const config = { - sorted: { icon: 'check_circle', label: 'SORTED', fg: '#30d158', bg: 'rgba(48,209,88,0.15)' }, - error: { icon: 'error', label: 'SORT ERROR', fg: '#ff453a', bg: 'rgba(255,69,58,0.15)' }, - holding:{ icon: 'hourglass_top', label: 'HOLDING', fg: '#ff9f0a', bg: 'rgba(255,159,10,0.15)' }, - }; - const c = config[status] || config.holding; - - return ( - <div className="mt-1.5"> - <div className="flex items-center gap-1.5"> - <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded" style={{ background: c.bg }}> - <span className="material-symbols-rounded" style={{ fontSize: 11, fontVariationSettings: "'FILL' 1", color: c.fg }}>{c.icon}</span> - <span className="text-[9px] font-bold tracking-wider" style={{ color: c.fg }}>{c.label}</span> - </span> - </div> - {dest && ( - <p className="text-[9px] font-mono text-text-muted truncate mt-0.5" title={dest}>{dest}</p> - )} - </div> - ); -} - -// ── Hover Controls ────────────────────────────────────────────────────────── - -function HoverControls({ torrent, state, onAction }) { - const [deleteConfirm, setDeleteConfirm] = useState(false); - const [loading, setLoading] = useState(null); - const [actionError, setActionError] = useState(null); - const errorTimerRef = useRef(null); - const confirmTimerRef = useRef(null); - - const startConfirmTimer = () => { - clearTimeout(confirmTimerRef.current); - // Delete confirmation auto-expires so the card does not stay armed on hover. - confirmTimerRef.current = setTimeout(() => setDeleteConfirm(false), 2000); - }; - - useEffect(() => () => clearTimeout(confirmTimerRef.current), []); - - const doAction = async (action) => { - setLoading(action); - setActionError(null); - clearTimeout(errorTimerRef.current); - try { - const method = action === 'delete' ? 'DELETE' : 'POST'; - const url = action === 'delete' - ? `/api/qbittorrent/torrents/${torrent.hash}?deleteFiles=false` - : `/api/qbittorrent/torrents/${torrent.hash}/${action}`; - const res = await fetch(url, { method }); - if (res.ok) { - if (onAction) onAction(); - } else { - setActionError('Action failed'); - errorTimerRef.current = setTimeout(() => setActionError(null), 3000); - } - } catch (_) { - setActionError('Network error'); - errorTimerRef.current = setTimeout(() => setActionError(null), 3000); - } - setLoading(null); - }; - - const handleDelete = () => { - if (!deleteConfirm) { - setDeleteConfirm(true); - startConfirmTimer(); - } else { - clearTimeout(confirmTimerRef.current); - setDeleteConfirm(false); - doAction('delete'); - } - }; - - const btnBase = { - display: 'flex', alignItems: 'center', justifyContent: 'center', - width: 34, height: 34, borderRadius: 8, - border: 'none', cursor: 'pointer', - backdropFilter: 'blur(8px)', - transition: 'background 0.15s, transform 0.1s', - fontSize: 18, - }; - - const iconBtn = (icon, clickHandler, bg, title, isLoading) => ( - <button - title={title} - onClick={(e) => { e.stopPropagation(); clickHandler(); }} - style={{ - ...btnBase, - background: isLoading ? 'rgba(255,255,255,0.08)' : bg, - opacity: isLoading ? 0.5 : 1, - }} - > - <span - className="material-symbols-rounded" - style={{ fontSize: 18, fontVariationSettings: "'FILL' 1", color: '#fff' }} - > - {isLoading ? 'hourglass_empty' : icon} - </span> - </button> - ); - - return ( - <div - style={{ - position: 'absolute', inset: 0, - background: 'linear-gradient(to bottom, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.0) 45%)', - display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', - padding: '10px 10px 0', - zIndex: 10, - }} - onClick={(e) => e.stopPropagation()} - > - {/* Left: action buttons */} - <div style={{ display: 'flex', gap: 6 }}> - {state === 'downloading' && iconBtn('pause', () => doAction('pause'), 'rgba(255,159,10,0.75)', 'Pause', loading === 'pause')} - {state === 'paused' && iconBtn('play_arrow', () => doAction('resume'), 'rgba(48,209,88,0.75)', 'Resume', loading === 'resume')} - {(state === 'seeding' || state === 'completed') && ( - <button - title={deleteConfirm ? 'Click again to confirm' : 'Delete torrent'} - onClick={(e) => { e.stopPropagation(); handleDelete(); }} - style={{ - ...btnBase, - background: deleteConfirm ? 'rgba(255,69,58,0.9)' : 'rgba(255,69,58,0.65)', - width: deleteConfirm ? 'auto' : 34, - padding: deleteConfirm ? '0 10px' : 0, - gap: 4, - whiteSpace: 'nowrap', - opacity: loading === 'delete' ? 0.5 : 1, - }} - > - <span className="material-symbols-rounded" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1", color: '#fff' }}> - {loading === 'delete' ? 'hourglass_empty' : 'delete'} - </span> - {deleteConfirm && <span style={{ fontSize: 11, fontWeight: 700, color: '#fff', letterSpacing: '0.02em' }}>Confirm?</span>} - </button> - )} - </div> - - {/* Right: open in qBittorrent */} - <a - href={getServiceUrl('qbittorrent')} - target="_blank" - rel="noopener noreferrer" - title="Open in qBittorrent" - onClick={(e) => e.stopPropagation()} - style={{ - ...btnBase, - background: 'rgba(255,255,255,0.12)', - textDecoration: 'none', - }} - > - <span className="material-symbols-rounded" style={{ fontSize: 18, fontVariationSettings: "'FILL' 0", color: 'rgba(255,255,255,0.85)' }}> - open_in_new - </span> - </a> - {/* Error toast */} - {actionError && ( - <div style={{ - position: 'absolute', bottom: 8, left: '50%', transform: 'translateX(-50%)', - background: 'rgba(255,69,58,0.9)', color: '#fff', - fontSize: 10, fontWeight: 600, padding: '3px 8px', borderRadius: 6, - whiteSpace: 'nowrap', pointerEvents: 'none', - }}>{actionError}</div> - )} - </div> - ); -} - -// ── Download Card ─────────────────────────────────────────────────────────── - -const DownloadCard = memo(function DownloadCard({ torrent, info, sortData, onAction }) { - const [hovered, setHovered] = useState(false); - const state = getTorrentState(torrent); - const color = STATE_COLOR[state]; - const rating = extractRating(info?.ratings); - const runtime = formatRuntime(info?.runtime); - const isActive = state === 'downloading'; - const displayProgress = state === 'completed' ? 100 : torrent.progress; - - let statusText = STATE_LABEL[state]; - if (state === 'downloading' && torrent.downloadSpeed > 0) { - statusText = formatSpeed(torrent.downloadSpeed); - } else if (state === 'seeding' && torrent.uploadSpeed > 0) { - statusText = formatSpeed(torrent.uploadSpeed); - } - - const displayTitle = info?.title || torrent.name; - const hasInfo = info?.title; - - return ( - <div - className="card carousel-item flex-none rounded-xl overflow-hidden bg-bg-card border border-border-subtle cursor-default" - style={{ width: 260 }} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - - {/* Poster area */} - <div className="relative" style={{ height: 360 }}> - <CardPoster url={info?.posterUrl || torrent.posterUrl} category={torrent.category} title={displayTitle} /> - - {/* Hover controls overlay */} - {hovered && ( - <HoverControls torrent={torrent} state={state} onAction={onAction} /> - )} - - {/* Gradient overlay at bottom of poster */} - <div className="poster-overlay absolute inset-x-0 bottom-0" style={{ height: '60%' }} /> - - {/* Progress bar overlay */} - <div className="absolute inset-x-0 bottom-0 px-3 pb-3"> - {/* Status badge */} - <div className="flex items-center justify-between mb-2"> - <div className="flex items-center gap-1.5"> - <span className="w-2 h-2 rounded-full flex-none" style={{ background: color }} /> - <span className="text-[11px] font-medium text-white/80 tabular-nums">{statusText}</span> - </div> - <span className="text-[10px] font-mono text-white/50 tabular-nums" style={{ minWidth: 56, textAlign: 'right', display: 'inline-block' }}> - {state === 'downloading' && torrent.eta > 0 && torrent.eta < 8640000 ? formatETA(torrent.eta) : '\u00a0'} - </span> - </div> - - {/* Progress bar */} - <div className="h-1 w-full rounded-full bg-white/10 overflow-hidden"> - <div - className="h-full rounded-full" - style={{ - width: `${Math.min(displayProgress, 100)}%`, - background: color, - transition: 'width 800ms cubic-bezier(0.22, 0.61, 0.36, 1)', - }} - /> - </div> - <div className="flex justify-between mt-1"> - <span className="text-[11px] font-mono font-medium text-white tabular-nums">{displayProgress}%</span> - <span className="text-[10px] font-mono text-white/40 tabular-nums">{formatBytes(torrent.size)}</span> - </div> - </div> - - {/* Status badge top-right */} - <div className="absolute top-2.5 right-2.5 px-2 py-0.5 rounded bg-black/50 backdrop-blur-sm"> - <span className="text-[9px] font-semibold uppercase tracking-wider" style={{ color: color }}>{STATE_LABEL[state]}</span> - </div> - - {/* Ratio badge top-left for seeding */} - {state === 'seeding' && torrent.ratio != null && ( - <div className="absolute top-2.5 left-2.5 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm"> - <span className="text-[10px] font-mono text-accent-orange">{torrent.ratio.toFixed(1)}x</span> - </div> - )} - </div> - - {/* Info area below poster */} - <div className="px-3 pt-2.5 pb-3"> - {/* Title */} - <h3 className="text-[13px] font-semibold text-text-primary line-clamp-2 leading-snug"> - {displayTitle}{info?.year && hasInfo ? ` (${info.year})` : ''} - </h3> - - {/* Episode info */} - {info?.episodeNumber && ( - <p className="text-[11px] text-accent-blue font-medium mt-0.5"> - {info.episodeNumber}{info.episodeTitle ? ` · ${info.episodeTitle}` : ''} - </p> - )} - - {/* Metadata line */} - {hasInfo && ( - <div className="flex items-center gap-1.5 mt-1 text-[10px] text-text-muted flex-wrap"> - {rating && ( - <span className="flex items-center gap-0.5 text-accent-orange"> - <span className="material-symbols-rounded" style={{ fontSize: 11, fontVariationSettings: "'FILL' 1" }}>star</span> - {rating} - </span> - )} - {runtime && <span>{runtime}</span>} - {info.network && <span>{info.network}</span>} - {info.quality && <span>{info.quality}</span>} - </div> - )} - - {/* Genres */} - {info?.genres?.length > 0 && ( - <div className="flex flex-wrap gap-1 mt-1.5"> - {info.genres.slice(0, 3).map(g => <span key={g} className="genre-pill">{g}</span>)} - </div> - )} - - {/* Overview */} - {info?.overview && ( - <p className="text-[10px] text-text-muted mt-1.5 line-clamp-2 leading-relaxed">{info.overview}</p> - )} - - {/* Sort destination */} - <SortLine sortData={sortData} /> - - {/* Added date */} - {torrent.addedOn && ( - <div className="text-[9px] text-text-muted mt-1.5 font-mono"> - Added {formatDate(torrent.addedOn)} - {torrent.completedOn && ` · Done ${formatDate(torrent.completedOn)}`} - </div> - )} - </div> - </div> - ); -}, downloadCardEqual); - -// Torrent polling recreates objects often, so memoization keys off painted fields only. -function downloadCardEqual(prev, next) { - if (prev.onAction !== next.onAction) return false; - if (prev.sortData !== next.sortData) return false; - if (prev.info !== next.info) return false; - const a = prev.torrent, b = next.torrent; - if (a === b) return true; - return a.hash === b.hash - && a.progress === b.progress - && a.downloadSpeed === b.downloadSpeed - && a.uploadSpeed === b.uploadSpeed - && a.eta === b.eta - && a.state === b.state - && a.size === b.size - && a.ratio === b.ratio - && a.completedOn === b.completedOn; -} - -// ── Carousel Row ──────────────────────────────────────────────────────────── - -function CarouselRow({ title, icon, count, color, children }) { +function CarouselRow({ title, count, color, children }) { const scrollRef = useRef(null); - const scroll = (dir) => { + const scroll = dir => { if (!scrollRef.current) return; - scrollRef.current.scrollBy({ left: dir * 560, behavior: 'smooth' }); + scrollRef.current.scrollBy({ left: dir * TORRENT_RAIL_SCROLL_PX, behavior: 'smooth' }); }; if (count === 0) return null; return ( <div className="mb-8"> - {/* Section header */} <div className="flex items-center gap-2.5 mb-3 px-8"> <span className="w-2.5 h-2.5 rounded-full" style={{ background: color }} /> <h2 className="text-[16px] font-semibold text-text-primary">{title}</h2> <span className="text-[13px] text-text-muted font-mono">{count}</span> <div className="flex-1" /> - <button onClick={() => scroll(-1)} className="p-1 rounded-md hover:bg-bg-hover active:bg-bg-hover text-text-muted hover:text-text-primary active:text-text-primary transition-colors"> - <span className="material-symbols-rounded" style={{ fontSize: 20 }}>chevron_left</span> + <button + onClick={() => scroll(-1)} + className="p-1 rounded-md hover:bg-bg-hover active:bg-bg-hover text-text-muted hover:text-text-primary active:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + chevron_left + </span> </button> - <button onClick={() => scroll(1)} className="p-1 rounded-md hover:bg-bg-hover active:bg-bg-hover text-text-muted hover:text-text-primary active:text-text-primary transition-colors"> - <span className="material-symbols-rounded" style={{ fontSize: 20 }}>chevron_right</span> + <button + onClick={() => scroll(1)} + className="p-1 rounded-md hover:bg-bg-hover active:bg-bg-hover text-text-muted hover:text-text-primary active:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + chevron_right + </span> </button> </div> - {/* Carousel */} <div className="carousel-container"> <div ref={scrollRef} className="carousel flex gap-4 overflow-x-auto px-8 pb-2"> {children} @@ -430,298 +48,10 @@ function CarouselRow({ title, icon, count, color, children }) { ); } -// ── Skeleton Card ─────────────────────────────────────────────────────────── - -function SkeletonCard() { - return ( - <div className="carousel-item flex-none rounded-xl overflow-hidden bg-bg-card border border-border-subtle" style={{ width: 260 }}> - <div className="shimmer" style={{ height: 360 }} /> - <div className="px-3 pt-3 pb-3 space-y-2"> - <div className="shimmer rounded h-4 w-3/4" /> - <div className="shimmer rounded h-3 w-1/2" /> - <div className="shimmer rounded h-3 w-2/3" /> - </div> - </div> - ); -} - - -// ── Group helpers ──────────────────────────────────────────────────────────── - -function extractSeriesName(name) { - const base = (name || '').split('/').pop().replace(/\.\w+$/, ''); - // SxxExx — individual episode - const m = base.match(/^(.+?)[.\s_-]+[Ss]\d{1,2}[Ee]\d{1,2}/); - if (m) return m[1].replace(/[._]/g, ' ').trim(); - // Sxx — season pack (not followed by another digit) - const m2 = base.match(/^(.+?)[.\s_-]+[Ss]\d{1,2}(?:\s|$|\.|_|-|[A-Z])/); - if (m2) return m2[1].replace(/[._]/g, ' ').trim(); - // "Season X" spelled out - const m3 = base.match(/^(.+?)[.\s_-]+[Ss]eason[.\s_-]*\d/i); - if (m3) return m3[1].replace(/[._]/g, ' ').trim(); - return base.replace(/[._]/g, ' ').trim(); -} - -function extractSeasonLabel(t, tInfo) { - if (tInfo?.episodeNumber) { - const m = tInfo.episodeNumber.match(/[Ss](\d{1,2})/); - return m ? 'S' + parseInt(m[1]) : tInfo.episodeNumber; - } - const name = t.name || ''; - // Individual episode - const ep = name.match(/[Ss](\d{1,2})[Ee](\d{1,2})/); - if (ep) return 'S' + parseInt(ep[1]) + 'E' + ep[2].padStart(2, '0'); - // Season pack - const sp = name.match(/[Ss](\d{1,2})(?:\s|$|\.|_|-|[A-Z])/); - if (sp) return 'S' + parseInt(sp[1]); - return '?'; -} - -function extractSeasonNum(t, tInfo) { - const label = extractSeasonLabel(t, tInfo); - const m = label.match(/S(\d+)/); - return m ? parseInt(m[1]) : 999; -} - -function groupByShow(torrents, getInfo) { - const map = new Map(); - torrents.forEach(t => { - const info = getInfo(t.hash); - const key = info?.title ?? extractSeriesName(t.name); - if (!map.has(key)) map.set(key, { key, torrents: [], info: null, posterUrl: null }); - const g = map.get(key); - g.torrents.push(t); - if (!g.info && info) g.info = info; - if (!g.posterUrl) g.posterUrl = info?.posterUrl || t.posterUrl || null; - }); - return Array.from(map.values()); -} - -// ── Grouped Download Card ──────────────────────────────────────────────────── - -const GroupedDownloadCard = memo(function GroupedDownloadCard({ group, getInfo, onAction }) { - const { torrents, info, key, posterUrl } = group; - const [hovered, setHovered] = useState(false); - const [ctrlBusy, setCtrlBusy] = useState(false); - - const anyDownloading = torrents.some(t => getTorrentState(t) === 'downloading'); - - const handleBulk = async (action) => { - setCtrlBusy(true); - await Promise.all(torrents.map(t => - fetch(`/api/qbittorrent/torrents/${t.hash}/${action}`, { method: 'POST' }).catch(() => {}) - )); - setCtrlBusy(false); - if (onAction) onAction(); - }; - - const totalSize = torrents.reduce((s, t) => s + (t.size || 0), 0); - const downloadedSize = torrents.reduce((s, t) => s + ((t.size || 0) * (t.progress || 0) / 100), 0); - const overallProgress = totalSize > 0 ? downloadedSize / totalSize * 100 : 0; - const totalSpeed = torrents.reduce((s, t) => s + (t.downloadSpeed || 0), 0); - const etaCandidates = torrents.filter(t => t.eta > 0 && t.eta < 8640000).map(t => t.eta); - const maxETA = etaCandidates.length > 0 ? Math.max(...etaCandidates) : null; - - const sortedItems = [...torrents].sort((a, b) => - extractSeasonNum(a, getInfo(a.hash)) - extractSeasonNum(b, getInfo(b.hash)) - ); - - const displayTitle = info?.title || key; - - return ( - <div - className="card carousel-item flex-none rounded-xl overflow-hidden bg-bg-card border border-border-subtle cursor-default" - style={{ width: 360 }} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {/* ── Poster ── */} - <div className="relative" style={{ height: 260 }}> - <CardPoster url={info?.posterUrl || posterUrl} category={torrents[0]?.category} title={displayTitle} /> - - {/* Pause/Resume all overlay on hover */} - {hovered && ( - <div style={{ position: 'absolute', top: 10, left: 10, zIndex: 12 }} onClick={e => e.stopPropagation()}> - <button - title={anyDownloading ? 'Pause all' : 'Resume all'} - disabled={ctrlBusy} - onClick={() => handleBulk(anyDownloading ? 'pause' : 'resume')} - style={{ width: 34, height: 34, borderRadius: 8, border: 'none', cursor: ctrlBusy ? 'default' : 'pointer', - display: 'flex', alignItems: 'center', justifyContent: 'center', - background: anyDownloading ? 'rgba(255,159,10,0.8)' : 'rgba(48,209,88,0.8)', - backdropFilter: 'blur(8px)', opacity: ctrlBusy ? 0.5 : 1 }} - > - <span className="material-symbols-rounded" style={{ fontSize: 18, fontVariationSettings: "'FILL' 1", color: '#fff' }}> - {ctrlBusy ? 'hourglass_empty' : anyDownloading ? 'pause' : 'play_arrow'} - </span> - </button> - </div> - )} - - {/* gradient — transparent top, heavy at bottom */} - <div - className="absolute inset-0" - style={{ background: 'linear-gradient(to bottom, rgba(0,0,0,0) 20%, rgba(0,0,0,0.5) 55%, rgba(0,0,0,0.93) 100%)' }} - /> - - {/* top-right: open in qBittorrent */} - <a - href={getServiceUrl('qbittorrent')} - target="_blank" rel="noopener noreferrer" - title="Open in qBittorrent" - className="absolute top-3 right-3 flex items-center justify-center rounded-lg" - style={{ width: 32, height: 32, background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(10px)', textDecoration: 'none' }} - onClick={e => e.stopPropagation()} - > - <span - className="material-symbols-rounded" - style={{ fontSize: 16, fontVariationSettings: "'FILL' 0", color: 'rgba(255,255,255,0.7)' }} - > - open_in_new - </span> - </a> - - {/* top-left: DOWNLOADING pill */} - <div - className="absolute top-3 left-3 px-2.5 py-1 rounded-lg" - style={{ background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(10px)' }} - > - <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: '#30d158' }}> - Downloading - </span> - </div> - - {/* bottom: aggregate progress */} - <div className="absolute inset-x-0 bottom-0 px-4 pb-4"> - <div className="flex items-end justify-between mb-2.5"> - {/* big % number */} - <span - className="font-black text-white tabular-nums" - style={{ fontSize: 38, lineHeight: 1, letterSpacing: '-0.03em' }} - > - {overallProgress.toFixed(1)} - <span style={{ fontSize: 20, fontWeight: 700, opacity: 0.65 }}>%</span> - </span> - {/* speed + size */} - <div className="text-right pb-0.5"> - {totalSpeed > 0 && ( - <div className="text-[14px] font-semibold tabular-nums" style={{ color: '#30d158' }}> - ↓ {formatSpeed(totalSpeed)} - </div> - )} - <div className="text-[10px] font-mono text-white/50 tabular-nums mt-0.5"> - {formatBytes(downloadedSize)} / {formatBytes(totalSize)} - </div> - {maxETA && ( - <div className="text-[10px] font-mono text-white/35 mt-0.5">{formatETA(maxETA)} remaining</div> - )} - </div> - </div> - {/* fat progress bar */} - <div className="h-2.5 w-full rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.12)' }}> - <div - className="h-full rounded-full" - style={{ - width: `${Math.min(overallProgress, 100)}%`, - background: 'linear-gradient(90deg, #1db954, #30d158)', - transition: 'width 800ms cubic-bezier(0.22, 0.61, 0.36, 1)', - boxShadow: '0 0 8px rgba(48,209,88,0.4)', - }} - /> - </div> - </div> - </div> - - {/* ── Title block ── */} - <div className="px-4 pt-3 pb-2.5 border-b border-border-subtle"> - <h3 className="text-[15px] font-bold text-text-primary leading-tight"> - {displayTitle}{info?.year ? ` (${info.year})` : ''} - </h3> - <p className="text-[11px] text-text-muted mt-0.5"> - {info?.network ? info.network + ' · ' : ''} - {sortedItems.length} season{sortedItems.length !== 1 ? 's' : ''} downloading - </p> - </div> - - {/* ── Per-season rows ── */} - <div className="px-4 pt-3 pb-4 space-y-3"> - {sortedItems.map(t => { - const tInfo = getInfo(t.hash); - const state = getTorrentState(t); - const color = STATE_COLOR[state]; - const label = extractSeasonLabel(t, tInfo); - const prog = t.progress || 0; - const speed = t.downloadSpeed || 0; - const eta = t.eta > 0 && t.eta < 8640000 ? t.eta : null; - - return ( - <div key={t.hash}> - {/* label + bar + % */} - <div className="flex items-center gap-2.5"> - <span - className="text-[10px] font-bold font-mono rounded-md flex-none text-center px-2 py-0.5" - style={{ background: 'rgba(48,209,88,0.15)', color: '#30d158', minWidth: 36 }} - > - {label} - </span> - <div className="flex-1 h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.1)' }}> - <div - className="h-full rounded-full" - style={{ width: `${Math.min(prog, 100)}%`, background: color, transition: 'width 800ms cubic-bezier(0.22, 0.61, 0.36, 1)' }} - /> - </div> - <span - className="text-[11px] font-mono font-semibold tabular-nums flex-none text-right" - style={{ color, minWidth: 42 }} - > - {prog.toFixed(1)}% - </span> - </div> - {/* speed + size + eta */} - <div className="flex items-center gap-3 mt-1" style={{ paddingLeft: 48 }}> - {speed > 0 && ( - <span className="text-[10px] font-mono font-medium tabular-nums" style={{ color: '#30d158' }}> - ↓ {formatSpeed(speed)} - </span> - )} - <span className="text-[10px] font-mono text-text-muted">{formatBytes(t.size || 0)}</span> - {eta && <span className="text-[10px] font-mono text-text-muted">{formatETA(eta)}</span>} - </div> - </div> - ); - })} - </div> - </div> - ); -}, groupedDownloadCardEqual); - -function groupedDownloadCardEqual(prev, next) { - if (prev.onAction !== next.onAction) return false; - if (prev.getInfo !== next.getInfo) return false; - if (prev.group.key !== next.group.key) return false; - const a = prev.group.torrents, b = next.group.torrents; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const x = a[i], y = b[i]; - if (x.hash !== y.hash - || x.progress !== y.progress - || x.downloadSpeed !== y.downloadSpeed - || x.eta !== y.eta - || x.size !== y.size - || x.state !== y.state) return false; - } - return true; -} - - -// ── Main Export ────────────────────────────────────────────────────────────── - export default memo(function TorrentTable({ torrents, mediaInfo = {}, onRefresh }) { const [sortHistories, setSortHistories] = useState({}); const prevStatesRef = useRef({}); - // Re-fetch sort history when a torrent transitions to seeding/completed — - // clears stale HOLDING status from when it was still downloading. useEffect(() => { const toRefetch = []; torrents.forEach(t => { @@ -741,13 +71,11 @@ export default memo(function TorrentTable({ torrents, mediaInfo = {}, onRefresh } }, [torrents]); - // Fetch sort history for all torrents that don't have it yet useEffect(() => { const hashes = torrents.map(t => t.hash); - const missing = hashes.filter(h => !(h in sortHistories)); + const missing = hashes.filter(h => !sortHistories[h]); if (missing.length === 0) return; - // Fetch in batches of 5 to avoid flooding let i = 0; let cancelled = false; let timer = null; @@ -759,64 +87,97 @@ export default memo(function TorrentTable({ torrents, mediaInfo = {}, onRefresh const t = torrents.find(t => t.hash === hash); if (!t) return; fetch(`/api/sort-history?search=${encodeURIComponent(t.name)}`, { cache: 'no-store' }) - .then(r => r.ok ? r.json() : null) - .then(data => { if (!cancelled) setSortHistories(prev => ({ ...prev, [hash]: data })); }) - .catch(() => { if (!cancelled) setSortHistories(prev => ({ ...prev, [hash]: [] })); }); + .then(r => (r.ok ? r.json() : null)) + .then(data => { + if (!cancelled) setSortHistories(prev => ({ ...prev, [hash]: data })); + }) + .catch(() => { + if (!cancelled) setSortHistories(prev => ({ ...prev, [hash]: null })); + }); }); timer = setTimeout(fetchNext, 500); }; fetchNext(); - return () => { cancelled = true; clearTimeout(timer); }; + return () => { + cancelled = true; + clearTimeout(timer); + }; }, [torrents]); - const getInfo = (hash) => mediaInfo[hash] || mediaInfo[hash?.toLowerCase()] || null; + const getInfo = hash => mediaInfo[hash] || mediaInfo[hash?.toLowerCase()] || null; const downloading = torrents.filter(t => getTorrentState(t) === 'downloading'); const library = torrents.filter(t => getTorrentState(t) === 'seeding' || getTorrentState(t) === 'completed'); const paused = torrents.filter(t => getTorrentState(t) === 'paused'); const errored = torrents.filter(t => getTorrentState(t) === 'error'); - // Sort downloading by progress desc, library by ratio desc downloading.sort((a, b) => b.progress - a.progress); library.sort((a, b) => (b.ratio || 0) - (a.ratio || 0)); const downloadingGroups = groupByShow(downloading, getInfo); - const noTorrents = torrents.length === 0; return ( <div className="flex-1 overflow-y-auto scroll-area py-6"> {noTorrents ? ( <div className="flex flex-col items-center justify-center h-full text-text-muted"> - <span className="material-symbols-rounded mb-3" style={{ fontSize: 48, fontVariationSettings: "'FILL' 1" }}>cloud_done</span> + <span className="material-symbols-rounded mb-3" style={{ fontSize: 48, fontVariationSettings: "'FILL' 1" }}> + cloud_done + </span> <p className="text-[14px]">No active transfers</p> </div> ) : ( <> - <CarouselRow title="Downloading" icon="download" count={downloadingGroups.length} color="#30d158"> + <CarouselRow title="Downloading" count={downloadingGroups.length} color="#30d158"> {downloadingGroups.map(g => - g.torrents.length === 1 - ? <DownloadCard key={g.key} torrent={g.torrents[0]} info={g.info} sortData={sortHistories[g.torrents[0].hash]} onAction={onRefresh} /> - : <GroupedDownloadCard key={g.key} group={g} getInfo={getInfo} onAction={onRefresh} /> + g.torrents.length === 1 ? ( + <DownloadCard + key={g.key} + torrent={g.torrents[0]} + info={g.info} + sortData={sortHistories[g.torrents[0].hash]} + onAction={onRefresh} + /> + ) : ( + <GroupedDownloadCard key={g.key} group={g} getInfo={getInfo} onAction={onRefresh} /> + ), )} </CarouselRow> - <CarouselRow title="Library" icon="video_library" count={library.length} color="#0a84ff"> + <CarouselRow title="Library" count={library.length} color="#0a84ff"> {library.map(t => ( - <DownloadCard key={t.hash} torrent={t} info={getInfo(t.hash)} sortData={sortHistories[t.hash]} onAction={onRefresh} /> + <DownloadCard + key={t.hash} + torrent={t} + info={getInfo(t.hash)} + sortData={sortHistories[t.hash]} + onAction={onRefresh} + /> ))} </CarouselRow> - <CarouselRow title="Paused" icon="pause" count={paused.length} color="#636366"> + <CarouselRow title="Paused" count={paused.length} color="#636366"> {paused.map(t => ( - <DownloadCard key={t.hash} torrent={t} info={getInfo(t.hash)} sortData={sortHistories[t.hash]} onAction={onRefresh} /> + <DownloadCard + key={t.hash} + torrent={t} + info={getInfo(t.hash)} + sortData={sortHistories[t.hash]} + onAction={onRefresh} + /> ))} </CarouselRow> {errored.length > 0 && ( - <CarouselRow title="Error" icon="error" count={errored.length} color="#ef4444"> + <CarouselRow title="Error" count={errored.length} color="#ef4444"> {errored.map(t => ( - <DownloadCard key={t.hash} torrent={t} info={getInfo(t.hash)} sortData={sortHistories[t.hash]} onAction={onRefresh} /> + <DownloadCard + key={t.hash} + torrent={t} + info={getInfo(t.hash)} + sortData={sortHistories[t.hash]} + onAction={onRefresh} + /> ))} </CarouselRow> )} diff --git a/frontend/src/api.js b/frontend/src/api.js index bccb720..7d02492 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -4,8 +4,6 @@ const inflight = new Map(); // Per-URL failure backoff (jittered, capped at 5s). const failureState = new Map(); // url -> { until, count } const BACKOFF_CAP_MS = 5000; -const API_META_KEY = '__apiMeta'; - function nextBackoff(count) { const base = Math.min(BACKOFF_CAP_MS, 250 * Math.pow(2, count)); return base / 2 + Math.random() * (base / 2); @@ -16,206 +14,6 @@ function isAbort(err) { return err && (err.name === 'AbortError' || err.code === 20); } -function createClientRequestId() { - return `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -function firstDefined(...values) { - for (const value of values) { - if (value !== undefined && value !== null && value !== '') return value; - } - return undefined; -} - -function toRetryAfterMs(value) { - if (value === undefined || value === null || value === '') return null; - const numeric = Number(value); - if (!Number.isFinite(numeric)) return null; - return numeric >= 1000 ? Math.round(numeric) : Math.round(numeric * 1000); -} - -function normalizeMessage(payload, status) { - if (typeof payload === 'string') return payload.trim() || `HTTP ${status}`; - if (payload && typeof payload === 'object') { - if (payload.error && typeof payload.error === 'object') { - return String( - firstDefined( - payload.error.message, - payload.error.detail, - payload.error.details, - payload.error.title, - payload.error.reason, - payload.error.code, - ) || `HTTP ${status}`, - ); - } - return String( - firstDefined( - typeof payload.error === 'string' ? payload.error : undefined, - payload.message, - payload.detail, - payload.details, - payload.title, - payload.reason, - ) || `HTTP ${status}`, - ); - } - return `HTTP ${status}`; -} - -function collectWarnings(payload) { - const raw = firstDefined( - payload?.warnings, - payload?.warning, - payload?.meta?.warnings, - payload?.error?.warnings, - ); - if (!raw) return []; - const items = Array.isArray(raw) ? raw : [raw]; - return items - .map((item) => { - if (!item) return null; - if (typeof item === 'string') return item.trim(); - if (typeof item === 'object') { - const message = firstDefined(item.message, item.detail, item.error, item.warning); - const service = firstDefined(item.service, item.name); - if (message && service) return `${service}: ${message}`; - return message ? String(message).trim() : null; - } - return String(item).trim(); - }) - .filter(Boolean); -} - -function parseTextBody(text) { - if (!text) return ''; - try { - return JSON.parse(text); - } catch { - return text; - } -} - -function attachApiMeta(payload, meta) { - if (!payload || (typeof payload !== 'object' && !Array.isArray(payload))) return payload; - try { - Object.defineProperty(payload, API_META_KEY, { - value: meta, - enumerable: false, - configurable: true, - }); - } catch {} - return payload; -} - -function buildApiMeta({ url, method, status, durationMs, attempt, clientRequestId, payload, response }) { - const payloadMeta = payload && typeof payload === 'object' - ? (payload.meta && typeof payload.meta === 'object' ? payload.meta : {}) - : {}; - const payloadRequest = payload && typeof payload === 'object' - ? (payload.request && typeof payload.request === 'object' ? payload.request : {}) - : {}; - return { - endpoint: firstDefined( - payload?.endpoint, - payloadMeta.endpoint, - payloadRequest.endpoint, - url, - ), - method, - status, - durationMs: firstDefined( - payload?.durationMs, - payloadMeta.durationMs, - payloadRequest.durationMs, - durationMs, - ), - attempt: firstDefined( - payload?.attempt, - payloadMeta.attempt, - payloadRequest.attempt, - attempt, - ), - retryAfterMs: firstDefined( - payload?.retryAfterMs, - payloadMeta.retryAfterMs, - payloadRequest.retryAfterMs, - toRetryAfterMs(response?.headers?.get('retry-after')), - null, - ), - clientRequestId, - requestId: firstDefined( - payload?.requestId, - payload?.request_id, - payloadMeta.requestId, - payloadMeta.request_id, - payloadRequest.id, - payloadRequest.requestId, - payloadRequest.request_id, - response?.headers?.get('x-request-id'), - response?.headers?.get('x-correlation-id'), - ), - correlationId: firstDefined( - payload?.correlationId, - payload?.correlation_id, - payloadMeta.correlationId, - payloadMeta.correlation_id, - payloadRequest.correlationId, - payloadRequest.correlation_id, - response?.headers?.get('x-correlation-id'), - ), - warnings: collectWarnings(payload), - }; -} - -function buildApiError({ payload, status, url, method, durationMs, attempt, clientRequestId, response }) { - const meta = buildApiMeta({ - url, - method, - status, - durationMs, - attempt, - clientRequestId, - payload, - response, - }); - const message = normalizeMessage(payload, status); - const error = new Error(message); - error.name = 'ApiError'; - error.status = status; - error.endpoint = meta.endpoint; - error.method = method; - error.durationMs = meta.durationMs; - error.attempt = meta.attempt; - error.retryAfterMs = meta.retryAfterMs; - error.clientRequestId = meta.clientRequestId; - error.requestId = meta.requestId; - error.correlationId = meta.correlationId; - error.warnings = meta.warnings; - error.response = payload; - return error; -} - -export function getApiMeta(payload) { - return payload?.[API_META_KEY] || null; -} - -export function getApiErrorDetails(error) { - if (!error) return null; - return { - message: String(error.message || 'Request failed'), - endpoint: error.endpoint || error.url || null, - method: error.method || null, - status: Number.isFinite(error.status) ? error.status : null, - durationMs: Number.isFinite(error.durationMs) ? error.durationMs : null, - attempt: Number.isFinite(error.attempt) ? error.attempt : null, - retryAfterMs: Number.isFinite(error.retryAfterMs) ? error.retryAfterMs : null, - clientRequestId: error.clientRequestId || null, - requestId: error.requestId || error.correlationId || null, - warnings: Array.isArray(error.warnings) ? error.warnings : [], - }; -} - /** * Thin wrapper around fetch() for JSON API calls. * Throws on non-2xx responses with the response body as the error message. @@ -230,14 +28,11 @@ export function getApiErrorDetails(error) { export async function apiFetch(url, options = {}) { const method = (options.method || 'GET').toUpperCase(); const isGet = method === 'GET'; - const clientRequestId = createClientRequestId(); - const queuedFailure = isGet ? failureState.get(url) : null; - const initialAttempt = queuedFailure ? queuedFailure.count + 1 : 1; - // Backoff: wait out the cooldown instead of surfacing a synthetic error to the UI. - const fail = queuedFailure; + // Backoff: if URL is in cooldown, short-circuit. + const fail = failureState.get(url); if (fail && fail.until > Date.now()) { - await new Promise(resolve => setTimeout(resolve, fail.until - Date.now())); + throw new Error(`backoff: ${url}`); } // Dedup GETs by URL (skip when caller passes a signal — they want their own lifecycle). @@ -246,66 +41,22 @@ export async function apiFetch(url, options = {}) { } const exec = (async () => { - const startedAt = performance.now(); try { - const res = await fetch(url, { - cache: 'no-store', - ...options, - headers: { - 'X-Client-Request-Id': clientRequestId, - ...(options.headers || {}), - }, - }); - const durationMs = Math.round(performance.now() - startedAt); - const contentType = res.headers.get('content-type') || ''; - const payload = contentType.includes('application/json') - ? await res.json() - : parseTextBody(await res.text().catch(() => '')); + const res = await fetch(url, { cache: 'no-store', ...options }); if (!res.ok) { - throw buildApiError({ - payload, - status: res.status, - url, - method, - durationMs, - attempt: initialAttempt, - clientRequestId, - response: res, - }); + const body = await res.text().catch(() => ''); + throw new Error(body || `HTTP ${res.status}`); } failureState.delete(url); - return attachApiMeta(payload, buildApiMeta({ - url, - method, - status: res.status, - durationMs, - attempt: initialAttempt, - clientRequestId, - payload, - response: res, - })); + return res.json(); } catch (err) { if (isAbort(err)) { // Don't record abort as a failure; rethrow so caller's try/catch can ignore. throw err; } - if (isGet) { - const prev = failureState.get(url) || { count: 0 }; - const count = prev.count + 1; - failureState.set(url, { count, until: Date.now() + nextBackoff(count) }); - } - if (err && typeof err === 'object' && !err.clientRequestId) { - err.clientRequestId = clientRequestId; - } - if (err && typeof err === 'object' && !err.endpoint) { - err.endpoint = url; - } - if (err && typeof err === 'object' && !err.method) { - err.method = method; - } - if (err && typeof err === 'object' && !Number.isFinite(err.attempt)) { - err.attempt = initialAttempt; - } + const prev = failureState.get(url) || { count: 0 }; + const count = prev.count + 1; + failureState.set(url, { count, until: Date.now() + nextBackoff(count) }); throw err; } finally { if (isGet && !options.signal) inflight.delete(url); @@ -322,14 +73,10 @@ export async function apiFetch(url, options = {}) { * @param {Object} [data] - Request body to JSON-serialize * @returns {Promise<any>} */ -export async function apiPost(url, data, options = {}) { +export async function apiPost(url, data) { return apiFetch(url, { - ...options, method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, + headers: { 'Content-Type': 'application/json' }, body: data !== undefined ? JSON.stringify(data) : undefined, }); } diff --git a/frontend/src/components/app/AppHeader.jsx b/frontend/src/components/app/AppHeader.jsx new file mode 100644 index 0000000..f5a5fcb --- /dev/null +++ b/frontend/src/components/app/AppHeader.jsx @@ -0,0 +1,244 @@ +import { formatSpeed } from '../../utils'; +import { DownArrowIcon, UpArrowIcon, SearchBarIcon } from './AppIcons'; + +export default function AppHeader({ + isMobile, + headerQuery, + setHeaderQuery, + setActiveView, + mobileSearchOpen, + toggleMobileSearch, + totalDl, + totalUl, + allHealthy, + runningCount, + containerCount, + lightMode, + setLightMode, + sidebarOpen, + onSidebarToggle, +}) { + return ( + <header + style={{ + height: 60, + flexShrink: 0, + background: 'var(--bg-header)', + borderBottom: '1px solid var(--border-subtle)', + display: 'flex', + alignItems: 'center', + padding: '0 28px', + gap: 20, + backdropFilter: 'blur(20px)', + zIndex: 50, + }} + > + <span + style={{ + fontSize: 18, + fontWeight: 700, + letterSpacing: '0.22em', + color: 'var(--text-primary)', + textTransform: 'uppercase', + flexShrink: 0, + }} + > + Icarus + </span> + + {!isMobile && ( + <div style={{ flex: 1, maxWidth: 360, marginLeft: 20 }}> + <div style={{ position: 'relative' }}> + <SearchBarIcon /> + <input + aria-label="Search library" + type="text" + placeholder="Search library…" + value={headerQuery} + onChange={e => { + setHeaderQuery(e.target.value); + setActiveView('library'); + }} + onFocus={() => setActiveView('library')} + style={{ + width: '100%', + background: 'var(--surface)', + border: '1px solid var(--border-subtle)', + borderRadius: 10, + padding: '8px 14px 8px 36px', + color: 'var(--text-primary)', + fontSize: 13.5, + outline: 'none', + fontFamily: 'inherit', + transition: 'border-color 0.2s, background 0.2s', + }} + /> + </div> + </div> + )} + + {isMobile && ( + <button + aria-label={mobileSearchOpen ? 'Close search' : 'Open search'} + onClick={toggleMobileSearch} + style={{ + width: 36, + height: 36, + borderRadius: 10, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: mobileSearchOpen ? 'rgba(10,132,255,0.18)' : 'var(--border-subtle)', + border: 'none', + cursor: 'pointer', + color: mobileSearchOpen ? '#0a84ff' : 'var(--text-secondary)', + }} + > + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 16, height: 16 }} + > + <circle cx="11" cy="11" r="7" /> + <line x1="16.5" y1="16.5" x2="22" y2="22" /> + </svg> + </button> + )} + + <div style={{ flex: 1 }} /> + + <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}> + {!isMobile && ( + <span + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + opacity: totalDl > 0 ? 1 : 0.28, + transition: 'opacity 0.4s', + }} + > + <DownArrowIcon /> + <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--text-secondary)' }}> + {totalDl > 0 ? formatSpeed(totalDl) : '—'} + </span> + </span> + )} + {!isMobile && ( + <span + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + color: 'var(--text-muted)', + opacity: totalUl > 0 ? 1 : 0.28, + transition: 'opacity 0.4s', + }} + > + <UpArrowIcon /> + <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--text-secondary)' }}> + {totalUl > 0 ? formatSpeed(totalUl) : '—'} + </span> + </span> + )} + <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}> + <div + style={{ + width: 7, + height: 7, + borderRadius: '50%', + flexShrink: 0, + background: allHealthy ? '#34C759' : '#FF375F', + boxShadow: allHealthy ? '0 0 6px rgba(52,199,89,0.7)' : '0 0 6px rgba(255,55,95,0.7)', + }} + /> + <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--text-secondary)' }}> + {runningCount}/{containerCount} + </span> + </div> + <button + aria-label={lightMode ? 'Switch to dark mode' : 'Switch to light mode'} + onClick={() => setLightMode(m => !m)} + style={{ + width: 32, + height: 32, + borderRadius: 9, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'var(--border-subtle)', + border: 'none', + cursor: 'pointer', + color: 'var(--text-secondary)', + transition: 'background 0.2s, color 0.2s', + }} + > + {lightMode ? ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 15, height: 15 }} + > + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> + </svg> + ) : ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 15, height: 15 }} + > + <circle cx="12" cy="12" r="5" /> + <line x1="12" y1="1" x2="12" y2="3" /> + <line x1="12" y1="21" x2="12" y2="23" /> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> + <line x1="1" y1="12" x2="3" y2="12" /> + <line x1="21" y1="12" x2="23" y2="12" /> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> + </svg> + )} + </button> + <button + aria-label={sidebarOpen ? 'Collapse panel' : 'Open panel'} + aria-pressed={sidebarOpen} + onClick={onSidebarToggle} + style={{ + width: isMobile ? 36 : 32, + height: isMobile ? 36 : 32, + borderRadius: isMobile ? 10 : 9, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: sidebarOpen ? 'rgba(255,55,95,0.18)' : 'var(--border-subtle)', + border: 'none', + cursor: 'pointer', + color: sidebarOpen ? '#FF375F' : 'var(--text-secondary)', + flexShrink: 0, + transition: 'background 160ms ease, color 160ms ease', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: isMobile ? 20 : 18 }}> + {sidebarOpen ? (isMobile ? 'close' : 'right_panel_close') : isMobile ? 'menu_open' : 'right_panel_open'} + </span> + </button> + </div> + </header> + ); +} diff --git a/frontend/src/components/app/AppIcons.jsx b/frontend/src/components/app/AppIcons.jsx new file mode 100644 index 0000000..663ef63 --- /dev/null +++ b/frontend/src/components/app/AppIcons.jsx @@ -0,0 +1,100 @@ +export const LibraryIcon = () => ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 20, height: 20 }} + > + <rect x="3" y="3" width="7" height="7" rx="1.5" /> + <rect x="14" y="3" width="7" height="7" rx="1.5" /> + <rect x="3" y="14" width="7" height="7" rx="1.5" /> + <rect x="14" y="14" width="7" height="7" rx="1.5" /> + </svg> +); + +export const DownloadsIcon = () => ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 20, height: 20 }} + > + <path d="M12 2v13m0 0l-4-4m4 4l4-4" /> + <path d="M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3" /> + </svg> +); + +export const SettingsIcon = () => ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 20, height: 20 }} + > + <circle cx="12" cy="12" r="3" /> + <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /> + </svg> +); + +export const DownArrowIcon = () => ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 13, height: 13 }} + > + <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" /> + <polyline points="17 6 23 6 23 12" /> + </svg> +); + +export const UpArrowIcon = () => ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + style={{ width: 13, height: 13 }} + > + <polyline points="23 18 13.5 8.5 8.5 13.5 1 6" /> + <polyline points="17 18 23 18 23 12" /> + </svg> +); + +export const SearchBarIcon = () => ( + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + style={{ + position: 'absolute', + left: 11, + top: '50%', + transform: 'translateY(-50%)', + color: 'var(--text-muted)', + width: 15, + height: 15, + pointerEvents: 'none', + }} + > + <circle cx="11" cy="11" r="7" /> + <line x1="16.5" y1="16.5" x2="22" y2="22" /> + </svg> +); diff --git a/frontend/src/components/app/AppNavRail.jsx b/frontend/src/components/app/AppNavRail.jsx new file mode 100644 index 0000000..0ec456c --- /dev/null +++ b/frontend/src/components/app/AppNavRail.jsx @@ -0,0 +1,80 @@ +import RailItem from './RailItem'; +import { LibraryIcon, DownloadsIcon, SettingsIcon } from './AppIcons'; + +export default function AppNavRail({ activeView, setActiveView, downloadingCount }) { + return ( + <nav + aria-label="Main navigation" + style={{ + width: 64, + flexShrink: 0, + background: 'var(--bg-nav)', + borderRight: '1px solid var(--border-subtle)', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '20px 0 24px', + zIndex: 100, + }} + > + <div + style={{ + width: 32, + height: 32, + marginBottom: 28, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + <svg viewBox="0 0 28 28" fill="none" style={{ width: 28, height: 28 }}> + <circle cx="14" cy="5" r="3" fill="#FF9F0A" /> + <path d="M13 8 L2 16 L10 13 L13 11Z" fill="rgba(235,235,245,0.88)" /> + <path d="M15 8 L26 16 L18 13 L15 11Z" fill="rgba(235,235,245,0.88)" /> + <path d="M14 11 L13 23 L14 20 L15 23Z" fill="#FF375F" /> + </svg> + </div> + + <div + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 4, + flex: 1, + width: '100%', + padding: '0 8px', + }} + > + <RailItem + icon={<LibraryIcon />} + active={activeView === 'library'} + onClick={() => setActiveView('library')} + title="Library" + /> + <RailItem + icon={<DownloadsIcon />} + active={activeView === 'downloads'} + onClick={() => setActiveView('downloads')} + title={`Downloads${downloadingCount > 0 ? ` (${downloadingCount})` : ''}`} + badge={downloadingCount} + /> + </div> + + <div style={{ flex: 1 }} /> + + <div + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 4, + width: '100%', + padding: '0 8px', + }} + > + <RailItem icon={<SettingsIcon />} active={false} onClick={() => {}} title="Settings" /> + </div> + </nav> + ); +} diff --git a/frontend/src/components/app/ArrQueueCard.jsx b/frontend/src/components/app/ArrQueueCard.jsx new file mode 100644 index 0000000..22019b2 --- /dev/null +++ b/frontend/src/components/app/ArrQueueCard.jsx @@ -0,0 +1,130 @@ +import { formatBytes, posterSrc } from '../../utils'; + +const ARR_STATUS_COLOR = { + downloading: '#0a84ff', + completed: '#0a84ff', + warning: '#ff9f0a', + error: '#ff453a', + failed: '#ff453a', + delay: '#ff9f0a', +}; + +export default function ArrQueueCard({ item }) { + const hasError = item.trackedStatus === 'warning' || item.trackedStatus === 'error' || item.status === 'failed'; + const statusColor = hasError ? '#ff9f0a' : ARR_STATUS_COLOR.downloading; + const progress = item.progress || 0; + const sizeLeft = item.sizeleft > 0 ? formatBytes(item.sizeleft) + ' left' : null; + const poster = posterSrc(item.posterUrl); + + return ( + <div + style={{ + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '10px 14px', + background: hasError ? 'rgba(255,159,10,0.04)' : 'var(--surface-subtle)', + borderRadius: 10, + border: `1px solid ${hasError ? 'rgba(255,159,10,0.2)' : 'var(--border-subtle)'}`, + }} + > + {poster ? ( + <img + src={poster} + alt="" + style={{ width: 32, height: 44, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} + decoding="async" + onError={e => { + e.target.style.display = 'none'; + }} + /> + ) : ( + <div + style={{ + width: 32, + height: 44, + borderRadius: 4, + background: 'var(--border-subtle)', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 16, color: 'var(--text-faint)' }}> + {item.service === 'sonarr' ? 'tv' : 'movie'} + </span> + </div> + )} + <div style={{ flex: 1, minWidth: 0 }}> + <div + style={{ + fontSize: 13, + fontWeight: 600, + color: 'var(--text-primary)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {item.title} + {item.episode ? ` — ${item.episode}` : ''} + </div> + {hasError && item.errorMessage ? ( + <div + style={{ + fontSize: 11, + color: '#ff9f0a', + marginTop: 2, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + > + {item.errorMessage} + </div> + ) : ( + <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}> + <div style={{ flex: 1, height: 3, background: 'var(--progress-bar)', borderRadius: 2, overflow: 'hidden' }}> + <div + style={{ + width: `${progress}%`, + height: '100%', + background: statusColor, + borderRadius: 2, + transition: 'width 0.5s', + boxShadow: `0 0 6px ${statusColor}90`, + animation: hasError ? 'none' : 'downloadPulse 1.8s ease-in-out infinite', + }} + /> + </div> + <span style={{ fontSize: 10, color: 'var(--text-faint)', flexShrink: 0 }}> + {sizeLeft || `${progress}%`} + </span> + </div> + )} + </div> + <div + style={{ + flexShrink: 0, + padding: '3px 8px', + borderRadius: 6, + background: `${statusColor}18`, + border: `1px solid ${statusColor}30`, + }} + > + <span + style={{ + fontSize: 10, + fontWeight: 600, + color: statusColor, + textTransform: 'uppercase', + letterSpacing: '0.04em', + }} + > + {hasError ? 'Warning' : item.status === 'completed' ? 'Importing' : 'Queued'} + </span> + </div> + </div> + ); +} diff --git a/frontend/src/components/app/ContainerChip.jsx b/frontend/src/components/app/ContainerChip.jsx new file mode 100644 index 0000000..b56f6bf --- /dev/null +++ b/frontend/src/components/app/ContainerChip.jsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { getServiceGradient } from '../../constants'; + +export default function ContainerChip({ container, name, href }) { + const [hovered, setHovered] = useState(false); + const [c1, c2] = getServiceGradient(name); + return ( + <div + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '5px 12px', + borderRadius: 20, + fontSize: 11.5, + fontWeight: 600, + whiteSpace: 'nowrap', + cursor: container.running && href ? 'pointer' : 'default', + border: '1px solid var(--border-subtle)', + color: container.running ? 'var(--text-secondary)' : 'var(--text-faint)', + opacity: container.running ? 1 : 0.38, + background: hovered && container.running ? 'var(--surface)' : 'transparent', + transition: 'background 0.2s', + userSelect: 'none', + }} + > + <span + style={{ + width: 16, + height: 16, + borderRadius: 4, + flexShrink: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + fontWeight: 800, + color: '#fff', + background: `linear-gradient(135deg, ${c1}, ${c2})`, + }} + > + {name[0]?.toUpperCase()} + </span> + {name} + {hovered && href && ( + <span + className="material-symbols-rounded" + style={{ + fontSize: 11, + color: 'rgba(235,235,245,0.45)', + fontVariationSettings: "'FILL' 0", + marginLeft: 1, + }} + > + open_in_new + </span> + )} + </div> + ); +} diff --git a/frontend/src/components/app/DownloadsView.jsx b/frontend/src/components/app/DownloadsView.jsx new file mode 100644 index 0000000..312a837 --- /dev/null +++ b/frontend/src/components/app/DownloadsView.jsx @@ -0,0 +1,197 @@ +import { Suspense, useMemo, useState } from 'react'; +import React from 'react'; +import { apiPost, apiDelete } from '../../api'; +import ArrQueueCard from './ArrQueueCard'; + +export default function DownloadsView({ + TorrentTable, + PipelineCard, + SlskdCard, + ManualSearchModal, + pipeline, + arrQueue, + torrents, + slskdDownloads, + mediaInfo, + torrentError, + onTorrentRefresh, + onSlskdUpdate, + manualSearchTarget, + setManualSearchTarget, + manualSearchResults, + manualSearchLoading, + fetchPendingSearches, +}) { + const [expandedPipelineKey, setExpandedPipelineKey] = useState(null); + + const pipelineStages = useMemo( + () => ({ + searching: pipeline.filter(p => p.stage === 'searching'), + downloading: pipeline.filter(p => p.stage === 'downloading'), + stuck: pipeline.filter(p => p.stage === 'stuck'), + }), + [pipeline], + ); + + const isEmpty = + pipeline.length === 0 && arrQueue.length === 0 && torrents.length === 0 && slskdDownloads.length === 0; + + return ( + <div style={{ flex: 1, overflowY: 'auto' }} className="scroll-area"> + {pipeline.length > 0 && ( + <div className="px-6 pt-4 pb-2"> + <div className="flex items-center gap-2 mb-3"> + <p className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Active Searches</p> + <span className="text-[10px] font-bold text-text-muted bg-white/[0.07] rounded-full px-1.5 py-0.5 leading-none"> + {pipeline.length} + </span> + <span style={{ fontSize: 10, color: 'var(--text-faint)' }}> + {[ + 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(' · ')} + </span> + <p className="text-[10px] text-text-muted ml-auto">Click card to expand log</p> + </div> + <div className="space-y-2 overflow-y-auto" style={{ maxHeight: 600 }}> + {pipeline.map(item => ( + <PipelineCard + key={item.key} + item={item} + expanded={expandedPipelineKey === item.key} + onToggle={() => setExpandedPipelineKey(prev => (prev === item.key ? null : item.key))} + onRetry={async key => { + await apiPost(`/api/pipeline/${encodeURIComponent(key)}/retry`); + }} + onCancel={async key => { + await apiDelete(`/api/pipeline/${encodeURIComponent(key)}/cancel`); + fetchPendingSearches(); + }} + onMonitor={async key => { + await apiPost(`/api/pipeline/${encodeURIComponent(key)}/monitor`); + fetchPendingSearches(); + }} + onManualSearch={item => setManualSearchTarget(item)} + onDismiss={async key => { + await apiDelete(`/api/pipeline/${encodeURIComponent(key)}`); + fetchPendingSearches(); + }} + /> + ))} + </div> + </div> + )} + + {arrQueue.length > 0 && ( + <div className="px-6 pt-4 pb-2"> + <div className="flex items-center gap-2 mb-3"> + <p className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Arr Queue</p> + <span className="text-[10px] font-bold text-text-muted bg-white/[0.07] rounded-full px-1.5 py-0.5 leading-none"> + {arrQueue.length} + </span> + </div> + <div className="space-y-2"> + {arrQueue.map(item => ( + <ArrQueueCard + key={`${item.service || 'arr'}-${item.id || item.downloadId || item.title}`} + item={item} + /> + ))} + </div> + </div> + )} + + {torrents.length > 0 && ( + <TorrentTable torrents={torrents} mediaInfo={mediaInfo} onRefresh={onTorrentRefresh} /> + )} + + {slskdDownloads.length > 0 && ( + <div className="px-6 pt-4 pb-2"> + <p className="text-[11px] font-semibold text-text-muted uppercase tracking-wider mb-3">Soulseek</p> + <div className="space-y-2"> + {slskdDownloads.map((dl, i) => ( + <SlskdCard + key={i} + dl={dl} + onDelete={async u => { + await apiDelete(`/api/slskd/downloads/${encodeURIComponent(u)}`); + onSlskdUpdate(); + }} + onRetry={async (u, fid) => { + await apiPost('/api/slskd/retry', { username: u, fileId: fid }); + setTimeout(onSlskdUpdate, 1000); + }} + /> + ))} + </div> + </div> + )} + + {manualSearchTarget && ( + <Suspense fallback={null}> + <ManualSearchModal + target={manualSearchTarget} + results={manualSearchResults} + loading={manualSearchLoading} + onClose={() => setManualSearchTarget(null)} + onGrab={async release => { + await apiPost('/api/grab', { + service: manualSearchTarget.service, + guid: release.guid, + indexerId: release.indexerId, + pipelineKey: manualSearchTarget.key, + downloadUrl: release.downloadUrl || undefined, + title: release.title, + }); + setTimeout(() => { + setManualSearchTarget(null); + fetchPendingSearches(); + }, 800); + }} + /> + </Suspense> + )} + + {isEmpty && ( + <div + style={{ + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: 300, + }} + > + {torrentError ? ( + <div style={{ textAlign: 'center' }}> + <p style={{ color: '#FF375F', fontSize: 14, fontWeight: 500, marginBottom: 4 }}> + qBittorrent unavailable + </p> + <p style={{ color: 'rgba(235,235,245,0.50)', fontSize: 12 }}>{torrentError}</p> + </div> + ) : ( + <div style={{ textAlign: 'center' }}> + <span + className="material-symbols-rounded" + style={{ + fontSize: 52, + display: 'block', + marginBottom: 16, + fontVariationSettings: "'FILL' 0", + color: 'var(--text-disabled)', + }} + > + download_done + </span> + <p style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 5 }}>All clear</p> + <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>No active transfers</p> + </div> + )} + </div> + )} + </div> + ); +} diff --git a/frontend/src/components/app/LoadingSkeleton.jsx b/frontend/src/components/app/LoadingSkeleton.jsx new file mode 100644 index 0000000..40180f1 --- /dev/null +++ b/frontend/src/components/app/LoadingSkeleton.jsx @@ -0,0 +1,22 @@ +export default function LoadingSkeleton() { + return ( + <div + style={{ + height: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'var(--bg-base)', + fontFamily: "-apple-system, 'SF Pro Display', sans-serif", + }} + > + <div style={{ textAlign: 'center' }}> + <svg viewBox="0 0 28 28" fill="none" style={{ width: 40, height: 40, margin: '0 auto 20px', display: 'block' }}> + <path d="M14 2 L24 22 L14 18 L4 22 Z" fill="white" opacity="0.9" /> + <path d="M14 6 L21 20 L14 16.5 L7 20 Z" fill="#FF375F" opacity="0.8" /> + </svg> + <p style={{ color: 'var(--text-muted)', fontSize: 13, letterSpacing: '0.04em' }}>Loading Icarus…</p> + </div> + </div> + ); +} diff --git a/frontend/src/components/app/MobileBottomNav.jsx b/frontend/src/components/app/MobileBottomNav.jsx new file mode 100644 index 0000000..fb3b8f3 --- /dev/null +++ b/frontend/src/components/app/MobileBottomNav.jsx @@ -0,0 +1,106 @@ +import { LibraryIcon, DownloadsIcon } from './AppIcons'; + +export default function MobileBottomNav({ + activeView, + setActiveView, + downloadingCount, + sidebarOpen, + onSidebarToggle, + onLeaveLibrary, +}) { + return ( + <nav + aria-label="Bottom navigation" + style={{ + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + height: 56, + background: 'var(--bg-nav)', + borderTop: '1px solid var(--border-subtle)', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-around', + zIndex: 200, + backdropFilter: 'blur(20px)', + WebkitBackdropFilter: 'blur(20px)', + paddingBottom: 'env(safe-area-inset-bottom)', + }} + > + {[ + { view: 'library', icon: <LibraryIcon />, label: 'Library' }, + { view: 'downloads', icon: <DownloadsIcon />, label: 'Downloads', badge: downloadingCount }, + ].map(({ view, icon, label, badge }) => ( + <button + key={view} + onClick={() => { + setActiveView(view); + if (view !== 'library') onLeaveLibrary(); + }} + style={{ + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 3, + height: '100%', + background: 'none', + border: 'none', + cursor: 'pointer', + position: 'relative', + color: activeView === view ? '#FF375F' : 'var(--text-muted)', + }} + > + {icon} + <span style={{ fontSize: 10, fontWeight: 600 }}>{label}</span> + {badge > 0 && ( + <span + style={{ + position: 'absolute', + top: 6, + right: 'calc(50% - 22px)', + minWidth: 16, + height: 16, + borderRadius: 8, + background: '#FF375F', + color: '#fff', + fontSize: 9, + fontWeight: 800, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0 3px', + }} + > + {badge > 99 ? '99+' : badge} + </span> + )} + </button> + ))} + <button + aria-label={sidebarOpen ? 'Close panel' : 'Open panel'} + onClick={onSidebarToggle} + style={{ + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 3, + height: '100%', + background: 'none', + border: 'none', + cursor: 'pointer', + color: sidebarOpen ? '#FF375F' : 'var(--text-muted)', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + {sidebarOpen ? 'close' : 'dashboard'} + </span> + <span style={{ fontSize: 10, fontWeight: 600 }}>Panel</span> + </button> + </nav> + ); +} diff --git a/frontend/src/components/app/RailItem.jsx b/frontend/src/components/app/RailItem.jsx new file mode 100644 index 0000000..088bba4 --- /dev/null +++ b/frontend/src/components/app/RailItem.jsx @@ -0,0 +1,69 @@ +export default function RailItem({ icon, active, onClick, title, badge }) { + return ( + <div + onClick={onClick} + title={title} + aria-label={title} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') onClick(); + }} + className="rail-item" + style={{ + width: 44, + height: 44, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + position: 'relative', + background: active ? 'rgba(255,55,95,0.18)' : 'transparent', + color: active ? '#FF375F' : 'rgba(235,235,245,0.50)', + transition: 'background 0.2s, color 0.2s', + }} + > + {active && ( + <div + style={{ + position: 'absolute', + left: -8, + top: '50%', + transform: 'translateY(-50%)', + width: 3, + height: 20, + background: '#FF375F', + borderRadius: 2, + }} + /> + )} + {icon} + {badge > 0 && ( + <div + style={{ + position: 'absolute', + top: 4, + right: 4, + minWidth: 16, + height: 16, + borderRadius: 8, + background: '#FF375F', + color: '#fff', + fontSize: 9, + fontWeight: 800, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0 3px', + lineHeight: 1, + boxShadow: '0 0 0 2px var(--bg-nav)', + pointerEvents: 'none', + }} + > + {badge > 99 ? '99+' : badge} + </div> + )} + </div> + ); +} diff --git a/frontend/src/components/app/ServiceStrip.jsx b/frontend/src/components/app/ServiceStrip.jsx new file mode 100644 index 0000000..6fec37c --- /dev/null +++ b/frontend/src/components/app/ServiceStrip.jsx @@ -0,0 +1,42 @@ +import { cleanName } from '../../utils'; +import { getServiceUrl } from '../../constants'; +import ContainerChip from './ContainerChip'; + +export default function ServiceStrip({ sortedContainers, tailscaleIp }) { + return ( + <div + style={{ + height: 44, + flexShrink: 0, + background: 'var(--bg-service-strip)', + borderBottom: '1px solid var(--border-subtle)', + display: 'flex', + alignItems: 'center', + padding: '0 28px', + gap: 8, + overflowX: 'auto', + scrollbarWidth: 'none', + }} + className="hide-scrollbar" + > + {sortedContainers.map(c => { + const name = cleanName(c.name); + const port = c.ports?.find(p => p.host) || c.ports?.[0]; + const href = c.running + ? tailscaleIp && port + ? `http://${tailscaleIp}:${port.host}` + : getServiceUrl(name) + : null; + return href ? ( + <a key={c.id} href={href} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}> + <ContainerChip container={c} name={name} href={href} /> + </a> + ) : ( + <div key={c.id}> + <ContainerChip container={c} name={name} href={null} /> + </div> + ); + })} + </div> + ); +} diff --git a/frontend/src/components/sidePanel/ActivityRow.jsx b/frontend/src/components/sidePanel/ActivityRow.jsx new file mode 100644 index 0000000..1fadead --- /dev/null +++ b/frontend/src/components/sidePanel/ActivityRow.jsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { timeAgo, formatActivityMessage } from '../../utils'; +import { DOT_COLOR, SERVICE_ICON, SEPARATOR } from './constants'; + +const ActivityRow = React.memo(function ActivityRow({ item, onDismiss, isLast, isMobile = false }) { + const [hover, setHover] = useState(false); + const { subject, reason } = formatActivityMessage(item); + const svcIcon = SERVICE_ICON[item.context?.service]; + const color = DOT_COLOR[item.status] || DOT_COLOR.info; + + return ( + <div + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + display: 'flex', + alignItems: 'flex-start', + gap: 10, + padding: '8px 4px', + borderBottom: isLast ? 'none' : SEPARATOR, + position: 'relative', + }} + > + <div + style={{ + width: 6, + height: 6, + borderRadius: '50%', + flexShrink: 0, + marginTop: 6, + background: color, + boxShadow: item.status === 'pending' ? `0 0 6px ${color}` : 'none', + }} + /> + <div style={{ flex: 1, minWidth: 0 }}> + <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> + {svcIcon && ( + <span + className="material-symbols-rounded" + style={{ + fontSize: 11, + color: 'var(--text-muted)', + fontVariationSettings: "'FILL' 1", + flexShrink: 0, + }} + > + {svcIcon} + </span> + )} + <span + title={subject} + style={{ + fontSize: 11.5, + color: 'var(--text-primary)', + fontWeight: 500, + lineHeight: 1.3, + flex: 1, + minWidth: 0, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {subject} + </span> + </div> + {reason && ( + <div + title={reason} + style={{ + fontSize: 10.5, + color: 'var(--text-muted)', + marginTop: 2, + lineHeight: 1.3, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {reason} + </div> + )} + </div> + <div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, marginTop: 3 }}> + <span style={{ fontSize: 10, color: 'var(--text-disabled)', fontVariantNumeric: 'tabular-nums' }}> + {timeAgo(item.timestamp)} + </span> + {(hover || isMobile) && ( + <button + onClick={e => { + e.stopPropagation(); + onDismiss(item.id); + }} + title="Dismiss" + style={{ + width: 18, + height: 18, + borderRadius: 4, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + background: 'transparent', + cursor: 'pointer', + color: 'var(--text-muted)', + }} + onMouseEnter={e => { + e.currentTarget.style.background = 'var(--border-medium)'; + e.currentTarget.style.color = 'var(--text-primary)'; + }} + onMouseLeave={e => { + e.currentTarget.style.background = 'transparent'; + e.currentTarget.style.color = 'var(--text-muted)'; + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 12 }}> + close + </span> + </button> + )} + </div> + </div> + ); +}); + +export default ActivityRow; diff --git a/frontend/src/components/sidePanel/DownloadMoreMenu.jsx b/frontend/src/components/sidePanel/DownloadMoreMenu.jsx new file mode 100644 index 0000000..e580d5c --- /dev/null +++ b/frontend/src/components/sidePanel/DownloadMoreMenu.jsx @@ -0,0 +1,90 @@ +import { useState, useEffect, useRef } from 'react'; +import React from 'react'; + +const MenuItem = React.memo(function MenuItem({ icon, label, onClick, danger }) { + const [hover, setHover] = useState(false); + return ( + <button + onClick={onClick} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + width: '100%', + padding: '7px 10px', + borderRadius: 7, + background: hover ? (danger ? 'rgba(255,55,95,0.14)' : 'var(--border-subtle)') : 'transparent', + color: danger ? '#ff6b8a' : 'var(--text-primary)', + fontSize: 12, + fontWeight: 500, + border: 'none', + cursor: 'pointer', + textAlign: 'left', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}> + {icon} + </span> + {label} + </button> + ); +}); + +export default function DownloadMoreMenu({ onClose, onPause, onDelete, onDeleteWithFiles, paused, anchorRef }) { + const menuRef = useRef(null); + useEffect(() => { + const handler = e => { + if (menuRef.current?.contains(e.target)) return; + if (anchorRef?.current?.contains(e.target)) return; + onClose(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onClose, anchorRef]); + return ( + <div + ref={menuRef} + style={{ + position: 'absolute', + right: 0, + top: 'calc(100% + 4px)', + zIndex: 100, + background: 'var(--surface-elevated)', + border: '1px solid var(--border-medium)', + borderRadius: 10, + padding: 4, + minWidth: 180, + boxShadow: '0 12px 32px rgba(0,0,0,0.3)', + backdropFilter: 'blur(20px)', + }} + > + <MenuItem + icon={paused ? 'play_arrow' : 'pause'} + label={paused ? 'Resume' : 'Pause'} + onClick={() => { + onPause(); + onClose(); + }} + /> + <MenuItem + icon="delete_outline" + label="Remove from list" + onClick={() => { + onDelete(); + onClose(); + }} + /> + <MenuItem + icon="delete_forever" + label="Remove + delete files" + danger + onClick={() => { + onDeleteWithFiles(); + onClose(); + }} + /> + </div> + ); +} diff --git a/frontend/src/components/sidePanel/IconButton.jsx b/frontend/src/components/sidePanel/IconButton.jsx new file mode 100644 index 0000000..7718097 --- /dev/null +++ b/frontend/src/components/sidePanel/IconButton.jsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; + +const IconButton = React.memo(function IconButton({ onClick, title, danger, disabled, children }) { + const [hover, setHover] = useState(false); + return ( + <button + onClick={onClick} + disabled={disabled} + title={title} + aria-label={title} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + width: 44, + height: 44, + borderRadius: 10, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + cursor: disabled ? 'default' : 'pointer', + background: hover && !disabled ? (danger ? 'rgba(255,55,95,0.14)' : 'var(--border-subtle)') : 'transparent', + color: danger ? '#ff6b8a' : 'var(--text-muted)', + opacity: disabled ? 0.4 : 1, + transition: 'background 0.15s, color 0.15s', + padding: 0, + flexShrink: 0, + }} + > + {children} + </button> + ); +}); + +export default IconButton; diff --git a/frontend/src/components/sidePanel/PanelBody.jsx b/frontend/src/components/sidePanel/PanelBody.jsx new file mode 100644 index 0000000..0b3e16d --- /dev/null +++ b/frontend/src/components/sidePanel/PanelBody.jsx @@ -0,0 +1,345 @@ +import { formatSpeed, formatBytes } from '../../utils'; +import QbDownloadItem from './QbDownloadItem'; +import SlskdDownloadItem from './SlskdDownloadItem'; +import ActivityRow from './ActivityRow'; +import Sparkline from './Sparkline'; +import { PANEL_TITLE_STYLE, SEPARATOR } from './constants'; + +function storageBarGradient(pct) { + if (pct > 85) return 'linear-gradient(90deg, #FF375F 0%, #FF6B8A 100%)'; + if (pct > 75) return 'linear-gradient(90deg, #FF9F0A 0%, #FFD60A 100%)'; + return 'linear-gradient(90deg, #0A84FF 0%, #5AC8FA 100%)'; +} + +export default function PanelBody({ + titleId, + isMobile, + onClose, + activeDownloads, + activeSlskd, + mediaInfo, + onTorrentAction, + onSlskdDelete, + onSlskdRetry, + activity, + onDismissActivity, + onClearActivity, + bwHistory, + bwTotals, + bwLifetime, + storage, +}) { + const totalItems = activeDownloads.length + activeSlskd.length; + + return ( + <> + {isMobile && ( + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 16px 8px', + borderBottom: '1px solid var(--border-subtle)', + flexShrink: 0, + }} + > + <span + id={titleId} + style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-secondary)', letterSpacing: '-0.01em' }} + > + Panel + </span> + <button + onClick={onClose} + style={{ + width: 30, + height: 30, + borderRadius: 8, + background: 'var(--border-subtle)', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'var(--text-secondary)', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 18 }}> + close + </span> + </button> + </div> + )} + + {!isMobile && ( + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '14px 12px 10px 16px', + borderBottom: '1px solid var(--border-subtle)', + flexShrink: 0, + }} + > + <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-secondary)', letterSpacing: '-0.01em' }}> + Panel + </span> + <button + type="button" + aria-label="Collapse panel" + onClick={onClose} + style={{ + width: 28, + height: 28, + borderRadius: 8, + background: 'var(--border-subtle)', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'var(--text-secondary)', + transition: 'background 160ms ease, color 160ms ease', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 18 }}> + chevron_right + </span> + </button> + </div> + )} + + <div + style={{ + padding: '16px 16px 16px', + borderBottom: SEPARATOR, + flexShrink: 0, + willChange: 'transform', + maxHeight: '45%', + overflowY: 'auto', + overscrollBehavior: 'contain', + WebkitOverflowScrolling: 'touch', + scrollbarWidth: 'thin', + }} + className="scroll-area" + > + <div style={{ ...PANEL_TITLE_STYLE, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> + <span>Now Downloading</span> + {totalItems > 0 && ( + <span style={{ color: 'var(--text-secondary)', fontSize: 10, letterSpacing: 0 }}>{totalItems}</span> + )} + </div> + + {totalItems === 0 ? ( + <p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: '6px 0' }}> + No active downloads + </p> + ) : ( + <div> + {activeDownloads.map((t, idx) => { + const info = mediaInfo?.[t.hash] || mediaInfo?.[t.hash?.toLowerCase()]; + const isLast = idx === activeDownloads.length - 1 && activeSlskd.length === 0; + return ( + <QbDownloadItem key={t.hash} torrent={t} info={info} onAction={onTorrentAction} isLast={isLast} /> + ); + })} + {activeSlskd.map((dl, i) => ( + <SlskdDownloadItem + key={dl.username + dl.directory} + dl={dl} + onDelete={onSlskdDelete} + onRetry={onSlskdRetry} + isLast={i === activeSlskd.length - 1} + /> + ))} + </div> + )} + </div> + + <div + style={{ + flex: 1, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + padding: '16px 16px 16px', + borderBottom: SEPARATOR, + }} + > + <div style={{ ...PANEL_TITLE_STYLE, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> + <span>Activity</span> + {activity.length > 0 && ( + <button + onClick={onClearActivity} + title="Clear all" + style={{ + fontSize: 10, + color: 'var(--text-muted)', + background: 'transparent', + border: 'none', + cursor: 'pointer', + padding: 0, + letterSpacing: 0, + textTransform: 'none', + }} + > + Clear + </button> + )} + </div> + <div + style={{ + willChange: 'transform', + overflowY: 'auto', + flex: 1, + scrollbarWidth: 'thin', + overscrollBehavior: 'contain', + WebkitOverflowScrolling: 'touch', + }} + className="scroll-area" + > + {activity.length === 0 ? ( + <p style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: '6px 0' }}> + No recent activity + </p> + ) : ( + activity.map((item, i) => ( + <ActivityRow + key={item.id} + item={item} + onDismiss={onDismissActivity} + isLast={i === activity.length - 1} + isMobile={isMobile} + /> + )) + )} + </div> + </div> + + {bwHistory.length > 0 && ( + <div style={{ padding: '16px 16px 16px', borderBottom: SEPARATOR, flexShrink: 0 }}> + <div style={{ ...PANEL_TITLE_STYLE, marginBottom: 8 }}>Bandwidth</div> + <div style={{ borderRadius: 6, overflow: 'hidden', background: 'var(--surface-subtle)' }}> + <Sparkline data={bwHistory} /> + </div> + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + marginTop: 8, + fontSize: 11.5, + fontVariantNumeric: 'tabular-nums', + }} + > + <span style={{ color: '#30d158', fontWeight: 600 }}> + ↓ {formatSpeed(bwHistory[bwHistory.length - 1]?.dl || 0)} + </span> + <span style={{ color: '#FF375F', opacity: 0.85, fontWeight: 600 }}> + ↑ {formatSpeed(bwHistory[bwHistory.length - 1]?.ul || 0)} + </span> + </div> + {(bwTotals.dl > 0 || bwTotals.ul > 0) && ( + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + marginTop: 5, + fontSize: 10.5, + color: 'var(--text-disabled)', + fontVariantNumeric: 'tabular-nums', + }} + > + <span>↓ {formatBytes(bwTotals.dl)} session</span> + <span>↑ {formatBytes(bwTotals.ul)} session</span> + </div> + )} + {(bwLifetime.dl > 0 || bwLifetime.ul > 0) && ( + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + marginTop: 3, + fontSize: 10.5, + fontVariantNumeric: 'tabular-nums', + }} + > + <span style={{ color: 'rgba(48,209,88,0.6)' }}>↓ {formatBytes(bwLifetime.dl)} lifetime</span> + <span style={{ color: 'rgba(255,55,95,0.5)' }}>↑ {formatBytes(bwLifetime.ul)} lifetime</span> + </div> + )} + </div> + )} + + {storage && ( + <div style={{ padding: '16px 16px 24px', flexShrink: 0 }}> + <div style={{ ...PANEL_TITLE_STYLE, marginBottom: 12 }}>Storage</div> + <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> + {storage.breakdown + ?.filter(d => d.size > 0) + .slice(0, 3) + .map(d => ( + <div key={d.path}> + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + marginBottom: 5, + }} + > + <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-secondary)' }}>{d.name}</span> + <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}> + {formatBytes(d.size)} + </span> + </div> + <div style={{ height: 4, background: 'var(--progress-bar)', borderRadius: 3, overflow: 'hidden' }}> + <div + style={{ + height: '100%', + width: storage.disk + ? `${Math.min(100, Math.round((d.size / storage.disk.total) * 100))}%` + : '50%', + borderRadius: 3, + background: 'linear-gradient(90deg, #0A84FF 0%, #5AC8FA 100%)', + transition: 'width 0.5s ease', + }} + /> + </div> + </div> + ))} + {storage.disk && ( + <div style={{ marginTop: 2, paddingTop: 10, borderTop: SEPARATOR }}> + <div + style={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + marginBottom: 5, + }} + > + <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-secondary)' }}>Total</span> + <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontVariantNumeric: 'tabular-nums' }}> + {formatBytes(storage.disk.available)} free + </span> + </div> + <div style={{ height: 4, background: 'var(--progress-bar)', borderRadius: 3, overflow: 'hidden' }}> + <div + style={{ + height: '100%', + width: `${Math.round((storage.disk.used / storage.disk.total) * 100)}%`, + borderRadius: 3, + background: storageBarGradient(Math.round((storage.disk.used / storage.disk.total) * 100)), + transition: 'width 0.5s ease', + }} + /> + </div> + </div> + )} + </div> + </div> + )} + </> + ); +} diff --git a/frontend/src/components/sidePanel/QbDownloadItem.jsx b/frontend/src/components/sidePanel/QbDownloadItem.jsx new file mode 100644 index 0000000..a42f535 --- /dev/null +++ b/frontend/src/components/sidePanel/QbDownloadItem.jsx @@ -0,0 +1,203 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { formatSpeed, formatBytes, getTorrentState } from '../../utils'; +import Thumb from './Thumb'; +import IconButton from './IconButton'; +import DownloadMoreMenu from './DownloadMoreMenu'; +import { SEPARATOR, ACTIVE_DOWNLOAD_BLUE, ACTIVE_DOWNLOAD_BLUE_SOFT } from './constants'; + +const QbDownloadItem = React.memo(function QbDownloadItem({ torrent, info, onAction, onOpenDetail, isLast }) { + const [menuOpen, setMenuOpen] = useState(false); + const [actionErr, setActionErr] = useState(null); + const [confirmCancel, setConfirmCancel] = useState(false); + const cancelTimerRef = useRef(null); + const moreBtnRef = useRef(null); + useEffect(() => () => clearTimeout(cancelTimerRef.current), []); + + const pct = Math.round(torrent.progress || 0); + const displayTitle = info?.title || torrent.name; + const sub = + [info?.year, info?.network, info?.episodeNumber].filter(Boolean).join(' · ') || + (torrent.size > 0 ? formatBytes(torrent.size) : ''); + const state = getTorrentState(torrent); + const paused = state === 'paused'; + const speed = torrent.downloadSpeed || 0; + const isMusic = info?.mediaType === 'music' || /lidarr|music/i.test(torrent.category || ''); + const isLiveDownload = !paused && speed > 0; + + const handle = async action => { + setActionErr(null); + try { + await onAction(torrent.hash, action); + } catch (e) { + setActionErr(e.message || 'Action failed'); + setTimeout(() => setActionErr(null), 3000); + } + }; + + return ( + <div + style={{ + paddingBottom: isLast ? 0 : 12, + marginBottom: isLast ? 0 : 12, + borderBottom: isLast ? 'none' : SEPARATOR, + position: 'relative', + }} + > + <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 7 }}> + <Thumb url={info?.posterUrl} title={displayTitle} square={isMusic} /> + <div style={{ flex: 1, minWidth: 0 }}> + <button + onClick={() => onOpenDetail?.(torrent, info)} + title={displayTitle} + style={{ + display: 'block', + width: '100%', + textAlign: 'left', + padding: 0, + background: 'transparent', + border: 'none', + cursor: 'pointer', + fontSize: 12.5, + fontWeight: 600, + color: 'var(--text-primary)', + lineHeight: 1.3, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {displayTitle} + </button> + {sub && ( + <div + style={{ + fontSize: 11, + color: 'var(--text-muted)', + marginTop: 2, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {sub} + </div> + )} + <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}> + {speed > 0 ? ( + <span + style={{ + fontSize: 11, + fontWeight: 600, + color: ACTIVE_DOWNLOAD_BLUE_SOFT, + fontVariantNumeric: 'tabular-nums', + animation: isLiveDownload ? 'downloadPulse 1.8s ease-in-out infinite' : 'none', + }} + > + ↓ {formatSpeed(speed)} + </span> + ) : ( + <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-disabled)' }}> + {paused ? 'Paused' : 'Stalled'} + </span> + )} + <span + style={{ + fontSize: 10.5, + fontWeight: 700, + color: 'var(--text-muted)', + fontVariantNumeric: 'tabular-nums', + }} + > + {pct}% + </span> + </div> + </div> + <div style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}> + <IconButton title={paused ? 'Resume' : 'Pause'} onClick={() => handle(paused ? 'resume' : 'pause')}> + <span className="material-symbols-rounded" style={{ fontSize: 15, fontVariationSettings: "'FILL' 1" }}> + {paused ? 'play_arrow' : 'pause'} + </span> + </IconButton> + {confirmCancel ? ( + <button + onClick={() => { + clearTimeout(cancelTimerRef.current); + setConfirmCancel(false); + handle('delete'); + }} + style={{ + height: 30, + padding: '0 8px', + borderRadius: 8, + display: 'flex', + alignItems: 'center', + gap: 4, + background: 'rgba(255,69,58,0.85)', + border: 'none', + cursor: 'pointer', + fontSize: 11, + fontWeight: 700, + color: '#fff', + whiteSpace: 'nowrap', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 13, fontVariationSettings: "'FILL' 1" }}> + delete + </span> + Remove? + </button> + ) : ( + <IconButton + title="Cancel download" + danger + onClick={() => { + setConfirmCancel(true); + clearTimeout(cancelTimerRef.current); + cancelTimerRef.current = setTimeout(() => setConfirmCancel(false), 2500); + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 15, fontVariationSettings: "'FILL' 1" }}> + close + </span> + </IconButton> + )} + <div ref={moreBtnRef}> + <IconButton title="More" onClick={() => setMenuOpen(!menuOpen)}> + <span className="material-symbols-rounded" style={{ fontSize: 16 }}> + more_vert + </span> + </IconButton> + </div> + {menuOpen && ( + <DownloadMoreMenu + anchorRef={moreBtnRef} + onClose={() => setMenuOpen(false)} + paused={paused} + onPause={() => handle(paused ? 'resume' : 'pause')} + onDelete={() => handle('delete')} + onDeleteWithFiles={() => handle('deleteFiles')} + /> + )} + </div> + </div> + <div style={{ height: 3, background: 'var(--progress-bar)', borderRadius: 2, overflow: 'hidden' }}> + <div + style={{ + height: '100%', + width: `${pct}%`, + borderRadius: 2, + background: paused + ? 'linear-gradient(90deg, #636366 0%, #8e8e93 100%)' + : `linear-gradient(90deg, ${ACTIVE_DOWNLOAD_BLUE} 0%, ${ACTIVE_DOWNLOAD_BLUE_SOFT} 100%)`, + boxShadow: paused ? 'none' : '0 0 8px rgba(10,132,255,0.35)', + transition: 'width 0.4s ease', + animation: isLiveDownload ? 'downloadPulse 1.8s ease-in-out infinite' : 'none', + }} + /> + </div> + {actionErr && <p style={{ fontSize: 10, color: '#ff453a', marginTop: 4, textAlign: 'right' }}>{actionErr}</p>} + </div> + ); +}); + +export default QbDownloadItem; diff --git a/frontend/src/components/sidePanel/SlskdDownloadItem.jsx b/frontend/src/components/sidePanel/SlskdDownloadItem.jsx new file mode 100644 index 0000000..0ca2c37 --- /dev/null +++ b/frontend/src/components/sidePanel/SlskdDownloadItem.jsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { formatSpeed } from '../../utils'; +import Thumb from './Thumb'; +import IconButton from './IconButton'; +import { SEPARATOR } from './constants'; + +const SlskdDownloadItem = React.memo(function SlskdDownloadItem({ dl, onDelete, onRetry, isLast }) { + const [confirmCancel, setConfirmCancel] = useState(false); + const cancelTimerRef = useRef(null); + useEffect(() => () => clearTimeout(cancelTimerRef.current), []); + + const pct = dl.percentComplete || 0; + const speed = dl.files?.find(f => f.state === 'InProgress')?.averageSpeed || 0; + const hasFailed = dl.failed > 0; + + return ( + <div + style={{ + paddingBottom: isLast ? 0 : 12, + marginBottom: isLast ? 0 : 12, + borderBottom: isLast ? 'none' : SEPARATOR, + position: 'relative', + }} + > + <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 7 }}> + <Thumb url={dl.posterUrl} title={`${dl.artistName} ${dl.albumName}`} square /> + <div style={{ flex: 1, minWidth: 0 }}> + <div + title={`${dl.artistName} — ${dl.albumName}`} + style={{ + fontSize: 12.5, + fontWeight: 600, + color: 'var(--text-primary)', + lineHeight: 1.3, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {dl.albumName} + </div> + <div + style={{ + fontSize: 11, + color: 'var(--text-muted)', + marginTop: 2, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {dl.artistName} · Soulseek + </div> + <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 3 }}> + {speed > 0 ? ( + <span style={{ fontSize: 11, fontWeight: 600, color: '#30d158', fontVariantNumeric: 'tabular-nums' }}> + ↓ {formatSpeed(speed)} + </span> + ) : ( + <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-disabled)' }}> + {hasFailed ? `${dl.failed} failed` : 'Queued'} + </span> + )} + <span + style={{ + fontSize: 10.5, + fontWeight: 700, + color: 'var(--text-muted)', + fontVariantNumeric: 'tabular-nums', + }} + > + {dl.completed}/{dl.fileCount} + </span> + </div> + </div> + <div style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}> + {hasFailed && ( + <IconButton title="Retry failed" onClick={() => onRetry(dl.username)}> + <span className="material-symbols-rounded" style={{ fontSize: 15 }}> + refresh + </span> + </IconButton> + )} + {confirmCancel ? ( + <button + onClick={() => { + clearTimeout(cancelTimerRef.current); + setConfirmCancel(false); + onDelete(dl.username); + }} + style={{ + height: 30, + padding: '0 8px', + borderRadius: 8, + display: 'flex', + alignItems: 'center', + gap: 4, + background: 'rgba(255,69,58,0.85)', + border: 'none', + cursor: 'pointer', + fontSize: 11, + fontWeight: 700, + color: '#fff', + whiteSpace: 'nowrap', + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 13, fontVariationSettings: "'FILL' 1" }}> + delete + </span> + Remove? + </button> + ) : ( + <IconButton + title="Cancel download" + danger + onClick={() => { + setConfirmCancel(true); + clearTimeout(cancelTimerRef.current); + cancelTimerRef.current = setTimeout(() => setConfirmCancel(false), 2500); + }} + > + <span className="material-symbols-rounded" style={{ fontSize: 15, fontVariationSettings: "'FILL' 1" }}> + close + </span> + </IconButton> + )} + </div> + </div> + <div style={{ height: 3, background: 'var(--progress-bar)', borderRadius: 2, overflow: 'hidden' }}> + <div + style={{ + height: '100%', + width: `${pct}%`, + borderRadius: 2, + background: hasFailed + ? 'linear-gradient(90deg, #ff9f0a 0%, #ffd60a 100%)' + : 'linear-gradient(90deg, #30d158 0%, #5ad67e 100%)', + transition: 'width 0.4s ease', + }} + /> + </div> + </div> + ); +}); + +export default SlskdDownloadItem; diff --git a/frontend/src/components/sidePanel/Sparkline.jsx b/frontend/src/components/sidePanel/Sparkline.jsx new file mode 100644 index 0000000..7fee522 --- /dev/null +++ b/frontend/src/components/sidePanel/Sparkline.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +const Sparkline = React.memo(function Sparkline({ data, height = 38 }) { + if (!data || data.length < 2) return <div style={{ height }} />; + const w = 280; + const h = height; + const maxVal = Math.max(1, ...data.map(d => Math.max(d.dl, d.ul))); + const pts = key => + data + .map((d, i) => { + const x = (i / (data.length - 1)) * w; + const y = h - (d[key] / maxVal) * (h - 4) - 2; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + return ( + <svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ display: 'block' }}> + <polyline + points={pts('dl')} + fill="none" + stroke="#30d158" + strokeWidth="1.5" + strokeLinejoin="round" + opacity="0.9" + /> + <polyline + points={pts('ul')} + fill="none" + stroke="#FF375F" + strokeWidth="1.5" + strokeLinejoin="round" + opacity="0.65" + /> + </svg> + ); +}); + +export default Sparkline; diff --git a/frontend/src/components/sidePanel/Thumb.jsx b/frontend/src/components/sidePanel/Thumb.jsx new file mode 100644 index 0000000..23fae02 --- /dev/null +++ b/frontend/src/components/sidePanel/Thumb.jsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from 'react'; +import { gradientFor, posterSrc } from '../../utils'; + +const Thumb = React.memo(function Thumb({ url, title, square }) { + const [failed, setFailed] = useState(false); + useEffect(() => { + setFailed(false); + }, [url]); + const size = square ? { width: 44, height: 44 } : { width: 36, height: 50 }; + const src = posterSrc(url); + const initials = (title || '?') + .trim() + .split(/\s+/) + .slice(0, 2) + .map(w => w[0]) + .join('') + .toUpperCase(); + return ( + <div + style={{ + ...size, + borderRadius: 6, + flexShrink: 0, + overflow: 'hidden', + position: 'relative', + background: gradientFor(title || ''), + boxShadow: '0 2px 6px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.06)', + }} + > + {src && !failed && ( + <img + src={src} + alt={title || ''} + decoding="async" + onError={() => setFailed(true)} + style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} + /> + )} + {(!src || failed) && ( + <div + style={{ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: square ? 13 : 11, + fontWeight: 800, + letterSpacing: '0.02em', + color: 'rgba(255,255,255,0.55)', + textShadow: '0 1px 4px rgba(0,0,0,0.6)', + }} + > + {initials} + </div> + )} + </div> + ); +}); + +export default Thumb; diff --git a/frontend/src/components/sidePanel/constants.js b/frontend/src/components/sidePanel/constants.js new file mode 100644 index 0000000..0cc31fd --- /dev/null +++ b/frontend/src/components/sidePanel/constants.js @@ -0,0 +1,25 @@ +export const DOT_COLOR = { + success: '#30d158', + error: '#ff375f', + pending: '#ff9f0a', + info: '#0a84ff', +}; + +export const SERVICE_ICON = { sonarr: 'tv', radarr: 'movie', lidarr: 'album', slskd: 'cloud_download' }; + +export const PANEL_TITLE_STYLE = { + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: 'var(--text-muted)', + marginBottom: 14, +}; + +export const SEPARATOR = '1px solid var(--border-subtle)'; +export const PANEL_WIDTH = 320; +export const PANEL_SLIDE_MS = 360; +export const PANEL_FADE_MS = 240; +export const PANEL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; +export const ACTIVE_DOWNLOAD_BLUE = '#0a84ff'; +export const ACTIVE_DOWNLOAD_BLUE_SOFT = '#5ac8fa'; diff --git a/frontend/src/components/torrent/TorrentDownloadCards.jsx b/frontend/src/components/torrent/TorrentDownloadCards.jsx new file mode 100644 index 0000000..895c2a4 --- /dev/null +++ b/frontend/src/components/torrent/TorrentDownloadCards.jsx @@ -0,0 +1,767 @@ +import { useState, useRef, useEffect, memo } from 'react'; +import { + formatBytes, + formatSpeed, + formatETA, + getTorrentState, + gradientFor, + extractRating, + posterSrc, + formatShortDate, + formatRuntime, +} from '../../utils'; +import { + EASING, + TIMING, + TORRENT_STATE_COLOR, + TORRENT_UNKNOWN_ETA_SECONDS, + getServiceUrl, +} from '../../constants'; +import { extractSeasonLabel, extractSeasonNum } from './torrentGrouping'; + +const STATE_LABEL = { + downloading: 'Downloading', + seeding: 'Seeding', + paused: 'Paused', + completed: 'Completed', + error: 'Error', +}; +const PROGRESS_TRANSITION = `width ${TIMING.PROGRESS_TRANSITION_MS}ms ${EASING.SPRING}`; + +function PosterPlaceholder({ category, title }) { + let icon = 'movie'; + if (category?.includes('sonarr')) icon = 'tv'; + else if (category?.includes('lidarr')) icon = 'album'; + const displayText = (title || '').toUpperCase().slice(0, 40); + return ( + <div className="absolute inset-0 flex items-end" style={{ background: gradientFor(title) }}> + <div + style={{ + position: 'absolute', + inset: 0, + background: + 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.04) 2px, rgba(0,0,0,0.04) 4px)', + pointerEvents: 'none', + }} + /> + <span + className="material-symbols-rounded absolute" + style={{ top: 12, right: 12, fontSize: 22, fontVariationSettings: "'FILL' 1", color: 'rgba(255,255,255,0.35)' }} + > + {icon} + </span> + {displayText && ( + <div + style={{ + position: 'relative', + padding: '0 14px 18px', + zIndex: 2, + fontSize: 15, + fontWeight: 800, + lineHeight: 1.15, + letterSpacing: '-0.01em', + color: 'rgba(255,255,255,0.88)', + textShadow: '0 2px 14px rgba(0,0,0,0.8)', + wordBreak: 'break-word', + }} + > + {displayText} + </div> + )} + </div> + ); +} + +const CardPoster = memo(function CardPoster({ url, category, title }) { + const [failed, setFailed] = useState(false); + // Retry image loads when refreshed metadata points at a new poster URL. + useEffect(() => { + setFailed(false); + }, [url]); + if (!url || failed) return <PosterPlaceholder category={category} title={title} />; + return ( + <img + src={posterSrc(url)} + alt={title ? `${title} poster` : 'Media poster'} + loading="eager" + decoding="async" + className="absolute inset-0 w-full h-full object-cover" + onError={() => setFailed(true)} + /> + ); +}); + +// ── Sort History ──────────────────────────────────────────────────────────── + +function SortLine({ sortData }) { + if (!sortData) return null; + const { status, dest } = sortData; + if (status === 'unknown') return null; + + const config = { + sorted: { icon: 'check_circle', label: 'SORTED', fg: '#30d158', bg: 'rgba(48,209,88,0.15)' }, + error: { icon: 'error', label: 'SORT ERROR', fg: '#ff453a', bg: 'rgba(255,69,58,0.15)' }, + holding: { icon: 'hourglass_top', label: 'HOLDING', fg: '#ff9f0a', bg: 'rgba(255,159,10,0.15)' }, + }; + const c = config[status] || config.holding; + + return ( + <div className="mt-1.5"> + <div className="flex items-center gap-1.5"> + <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded" style={{ background: c.bg }}> + <span + className="material-symbols-rounded" + style={{ fontSize: 11, fontVariationSettings: "'FILL' 1", color: c.fg }} + > + {c.icon} + </span> + <span className="text-[9px] font-bold tracking-wider" style={{ color: c.fg }}> + {c.label} + </span> + </span> + </div> + {dest && ( + <p className="text-[9px] font-mono text-text-muted truncate mt-0.5" title={dest}> + {dest} + </p> + )} + </div> + ); +} + +// ── Hover Controls ────────────────────────────────────────────────────────── + +function HoverControls({ torrent, state, onAction }) { + const [deleteConfirm, setDeleteConfirm] = useState(false); + const [loading, setLoading] = useState(null); + const [actionError, setActionError] = useState(null); + const errorTimerRef = useRef(null); + const confirmTimerRef = useRef(null); + + // Delete confirmation auto-expires so the card does not stay armed on hover. + const startConfirmTimer = () => { + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = setTimeout(() => setDeleteConfirm(false), 2000); + }; + + useEffect(() => () => clearTimeout(confirmTimerRef.current), []); + + const doAction = async action => { + setLoading(action); + setActionError(null); + clearTimeout(errorTimerRef.current); + try { + const method = action === 'delete' ? 'DELETE' : 'POST'; + const url = + action === 'delete' + ? `/api/qbittorrent/torrents/${torrent.hash}?deleteFiles=false` + : `/api/qbittorrent/torrents/${torrent.hash}/${action}`; + const res = await fetch(url, { method }); + if (res.ok) { + if (onAction) onAction(); + } else { + setActionError('Action failed'); + errorTimerRef.current = setTimeout(() => setActionError(null), 3000); + } + } catch (_) { + setActionError('Network error'); + errorTimerRef.current = setTimeout(() => setActionError(null), 3000); + } + setLoading(null); + }; + + const handleDelete = () => { + if (!deleteConfirm) { + setDeleteConfirm(true); + startConfirmTimer(); + } else { + clearTimeout(confirmTimerRef.current); + setDeleteConfirm(false); + doAction('delete'); + } + }; + + const btnBase = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 34, + height: 34, + borderRadius: 8, + border: 'none', + cursor: 'pointer', + backdropFilter: 'blur(8px)', + transition: 'background 0.15s, transform 0.1s', + fontSize: 18, + }; + + const iconBtn = (icon, clickHandler, bg, title, isLoading) => ( + <button + title={title} + onClick={e => { + e.stopPropagation(); + clickHandler(); + }} + style={{ + ...btnBase, + background: isLoading ? 'rgba(255,255,255,0.08)' : bg, + opacity: isLoading ? 0.5 : 1, + }} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 18, fontVariationSettings: "'FILL' 1", color: '#fff' }} + > + {isLoading ? 'hourglass_empty' : icon} + </span> + </button> + ); + + return ( + <div + style={{ + position: 'absolute', + inset: 0, + background: 'linear-gradient(to bottom, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.0) 45%)', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + padding: '10px 10px 0', + zIndex: 10, + }} + onClick={e => e.stopPropagation()} + > + <div style={{ display: 'flex', gap: 6 }}> + {state === 'downloading' && + iconBtn('pause', () => doAction('pause'), 'rgba(255,159,10,0.75)', 'Pause', loading === 'pause')} + {state === 'paused' && + iconBtn('play_arrow', () => doAction('resume'), 'rgba(48,209,88,0.75)', 'Resume', loading === 'resume')} + {(state === 'seeding' || state === 'completed') && ( + <button + title={deleteConfirm ? 'Click again to confirm' : 'Delete torrent'} + onClick={e => { + e.stopPropagation(); + handleDelete(); + }} + style={{ + ...btnBase, + background: deleteConfirm ? 'rgba(255,69,58,0.9)' : 'rgba(255,69,58,0.65)', + width: deleteConfirm ? 'auto' : 34, + padding: deleteConfirm ? '0 10px' : 0, + gap: 4, + whiteSpace: 'nowrap', + opacity: loading === 'delete' ? 0.5 : 1, + }} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 18, fontVariationSettings: "'FILL' 1", color: '#fff' }} + > + {loading === 'delete' ? 'hourglass_empty' : 'delete'} + </span> + {deleteConfirm && ( + <span style={{ fontSize: 11, fontWeight: 700, color: '#fff', letterSpacing: '0.02em' }}>Confirm?</span> + )} + </button> + )} + </div> + + <a + href={getServiceUrl('qbittorrent')} + target="_blank" + rel="noopener noreferrer" + title="Open in qBittorrent" + onClick={e => e.stopPropagation()} + style={{ + ...btnBase, + background: 'rgba(255,255,255,0.12)', + textDecoration: 'none', + }} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 18, fontVariationSettings: "'FILL' 0", color: 'rgba(255,255,255,0.85)' }} + > + open_in_new + </span> + </a> + {actionError && ( + <div + style={{ + position: 'absolute', + bottom: 8, + left: '50%', + transform: 'translateX(-50%)', + background: 'rgba(255,69,58,0.9)', + color: '#fff', + fontSize: 10, + fontWeight: 600, + padding: '3px 8px', + borderRadius: 6, + whiteSpace: 'nowrap', + pointerEvents: 'none', + }} + > + {actionError} + </div> + )} + </div> + ); +} + +// ── Download Card ─────────────────────────────────────────────────────────── + +const DownloadCard = memo(function DownloadCard({ torrent, info, sortData, onAction }) { + const [hovered, setHovered] = useState(false); + const state = getTorrentState(torrent); + const color = TORRENT_STATE_COLOR[state]; + const rating = extractRating(info?.ratings); + const runtime = formatRuntime(info?.runtime); + const isActive = state === 'downloading'; + const displayProgress = state === 'completed' ? 100 : torrent.progress; + + let statusText = STATE_LABEL[state]; + if (state === 'downloading' && torrent.downloadSpeed > 0) { + statusText = formatSpeed(torrent.downloadSpeed); + } else if (state === 'seeding' && torrent.uploadSpeed > 0) { + statusText = formatSpeed(torrent.uploadSpeed); + } + + const displayTitle = info?.title || torrent.name; + const hasInfo = info?.title; + + return ( + <div + className="card carousel-item flex-none rounded-xl overflow-hidden bg-bg-card border border-border-subtle cursor-default" + style={{ width: 260 }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + <div className="relative" style={{ height: 360 }}> + <CardPoster url={info?.posterUrl} category={torrent.category} title={info?.title} /> + + {hovered && <HoverControls torrent={torrent} state={state} onAction={onAction} />} + + <div className="poster-overlay absolute inset-x-0 bottom-0" style={{ height: '60%' }} /> + + <div className="absolute inset-x-0 bottom-0 px-3 pb-3"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-1.5"> + <span className="w-2 h-2 rounded-full flex-none" style={{ background: color }} /> + <span className="text-[11px] font-medium text-white/80 tabular-nums">{statusText}</span> + </div> + <span + className="text-[10px] font-mono text-white/50 tabular-nums" + style={{ minWidth: 56, textAlign: 'right', display: 'inline-block' }} + > + {state === 'downloading' && torrent.eta > 0 && torrent.eta < TORRENT_UNKNOWN_ETA_SECONDS + ? formatETA(torrent.eta) + : '\u00a0'} + </span> + </div> + + <div className="h-1 w-full rounded-full bg-white/10 overflow-hidden"> + <div + className="h-full rounded-full" + style={{ + width: `${Math.min(displayProgress, 100)}%`, + background: color, + transition: PROGRESS_TRANSITION, + }} + /> + </div> + <div className="flex justify-between mt-1"> + <span className="text-[11px] font-mono font-medium text-white tabular-nums">{displayProgress}%</span> + <span className="text-[10px] font-mono text-white/40 tabular-nums">{formatBytes(torrent.size)}</span> + </div> + </div> + + <div className="absolute top-2.5 right-2.5 px-2 py-0.5 rounded bg-black/50 backdrop-blur-sm"> + <span className="text-[9px] font-semibold uppercase tracking-wider" style={{ color: color }}> + {STATE_LABEL[state]} + </span> + </div> + + {state === 'seeding' && torrent.ratio != null && ( + <div className="absolute top-2.5 left-2.5 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm"> + <span className="text-[10px] font-mono text-accent-orange">{torrent.ratio.toFixed(1)}x</span> + </div> + )} + </div> + + <div className="px-3 pt-2.5 pb-3"> + <h3 className="text-[13px] font-semibold text-text-primary line-clamp-2 leading-snug"> + {displayTitle} + {info?.year && hasInfo ? ` (${info.year})` : ''} + </h3> + + {info?.episodeNumber && ( + <p className="text-[11px] text-accent-blue font-medium mt-0.5"> + {info.episodeNumber} + {info.episodeTitle ? ` · ${info.episodeTitle}` : ''} + </p> + )} + + {hasInfo && ( + <div className="flex items-center gap-1.5 mt-1 text-[10px] text-text-muted flex-wrap"> + {rating && ( + <span className="flex items-center gap-0.5 text-accent-orange"> + <span className="material-symbols-rounded" style={{ fontSize: 11, fontVariationSettings: "'FILL' 1" }}> + star + </span> + {rating} + </span> + )} + {runtime && <span>{runtime}</span>} + {info.network && <span>{info.network}</span>} + {info.quality && <span>{info.quality}</span>} + </div> + )} + + {info?.genres?.length > 0 && ( + <div className="flex flex-wrap gap-1 mt-1.5"> + {info.genres.slice(0, 3).map(g => ( + <span key={g} className="genre-pill"> + {g} + </span> + ))} + </div> + )} + + {info?.overview && ( + <p className="text-[10px] text-text-muted mt-1.5 line-clamp-2 leading-relaxed">{info.overview}</p> + )} + + <SortLine sortData={sortData} /> + + {torrent.addedOn && ( + <div className="text-[9px] text-text-muted mt-1.5 font-mono"> + Added {formatShortDate(torrent.addedOn)} + {torrent.completedOn && ` · Done ${formatShortDate(torrent.completedOn)}`} + </div> + )} + </div> + </div> + ); +}, downloadCardEqual); + +// Torrent polling recreates objects often, so memoization keys off painted fields only. +function downloadCardEqual(prev, next) { + if (prev.onAction !== next.onAction) return false; + if (prev.sortData !== next.sortData) return false; + if (prev.info !== next.info) return false; + const a = prev.torrent, + b = next.torrent; + if (a === b) return true; + return ( + a.hash === b.hash && + a.progress === b.progress && + a.downloadSpeed === b.downloadSpeed && + a.uploadSpeed === b.uploadSpeed && + a.eta === b.eta && + a.state === b.state && + a.size === b.size && + a.ratio === b.ratio && + a.completedOn === b.completedOn + ); +} + +// ── Carousel Row ──────────────────────────────────────────────────────────── + +function CarouselRow({ title, icon, count, color, children }) { + const scrollRef = useRef(null); + + const scroll = dir => { + if (!scrollRef.current) return; + scrollRef.current.scrollBy({ left: dir * TORRENT_RAIL_SCROLL_PX, behavior: 'smooth' }); + }; + + if (count === 0) return null; + + return ( + <div className="mb-8"> + <div className="flex items-center gap-2.5 mb-3 px-8"> + <span className="w-2.5 h-2.5 rounded-full" style={{ background: color }} /> + <h2 className="text-[16px] font-semibold text-text-primary">{title}</h2> + <span className="text-[13px] text-text-muted font-mono">{count}</span> + <div className="flex-1" /> + <button + onClick={() => scroll(-1)} + className="p-1 rounded-md hover:bg-bg-hover active:bg-bg-hover text-text-muted hover:text-text-primary active:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + chevron_left + </span> + </button> + <button + onClick={() => scroll(1)} + className="p-1 rounded-md hover:bg-bg-hover active:bg-bg-hover text-text-muted hover:text-text-primary active:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + chevron_right + </span> + </button> + </div> + + <div className="carousel-container"> + <div ref={scrollRef} className="carousel flex gap-4 overflow-x-auto px-8 pb-2"> + {children} + </div> + </div> + </div> + ); +} + +// ── Skeleton Card ─────────────────────────────────────────────────────────── + +function SkeletonCard() { + return ( + <div + className="carousel-item flex-none rounded-xl overflow-hidden bg-bg-card border border-border-subtle" + style={{ width: 260 }} + > + <div className="shimmer" style={{ height: 360 }} /> + <div className="px-3 pt-3 pb-3 space-y-2"> + <div className="shimmer rounded h-4 w-3/4" /> + <div className="shimmer rounded h-3 w-1/2" /> + <div className="shimmer rounded h-3 w-2/3" /> + </div> + </div> + ); +} + +// ── Grouped Download Card ──────────────────────────────────────────────────── + +const GroupedDownloadCard = memo(function GroupedDownloadCard({ group, getInfo, onAction }) { + const { torrents, info, key } = group; + const [hovered, setHovered] = useState(false); + const [ctrlBusy, setCtrlBusy] = useState(false); + + const anyDownloading = torrents.some(t => getTorrentState(t) === 'downloading'); + + const handleBulk = async action => { + setCtrlBusy(true); + await Promise.all( + torrents.map(t => fetch(`/api/qbittorrent/torrents/${t.hash}/${action}`, { method: 'POST' }).catch(() => {})), + ); + setCtrlBusy(false); + if (onAction) onAction(); + }; + + const totalSize = torrents.reduce((s, t) => s + (t.size || 0), 0); + const downloadedSize = torrents.reduce((s, t) => s + ((t.size || 0) * (t.progress || 0)) / 100, 0); + const overallProgress = totalSize > 0 ? (downloadedSize / totalSize) * 100 : 0; + const totalSpeed = torrents.reduce((s, t) => s + (t.downloadSpeed || 0), 0); + const etaCandidates = torrents.filter(t => t.eta > 0 && t.eta < TORRENT_UNKNOWN_ETA_SECONDS).map(t => t.eta); + const maxETA = etaCandidates.length > 0 ? Math.max(...etaCandidates) : null; + + const sortedItems = [...torrents].sort( + (a, b) => extractSeasonNum(a, getInfo(a.hash)) - extractSeasonNum(b, getInfo(b.hash)), + ); + + const displayTitle = info?.title || key; + + return ( + <div + className="card carousel-item flex-none rounded-xl overflow-hidden bg-bg-card border border-border-subtle cursor-default" + style={{ width: 360 }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + <div className="relative" style={{ height: 260 }}> + <CardPoster url={info?.posterUrl} category={torrents[0]?.category} title={displayTitle} /> + + {hovered && ( + <div style={{ position: 'absolute', top: 10, left: 10, zIndex: 12 }} onClick={e => e.stopPropagation()}> + <button + title={anyDownloading ? 'Pause all' : 'Resume all'} + disabled={ctrlBusy} + onClick={() => handleBulk(anyDownloading ? 'pause' : 'resume')} + style={{ + width: 34, + height: 34, + borderRadius: 8, + border: 'none', + cursor: ctrlBusy ? 'default' : 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: anyDownloading ? 'rgba(255,159,10,0.8)' : 'rgba(48,209,88,0.8)', + backdropFilter: 'blur(8px)', + opacity: ctrlBusy ? 0.5 : 1, + }} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 18, fontVariationSettings: "'FILL' 1", color: '#fff' }} + > + {ctrlBusy ? 'hourglass_empty' : anyDownloading ? 'pause' : 'play_arrow'} + </span> + </button> + </div> + )} + + <div + className="absolute inset-0" + style={{ + background: 'linear-gradient(to bottom, rgba(0,0,0,0) 20%, rgba(0,0,0,0.5) 55%, rgba(0,0,0,0.93) 100%)', + }} + /> + + <a + href={getServiceUrl('qbittorrent')} + target="_blank" + rel="noopener noreferrer" + title="Open in qBittorrent" + className="absolute top-3 right-3 flex items-center justify-center rounded-lg" + style={{ + width: 32, + height: 32, + background: 'rgba(0,0,0,0.45)', + backdropFilter: 'blur(10px)', + textDecoration: 'none', + }} + onClick={e => e.stopPropagation()} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 16, fontVariationSettings: "'FILL' 0", color: 'rgba(255,255,255,0.7)' }} + > + open_in_new + </span> + </a> + + <div + className="absolute top-3 left-3 px-2.5 py-1 rounded-lg" + style={{ background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(10px)' }} + > + <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: '#30d158' }}> + Downloading + </span> + </div> + + <div className="absolute inset-x-0 bottom-0 px-4 pb-4"> + <div className="flex items-end justify-between mb-2.5"> + <span + className="font-black text-white tabular-nums" + style={{ fontSize: 38, lineHeight: 1, letterSpacing: '-0.03em' }} + > + {overallProgress.toFixed(1)} + <span style={{ fontSize: 20, fontWeight: 700, opacity: 0.65 }}>%</span> + </span> + <div className="text-right pb-0.5"> + {totalSpeed > 0 && ( + <div className="text-[14px] font-semibold tabular-nums" style={{ color: '#30d158' }}> + ↓ {formatSpeed(totalSpeed)} + </div> + )} + <div className="text-[10px] font-mono text-white/50 tabular-nums mt-0.5"> + {formatBytes(downloadedSize)} / {formatBytes(totalSize)} + </div> + {maxETA && ( + <div className="text-[10px] font-mono text-white/35 mt-0.5">{formatETA(maxETA)} remaining</div> + )} + </div> + </div> + <div className="h-2.5 w-full rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.12)' }}> + <div + className="h-full rounded-full" + style={{ + width: `${Math.min(overallProgress, 100)}%`, + background: 'linear-gradient(90deg, #1db954, #30d158)', + transition: PROGRESS_TRANSITION, + boxShadow: '0 0 8px rgba(48,209,88,0.4)', + }} + /> + </div> + </div> + </div> + + <div className="px-4 pt-3 pb-2.5 border-b border-border-subtle"> + <h3 className="text-[15px] font-bold text-text-primary leading-tight"> + {displayTitle} + {info?.year ? ` (${info.year})` : ''} + </h3> + <p className="text-[11px] text-text-muted mt-0.5"> + {info?.network ? info.network + ' · ' : ''} + {sortedItems.length} season{sortedItems.length !== 1 ? 's' : ''} downloading + </p> + </div> + + <div className="px-4 pt-3 pb-4 space-y-3"> + {sortedItems.map(t => { + const tInfo = getInfo(t.hash); + const state = getTorrentState(t); + const color = TORRENT_STATE_COLOR[state]; + const label = extractSeasonLabel(t, tInfo); + const prog = t.progress || 0; + const speed = t.downloadSpeed || 0; + const eta = t.eta > 0 && t.eta < TORRENT_UNKNOWN_ETA_SECONDS ? t.eta : null; + + return ( + <div key={t.hash}> + <div className="flex items-center gap-2.5"> + <span + className="text-[10px] font-bold font-mono rounded-md flex-none text-center px-2 py-0.5" + style={{ background: 'rgba(48,209,88,0.15)', color: '#30d158', minWidth: 36 }} + > + {label} + </span> + <div + className="flex-1 h-1.5 rounded-full overflow-hidden" + style={{ background: 'rgba(255,255,255,0.1)' }} + > + <div + className="h-full rounded-full" + style={{ width: `${Math.min(prog, 100)}%`, background: color, transition: PROGRESS_TRANSITION }} + /> + </div> + <span + className="text-[11px] font-mono font-semibold tabular-nums flex-none text-right" + style={{ color, minWidth: 42 }} + > + {prog.toFixed(1)}% + </span> + </div> + <div className="flex items-center gap-3 mt-1" style={{ paddingLeft: 48 }}> + {speed > 0 && ( + <span className="text-[10px] font-mono font-medium tabular-nums" style={{ color: '#30d158' }}> + ↓ {formatSpeed(speed)} + </span> + )} + <span className="text-[10px] font-mono text-text-muted">{formatBytes(t.size || 0)}</span> + {eta && <span className="text-[10px] font-mono text-text-muted">{formatETA(eta)}</span>} + </div> + </div> + ); + })} + </div> + </div> + ); +}, groupedDownloadCardEqual); + +// Group cards memoize against the rendered torrent fields for each season row. +function groupedDownloadCardEqual(prev, next) { + if (prev.onAction !== next.onAction) return false; + if (prev.getInfo !== next.getInfo) return false; + if (prev.group.key !== next.group.key) return false; + const a = prev.group.torrents, + b = next.group.torrents; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i], + y = b[i]; + if ( + x.hash !== y.hash || + x.progress !== y.progress || + x.downloadSpeed !== y.downloadSpeed || + x.eta !== y.eta || + x.size !== y.size || + x.state !== y.state + ) + return false; + } + return true; +} + +export { DownloadCard, GroupedDownloadCard }; diff --git a/frontend/src/components/torrent/torrentGrouping.js b/frontend/src/components/torrent/torrentGrouping.js new file mode 100644 index 0000000..f914ee9 --- /dev/null +++ b/frontend/src/components/torrent/torrentGrouping.js @@ -0,0 +1,46 @@ +/** Groups torrents by show title for multi-season carousel cards. */ +export function extractSeriesName(name) { + const base = (name || '') + .split('/') + .pop() + .replace(/\.\w+$/, ''); + const m = base.match(/^(.+?)[.\s_-]+[Ss]\d{1,2}[Ee]\d{1,2}/); + if (m) return m[1].replace(/[._]/g, ' ').trim(); + const m2 = base.match(/^(.+?)[.\s_-]+[Ss]\d{1,2}(?:\s|$|\.|_|-|[A-Z])/); + if (m2) return m2[1].replace(/[._]/g, ' ').trim(); + const m3 = base.match(/^(.+?)[.\s_-]+[Ss]eason[.\s_-]*\d/i); + if (m3) return m3[1].replace(/[._]/g, ' ').trim(); + return base.replace(/[._]/g, ' ').trim(); +} + +export function extractSeasonLabel(t, tInfo) { + if (tInfo?.episodeNumber) { + const m = tInfo.episodeNumber.match(/[Ss](\d{1,2})/); + return m ? 'S' + parseInt(m[1]) : tInfo.episodeNumber; + } + const name = t.name || ''; + const ep = name.match(/[Ss](\d{1,2})[Ee](\d{1,2})/); + if (ep) return 'S' + parseInt(ep[1]) + 'E' + ep[2].padStart(2, '0'); + const sp = name.match(/[Ss](\d{1,2})(?:\s|$|\.|_|-|[A-Z])/); + if (sp) return 'S' + parseInt(sp[1]); + return '?'; +} + +export function extractSeasonNum(t, tInfo) { + const label = extractSeasonLabel(t, tInfo); + const m = label.match(/S(\d+)/); + return m ? parseInt(m[1]) : 999; +} + +export function groupByShow(torrents, getInfo) { + const map = new Map(); + torrents.forEach(t => { + const info = getInfo(t.hash); + const key = info?.title ?? extractSeriesName(t.name); + if (!map.has(key)) map.set(key, { key, torrents: [], info: null }); + const g = map.get(key); + g.torrents.push(t); + if (!g.info && info) g.info = info; + }); + return Array.from(map.values()); +} diff --git a/frontend/src/constants.js b/frontend/src/constants.js index fae627c..c159d62 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -1,12 +1,12 @@ // ─── Z-Index Layers ──────────────────────────────────────────────────────── /** Stacking order for overlapping UI surfaces. Keep modals above panels above nav. */ export const Z = { - DROPDOWN: 10, + DROPDOWN: 10, MODAL_BACKDROP: 100, - SIDE_BACKDROP: 149, - SIDE_PANEL: 150, - BOTTOM_NAV: 200, - MODAL: 300, + SIDE_BACKDROP: 149, + SIDE_PANEL: 150, + BOTTOM_NAV: 200, + MODAL: 300, }; // ─── Breakpoints ─────────────────────────────────────────────────────────── @@ -19,80 +19,114 @@ export const BP = { /** Color tokens — mirrors CSS variables and tailwind theme.extend.colors. */ export const COLOR = { // Text - TEXT_PRIMARY: "rgba(235,235,245,0.92)", - TEXT_SECONDARY: "rgba(235,235,245,0.80)", - TEXT_MUTED: "rgba(235,235,245,0.50)", - TEXT_FAINT: "rgba(235,235,245,0.30)", - TEXT_DISABLED: "rgba(235,235,245,0.40)", + TEXT_PRIMARY: 'rgba(235,235,245,0.92)', + TEXT_SECONDARY: 'rgba(235,235,245,0.80)', + TEXT_MUTED: 'rgba(235,235,245,0.50)', + TEXT_FAINT: 'rgba(235,235,245,0.30)', + TEXT_DISABLED: 'rgba(235,235,245,0.40)', // Accents - ACCENT_RED: "#FF375F", - ACCENT_GREEN: "#30d158", - ACCENT_ORANGE: "#FF9F0A", - ACCENT_BLUE: "#0a84ff", + ACCENT_RED: '#FF375F', + ACCENT_GREEN: '#30d158', + ACCENT_ORANGE: '#FF9F0A', + ACCENT_BLUE: '#0a84ff', // Surfaces - SURFACE: "rgba(28,28,30,1)", - SURFACE_HOVER: "rgba(44,44,46,1)", - BG_BASE: "#000000", - BG_NAV: "rgba(10,10,12,0.96)", + SURFACE: 'rgba(28,28,30,1)', + SURFACE_HOVER: 'rgba(44,44,46,1)', + BG_BASE: '#000000', + BG_NAV: 'rgba(10,10,12,0.96)', // Borders - BORDER_SUBTLE: "rgba(255,255,255,0.08)", - BORDER_MEDIUM: "rgba(255,255,255,0.12)", + BORDER_SUBTLE: 'rgba(255,255,255,0.08)', + BORDER_MEDIUM: 'rgba(255,255,255,0.12)', }; /** Spacing scale (px). Aligns with tailwind 4px base — use for inline styles only. */ export const SPACING = { - XS: 4, - SM: 8, - MD: 12, - LG: 16, - XL: 24, + XS: 4, + SM: 8, + MD: 12, + LG: 16, + XL: 24, XXL: 32, }; /** Border-radius scale (px). Mirrors tailwind theme.extend.borderRadius. */ export const RADIUS = { - SM: 4, - MD: 6, - LG: 10, - XL: 14, + SM: 4, + MD: 6, + LG: 10, + XL: 14, XXL: 20, }; /** Easing curves. Mirrors tailwind transitionTimingFunction tokens. */ export const EASING = { - SPRING: "cubic-bezier(0.22, 0.61, 0.36, 1)", - SNAPPY: "cubic-bezier(0.4, 0, 0.2, 1)", + SPRING: 'cubic-bezier(0.22, 0.61, 0.36, 1)', + SNAPPY: 'cubic-bezier(0.4, 0, 0.2, 1)', +}; + +export const TIMING = { + PROGRESS_TRANSITION_MS: 800, + ACTIVITY_RECENT_MS: 10 * 60 * 1000, +}; + +export const TORRENT_UNKNOWN_ETA_SECONDS = 8_640_000; +export const TORRENT_RAIL_SCROLL_PX = 560; + +export const QUALITY_ORDER = { + '4K': 0, + '1080p': 1, + '720p': 2, + '480p': 3, + HDTV: 4, + WEB: 5, + BluRay: 6, + CAM: 7, + Other: 8, +}; + +export const TORRENT_STATE_COLOR = { + downloading: '#30d158', + seeding: '#ff9f0a', + paused: '#636366', + completed: '#0a84ff', + error: '#ff453a', +}; + +export const SERVICE_COLOR = { + sonarr: '#3498db', + radarr: '#e8b34b', + lidarr: '#1db954', }; // ─── Service config (colors + LAN URLs) ─────────────────────────────────── // Derive the LAN host from the browser location so service links work on any server -const LAN_HOST = typeof window !== "undefined" ? window.location.hostname : "localhost"; +const LAN_HOST = typeof window !== 'undefined' ? window.location.hostname : 'localhost'; /** Per-service gradients, LAN URLs, and short labels for the dashboard tiles. */ export const SERVICES = { - sonarr: { gradient: ["#3498db", "#1a5f8a"], url: `http://${LAN_HOST}:8989`, label: "TV" }, - radarr: { gradient: ["#e8b34b", "#a07820"], url: `http://${LAN_HOST}:7878`, label: "Movie" }, - lidarr: { gradient: ["#9b59b6", "#5d2887"], url: `http://${LAN_HOST}:8686`, label: "Music" }, - qbittorrent: { gradient: ["#27ae60", "#145a32"], url: `http://${LAN_HOST}:8080`, label: null }, - jellyfin: { gradient: ["#00b4d8", "#0077b6"], url: `http://${LAN_HOST}:8096`, label: null }, - navidrome: { gradient: ["#e84393", "#a01a5f"], url: `http://${LAN_HOST}:4533`, label: null }, - prowlarr: { gradient: ["#e74c3c", "#7b1a1a"], url: `http://${LAN_HOST}:9696`, label: null }, - bazarr: { gradient: ["#f39c12", "#8b6914"], url: `http://${LAN_HOST}:6767`, label: null }, - slskd: { gradient: ["#8e44ad", "#5b2870"], url: `http://${LAN_HOST}:5030`, label: null }, - obsidian: { gradient: ["#7c3aed", "#4c1d95"], url: null, label: null }, - flaresolverr:{ gradient: ["#ef4444", "#991b1b"], url: null, label: null }, - couchdb: { gradient: ["#d97706", "#92400e"], url: null, label: null }, + sonarr: { gradient: ['#3498db', '#1a5f8a'], url: `http://${LAN_HOST}:8989`, label: 'TV' }, + radarr: { gradient: ['#e8b34b', '#a07820'], url: `http://${LAN_HOST}:7878`, label: 'Movie' }, + lidarr: { gradient: ['#9b59b6', '#5d2887'], url: `http://${LAN_HOST}:8686`, label: 'Music' }, + qbittorrent: { gradient: ['#27ae60', '#145a32'], url: `http://${LAN_HOST}:8080`, label: null }, + jellyfin: { gradient: ['#00b4d8', '#0077b6'], url: `http://${LAN_HOST}:8096`, label: null }, + navidrome: { gradient: ['#e84393', '#a01a5f'], url: `http://${LAN_HOST}:4533`, label: null }, + prowlarr: { gradient: ['#e74c3c', '#7b1a1a'], url: `http://${LAN_HOST}:9696`, label: null }, + bazarr: { gradient: ['#f39c12', '#8b6914'], url: `http://${LAN_HOST}:6767`, label: null }, + slskd: { gradient: ['#8e44ad', '#5b2870'], url: `http://${LAN_HOST}:5030`, label: null }, + obsidian: { gradient: ['#7c3aed', '#4c1d95'], url: null, label: null }, + flaresolverr: { gradient: ['#ef4444', '#991b1b'], url: null, label: null }, + couchdb: { gradient: ['#d97706', '#92400e'], url: null, label: null }, }; /** Fallback gradient for unknown services. */ -export const SERVICE_DEFAULT_GRADIENT = ["#636e72", "#2d3436"]; +export const SERVICE_DEFAULT_GRADIENT = ['#636e72', '#2d3436']; /** Returns [color1, color2] gradient for a service name string. */ export function getServiceGradient(name) { - const key = (name || "").toLowerCase().replace(/[^a-z]/g, ""); + const key = (name || '').toLowerCase().replace(/[^a-z]/g, ''); for (const [svc, cfg] of Object.entries(SERVICES)) { if (key.includes(svc)) return cfg.gradient; } @@ -101,7 +135,7 @@ export function getServiceGradient(name) { /** Returns the LAN URL for a service name string, or null. */ export function getServiceUrl(name) { - const key = (name || "").toLowerCase().replace(/[^a-z]/g, ""); + const key = (name || '').toLowerCase().replace(/[^a-z]/g, ''); for (const [svc, cfg] of Object.entries(SERVICES)) { if (key.includes(svc) && cfg.url) return cfg.url; } diff --git a/frontend/src/hooks/useAppData.js b/frontend/src/hooks/useAppData.js new file mode 100644 index 0000000..4c17869 --- /dev/null +++ b/frontend/src/hooks/useAppData.js @@ -0,0 +1,173 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { apiFetch } from '../api'; + +/** + * Core dashboard data: containers, torrents, pipeline, arr queue, slskd, media info. + * Owns the 5s refresh loop plus slower tailscale/media-info polls. + */ +export function useAppData() { + const [containers, setContainers] = useState([]); + const [torrents, setTorrents] = useState([]); + const [torrentError, setTorrentError] = useState(null); + const [slskdDownloads, setSlskdDownloads] = useState([]); + const [pendingSearches, setPendingSearches] = useState([]); + const [pipeline, setPipeline] = useState([]); + const [arrQueue, setArrQueue] = useState([]); + const [loading, setLoading] = useState(true); + const [tailscaleIp, setTailscaleIp] = useState(null); + const [mediaInfo, setMediaInfo] = useState({}); + + const torrentsRef = useRef([]); + const prevPipelineRef = useRef({}); + + const fetchContainers = useCallback(async () => { + try { + setContainers(await apiFetch('/api/containers')); + } catch (e) { + console.error('[fetchContainers]', e); + } + }, []); + + const fetchTorrents = useCallback(async () => { + try { + const data = await apiFetch('/api/qbittorrent/status'); + const parsed = (data.torrents || []).map(t => ({ ...t, progress: parseFloat(t.progress) || 0 })); + setTorrents(parsed); + torrentsRef.current = parsed; + setTorrentError(null); + } catch (e) { + console.error('[fetchTorrents]', e); + setTorrentError(e.message); + } + }, []); + + const fetchSlskd = useCallback(async () => { + try { + setSlskdDownloads(await apiFetch('/api/slskd/downloads')); + } catch (e) { + console.error('[fetchSlskd]', e); + } + }, []); + + const fetchPendingSearches = useCallback(async () => { + try { + const pending = await apiFetch('/api/pending-searches'); + setPendingSearches(pending); + const items = await apiFetch('/api/pipeline'); + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + const prev = prevPipelineRef.current; + for (const item of items) { + const prevStage = prev[item.key]; + const isNowComplete = item.stage === 'complete'; + const wasNotComplete = !prevStage || prevStage !== 'complete'; + if (isNowComplete && wasNotComplete && prevStage !== undefined) { + new Notification(`Download complete: ${item.title}`, { + body: item.subtitle ? `${item.subtitle} — Successfully imported` : 'Successfully imported', + icon: '/favicon.ico', + }); + } + } + } + const newMap = {}; + for (const item of items) newMap[item.key] = item.stage; + prevPipelineRef.current = newMap; + setPipeline(items); + } catch (e) { + console.error('[fetchPendingSearches]', e); + } + }, []); + + const fetchArrQueue = useCallback(async () => { + try { + setArrQueue(await apiFetch('/api/arr-queue')); + } catch (e) { + console.error('[fetchArrQueue]', e); + } + }, []); + + const fetchMediaInfo = useCallback(torrentList => { + try { + const hashes = (torrentList || []).map(t => t.hash).filter(Boolean); + if (hashes.length === 0) return; + apiFetch(`/api/media-info/batch?hashes=${hashes.join(',')}`) + .then(data => setMediaInfo(prev => ({ ...prev, ...data }))) + .catch(e => console.error('[fetchMediaInfo]', e)); + } catch (e) { + console.error('[fetchMediaInfo]', e); + } + }, []); + + const fetchTailscaleIp = useCallback(async () => { + try { + const data = await apiFetch('/api/tailscale-ip'); + setTailscaleIp(data.ip); + } catch (e) { + console.error('[fetchTailscaleIp]', e); + } + }, []); + + useEffect(() => { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission().catch(() => {}); + } + + const init = async () => { + setLoading(true); + await Promise.all([ + fetchContainers(), + fetchTorrents(), + fetchTailscaleIp(), + fetchSlskd(), + fetchPendingSearches(), + fetchArrQueue(), + ]); + setLoading(false); + fetchMediaInfo(torrentsRef.current); + }; + const refresh = async () => { + await Promise.all([fetchContainers(), fetchTorrents(), fetchSlskd(), fetchPendingSearches(), fetchArrQueue()]); + }; + init(); + const t1 = setInterval(refresh, 5000); + const t2 = setInterval(() => fetchTailscaleIp(), 60000); + const t3 = setInterval(() => fetchMediaInfo(torrentsRef.current), 30000); + return () => { + clearInterval(t1); + clearInterval(t2); + clearInterval(t3); + }; + }, [ + fetchContainers, + fetchTorrents, + fetchTailscaleIp, + fetchSlskd, + fetchPendingSearches, + fetchArrQueue, + fetchMediaInfo, + ]); + + const torrentHashKey = torrents + .map(t => t.hash) + .sort() + .join(','); + useEffect(() => { + const missing = torrents.filter(t => t.hash && !mediaInfo[t.hash]); + if (missing.length > 0) fetchMediaInfo(missing); + }, [torrentHashKey, mediaInfo, fetchMediaInfo]); + + return { + containers, + torrents, + torrentError, + slskdDownloads, + pendingSearches, + pipeline, + arrQueue, + loading, + tailscaleIp, + mediaInfo, + fetchSlskd, + fetchTorrents, + fetchPendingSearches, + }; +} diff --git a/frontend/src/hooks/useBandwidth.js b/frontend/src/hooks/useBandwidth.js new file mode 100644 index 0000000..4b363f4 --- /dev/null +++ b/frontend/src/hooks/useBandwidth.js @@ -0,0 +1,30 @@ +import { useState, useEffect } from 'react'; +import { apiFetch } from '../api'; + +/** Polls /api/bandwidth every 3s for sparkline + session/lifetime totals. */ +export function useBandwidth() { + const [bwHistory, setBwHistory] = useState([]); + const [bwTotals, setBwTotals] = useState({ dl: 0, ul: 0 }); + const [bwLifetime, setBwLifetime] = useState({ dl: 0, ul: 0 }); + + useEffect(() => { + const poll = async () => { + try { + const { dlSpeed, ulSpeed, dlTotal, ulTotal, lifetimeDl, lifetimeUl } = await apiFetch('/api/bandwidth'); + setBwHistory(prev => { + const next = [...prev, { dl: dlSpeed, ul: ulSpeed }]; + return next.length > 60 ? next.slice(-60) : next; + }); + setBwTotals({ dl: dlTotal, ul: ulTotal }); + setBwLifetime({ dl: lifetimeDl || 0, ul: lifetimeUl || 0 }); + } catch (e) { + console.error('[fetchBandwidth]', e); + } + }; + poll(); + const t = setInterval(poll, 3000); + return () => clearInterval(t); + }, []); + + return { bwHistory, bwTotals, bwLifetime }; +} diff --git a/frontend/src/hooks/useLayoutPrefs.js b/frontend/src/hooks/useLayoutPrefs.js new file mode 100644 index 0000000..05b95cb --- /dev/null +++ b/frontend/src/hooks/useLayoutPrefs.js @@ -0,0 +1,90 @@ +import { useState, useEffect, useRef } from 'react'; +import { BP } from '../constants'; + +/** Theme, sidebar, mobile viewport, and header search state. */ +export function useLayoutPrefs() { + const [activeView, setActiveView] = useState('library'); + const [headerQuery, setHeaderQuery] = useState(''); + const [isMobile, setIsMobile] = useState(() => window.innerWidth < BP.MOBILE); + const [sidebarOpen, setSidebarOpen] = useState(() => { + if (window.innerWidth < BP.MOBILE) return false; + const saved = localStorage.getItem('sidebarOpen'); + if (saved === 'true') return true; + if (saved === 'false') return false; + return true; + }); + const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const [mobileSearchValue, setMobileSearchValue] = useState(''); + const [lightMode, setLightMode] = useState(() => { + const saved = localStorage.getItem('theme') === 'light'; + document.documentElement.setAttribute('data-theme', saved ? 'light' : 'dark'); + return saved; + }); + const mobileSearchDebounce = useRef(null); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', lightMode ? 'light' : 'dark'); + localStorage.setItem('theme', lightMode ? 'light' : 'dark'); + }, [lightMode]); + + useEffect(() => { + if (!isMobile) localStorage.setItem('sidebarOpen', sidebarOpen ? 'true' : 'false'); + }, [sidebarOpen, isMobile]); + + useEffect(() => { + let resizeTimer; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const mobile = window.innerWidth < BP.MOBILE; + setIsMobile(mobile); + setSidebarOpen(prev => (mobile ? false : prev)); + }, 100); + }; + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + clearTimeout(resizeTimer); + }; + }, []); + + const handleMobileSearchChange = val => { + setMobileSearchValue(val); + setActiveView('library'); + clearTimeout(mobileSearchDebounce.current); + mobileSearchDebounce.current = setTimeout(() => setHeaderQuery(val), 200); + }; + + const toggleMobileSearch = () => { + setMobileSearchOpen(o => { + if (o) { + setMobileSearchValue(''); + setHeaderQuery(''); + } + return !o; + }); + }; + + const clearMobileSearch = () => { + setMobileSearchOpen(false); + setMobileSearchValue(''); + setHeaderQuery(''); + }; + + return { + activeView, + setActiveView, + headerQuery, + setHeaderQuery, + isMobile, + sidebarOpen, + setSidebarOpen, + mobileSearchOpen, + mobileSearchValue, + lightMode, + setLightMode, + handleMobileSearchChange, + toggleMobileSearch, + clearMobileSearch, + }; +} diff --git a/frontend/src/hooks/useManualSearch.js b/frontend/src/hooks/useManualSearch.js new file mode 100644 index 0000000..a33ee73 --- /dev/null +++ b/frontend/src/hooks/useManualSearch.js @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; +import { apiFetch } from '../api'; + +/** Fetches manual/fast search results when a pipeline item opens the grab modal. */ +export function useManualSearch(manualSearchTarget) { + const [manualSearchResults, setManualSearchResults] = useState([]); + const [manualSearchLoading, setManualSearchLoading] = useState(false); + + useEffect(() => { + if (!manualSearchTarget) { + setManualSearchResults([]); + return; + } + setManualSearchLoading(true); + const controller = new AbortController(); + const { service, title, retryId, seriesId, movieId, seasonNumbers } = manualSearchTarget; + const sn = seasonNumbers?.length === 1 ? seasonNumbers[0] : null; + const id = service === 'sonarr' ? seriesId || retryId : movieId || retryId; + let url; + if (id) { + url = `/api/manual-search?service=${service}&id=${id}${sn ? `&seasonNumber=${sn}` : ''}`; + } else if (title) { + let q = title; + if (sn && service === 'sonarr') q += ` S${String(sn).padStart(2, '0')}`; + url = `/api/fast-search?query=${encodeURIComponent(q)}&service=${service}`; + } + apiFetch(url, { signal: controller.signal }) + .then(results => { + setManualSearchResults(Array.isArray(results) ? results : []); + setManualSearchLoading(false); + }) + .catch(e => { + if (e.name !== 'AbortError') setManualSearchLoading(false); + }); + return () => controller.abort(); + }, [manualSearchTarget]); + + return { manualSearchResults, manualSearchLoading }; +} diff --git a/frontend/src/hooks/useMobilePanelFocus.js b/frontend/src/hooks/useMobilePanelFocus.js new file mode 100644 index 0000000..b62d5b9 --- /dev/null +++ b/frontend/src/hooks/useMobilePanelFocus.js @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; + +/** Mobile overlay focus trap + Escape to close. */ +export function useMobilePanelFocus(isMobile, isOpen, onClose, panelRef) { + const prevFocusRef = useRef(null); + + useEffect(() => { + if (!isMobile) return; + if (isOpen) { + prevFocusRef.current = document.activeElement; + const node = panelRef.current; + if (node) { + const focusable = node.querySelector('button, [href], input, [tabindex]:not([tabindex="-1"])'); + (focusable || node).focus?.(); + } + const onKey = e => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose?.(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + } else if (prevFocusRef.current) { + try { + prevFocusRef.current.focus?.(); + } catch {} + prevFocusRef.current = null; + } + }, [isMobile, isOpen, onClose, panelRef]); +} diff --git a/frontend/src/hooks/useSidePanelData.js b/frontend/src/hooks/useSidePanelData.js new file mode 100644 index 0000000..c7f7e89 --- /dev/null +++ b/frontend/src/hooks/useSidePanelData.js @@ -0,0 +1,69 @@ +import { useState, useEffect, useRef } from 'react'; +import { apiFetch } from '../api'; + +/** Polls activity log and storage for the side panel. */ +export function useSidePanelData() { + const [activity, setActivity] = useState([]); + const [storage, setStorage] = useState(null); + const prevActivityIdRef = useRef(null); + + useEffect(() => { + const fetchActivity = async () => { + try { + const items = await apiFetch('/api/activity-log?limit=20'); + if (items.length > 0 && typeof Notification !== 'undefined' && Notification.permission === 'granted') { + const newestId = items[0].id; + if (prevActivityIdRef.current !== null && newestId !== prevActivityIdRef.current) { + const newItem = items[0]; + const msg = (newItem.message || '').toLowerCase(); + const isImport = msg.includes('import') || msg.includes('added to library') || newItem.status === 'success'; + if (isImport) { + const title = newItem.context?.title || newItem.context?.artistName || 'Media imported'; + new Notification(`Imported: ${title}`, { + body: newItem.message || 'New activity', + icon: '/favicon.ico', + }); + } + } + if (prevActivityIdRef.current === null || items[0].id !== prevActivityIdRef.current) { + prevActivityIdRef.current = items[0].id; + } + } else if (items.length > 0 && prevActivityIdRef.current === null) { + prevActivityIdRef.current = items[0].id; + } + setActivity(items); + } catch {} + }; + + const fetchStorage = async () => { + try { + setStorage(await apiFetch('/api/storage')); + } catch {} + }; + + fetchActivity(); + fetchStorage(); + const t1 = setInterval(fetchActivity, 15000); + const t2 = setInterval(fetchStorage, 60000); + return () => { + clearInterval(t1); + clearInterval(t2); + }; + }, []); + + const dismissActivity = async id => { + try { + await apiFetch(`/api/activity-log/${id}`, { method: 'DELETE' }); + setActivity(prev => prev.filter(a => a.id !== id)); + } catch {} + }; + + const clearActivity = async () => { + try { + await apiFetch('/api/activity-log', { method: 'DELETE' }); + setActivity([]); + } catch {} + }; + + return { activity, storage, dismissActivity, clearActivity }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 751400b..ad831ca 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -14,81 +14,81 @@ /* ── Theme Variables ── */ :root { --bg-base: #000000; - --bg-nav: rgba(10,10,12,0.96); - --bg-header: rgba(0,0,0,0.85); - --bg-service-strip: rgba(28,28,30,0.6); - --bg-mobile-search: rgba(0,0,0,0.92); - --surface: rgba(28,28,30,1); - --surface-hover: rgba(44,44,46,1); - --surface-elevated: rgba(30,30,32,0.98); - --surface-subtle: rgba(255,255,255,0.04); - --progress-bar: rgba(58,58,60,1); - --border-subtle: rgba(255,255,255,0.08); - --border-medium: rgba(255,255,255,0.12); - --text-primary: rgba(235,235,245,0.92); - --text-secondary: rgba(235,235,245,0.80); - --text-muted: rgba(235,235,245,0.50); - --text-faint: rgba(235,235,245,0.30); - --text-disabled: rgba(235,235,245,0.40); - --scrollbar-thumb: rgba(255,255,255,0.15); - --scrollbar-thumb-hover: rgba(255,255,255,0.25); + --bg-nav: rgba(10, 10, 12, 0.96); + --bg-header: rgba(0, 0, 0, 0.85); + --bg-service-strip: rgba(28, 28, 30, 0.6); + --bg-mobile-search: rgba(0, 0, 0, 0.92); + --surface: rgba(28, 28, 30, 1); + --surface-hover: rgba(44, 44, 46, 1); + --surface-elevated: rgba(30, 30, 32, 0.98); + --surface-subtle: rgba(255, 255, 255, 0.04); + --progress-bar: rgba(58, 58, 60, 1); + --border-subtle: rgba(255, 255, 255, 0.08); + --border-medium: rgba(255, 255, 255, 0.12); + --text-primary: rgba(235, 235, 245, 0.92); + --text-secondary: rgba(235, 235, 245, 0.8); + --text-muted: rgba(235, 235, 245, 0.5); + --text-faint: rgba(235, 235, 245, 0.3); + --text-disabled: rgba(235, 235, 245, 0.4); + --scrollbar-thumb: rgba(255, 255, 255, 0.15); + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.25); --shimmer-base: #2c2c2e; --shimmer-highlight: #3a3a3c; --carousel-fade: #000000; - --rail-hover-bg: rgba(28,28,30,1); - --rail-hover-color: rgba(235,235,245,0.80); - --pill-inactive-color: rgba(235,235,245,0.50); - --pill-inactive-border: rgba(255,255,255,0.08); - --pill-hover-bg: rgba(28,28,30,1); - --pill-hover-color: rgba(235,235,245,0.80); + --rail-hover-bg: rgba(28, 28, 30, 1); + --rail-hover-color: rgba(235, 235, 245, 0.8); + --pill-inactive-color: rgba(235, 235, 245, 0.5); + --pill-inactive-border: rgba(255, 255, 255, 0.08); + --pill-hover-bg: rgba(28, 28, 30, 1); + --pill-hover-color: rgba(235, 235, 245, 0.8); --chip-hover-bg: #3a3a3c; --chip-hover-border: #48484a; --genre-pill-color: #aeaeb2; --genre-pill-bg: #2c2c2e; --genre-pill-border: #38383a; - --text-muted: rgba(235,235,245,0.62); - --shadow-sm: 0 1px 2px rgba(0,0,0,0.35); - --shadow-md: 0 6px 18px rgba(0,0,0,0.45); - --shadow-lg: 0 18px 48px rgba(0,0,0,0.65); + --text-muted: rgba(235, 235, 245, 0.62); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 6px 18px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.65); } -[data-theme="light"] { +[data-theme='light'] { --bg-base: #f2f2f7; - --bg-nav: rgba(248,248,252,0.96); - --bg-header: rgba(245,245,250,0.92); - --bg-service-strip: rgba(255,255,255,0.80); - --bg-mobile-search: rgba(248,248,252,0.98); - --surface: rgba(255,255,255,1); - --surface-hover: rgba(242,242,247,1); - --surface-elevated: rgba(255,255,255,0.99); - --surface-subtle: rgba(0,0,0,0.03); - --progress-bar: rgba(200,200,210,1); - --border-subtle: rgba(0,0,0,0.08); - --border-medium: rgba(0,0,0,0.12); - --text-primary: rgba(0,0,0,0.85); - --text-secondary: rgba(0,0,0,0.65); - --text-muted: rgba(0,0,0,0.45); - --text-faint: rgba(0,0,0,0.25); - --text-disabled: rgba(0,0,0,0.35); - --scrollbar-thumb: rgba(0,0,0,0.18); - --scrollbar-thumb-hover: rgba(0,0,0,0.28); + --bg-nav: rgba(248, 248, 252, 0.96); + --bg-header: rgba(245, 245, 250, 0.92); + --bg-service-strip: rgba(255, 255, 255, 0.8); + --bg-mobile-search: rgba(248, 248, 252, 0.98); + --surface: rgba(255, 255, 255, 1); + --surface-hover: rgba(242, 242, 247, 1); + --surface-elevated: rgba(255, 255, 255, 0.99); + --surface-subtle: rgba(0, 0, 0, 0.03); + --progress-bar: rgba(200, 200, 210, 1); + --border-subtle: rgba(0, 0, 0, 0.08); + --border-medium: rgba(0, 0, 0, 0.12); + --text-primary: rgba(0, 0, 0, 0.85); + --text-secondary: rgba(0, 0, 0, 0.65); + --text-muted: rgba(0, 0, 0, 0.45); + --text-faint: rgba(0, 0, 0, 0.25); + --text-disabled: rgba(0, 0, 0, 0.35); + --scrollbar-thumb: rgba(0, 0, 0, 0.18); + --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); --shimmer-base: #e5e5ea; --shimmer-highlight: #d1d1d6; --carousel-fade: #f2f2f7; - --rail-hover-bg: rgba(230,230,235,1); - --rail-hover-color: rgba(0,0,0,0.70); - --pill-inactive-color: rgba(0,0,0,0.45); - --pill-inactive-border: rgba(0,0,0,0.10); - --pill-hover-bg: rgba(242,242,247,1); - --pill-hover-color: rgba(0,0,0,0.70); + --rail-hover-bg: rgba(230, 230, 235, 1); + --rail-hover-color: rgba(0, 0, 0, 0.7); + --pill-inactive-color: rgba(0, 0, 0, 0.45); + --pill-inactive-border: rgba(0, 0, 0, 0.1); + --pill-hover-bg: rgba(242, 242, 247, 1); + --pill-hover-color: rgba(0, 0, 0, 0.7); --chip-hover-bg: #e5e5ea; --chip-hover-border: #c7c7cc; --genre-pill-color: #636366; --genre-pill-bg: #e5e5ea; --genre-pill-border: #c7c7cc; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); - --shadow-md: 0 6px 20px rgba(0,0,0,0.07); - --shadow-lg: 0 18px 48px rgba(0,0,0,0.12); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.07); + --shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.12); } * { @@ -97,32 +97,48 @@ box-sizing: border-box; } -html { scroll-behavior: smooth; } +html { + scroll-behavior: smooth; +} body { -webkit-tap-highlight-color: transparent; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; min-height: 100dvh; - font-family: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; - font-feature-settings: "cv11", "ss01", "ss03"; + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'SF Pro Text', + system-ui, + sans-serif; + font-feature-settings: 'cv11', 'ss01', 'ss03'; background-color: var(--bg-base); - background-image: radial-gradient(ellipse at top, rgba(255,255,255,0.025), transparent 60%); + background-image: radial-gradient(ellipse at top, rgba(255, 255, 255, 0.025), transparent 60%); } -[data-theme="light"] body { +[data-theme='light'] body { background-image: none; } .tabular-nums, -.speed, .size, .count, .bandwidth, .eta, -[class*="speed"], [class*="size-"], [class*="count-"] { +.speed, +.size, +.count, +.bandwidth, +.eta, +[class*='speed'], +[class*='size-'], +[class*='count-'] { font-variant-numeric: tabular-nums; - font-feature-settings: "tnum", "cv11"; + font-feature-settings: 'tnum', 'cv11'; } @media (prefers-reduced-motion: reduce) { - *, ::before, ::after { + *, + ::before, + ::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; diff --git a/frontend/src/library/LibraryView.jsx b/frontend/src/library/LibraryView.jsx new file mode 100644 index 0000000..362cdb3 --- /dev/null +++ b/frontend/src/library/LibraryView.jsx @@ -0,0 +1,97 @@ +import { useLibraryState } from './hooks/useLibraryState'; +import LibraryHeader from './components/LibraryHeader'; +import LibraryGrid from './components/LibraryGrid'; +import AddResults from './components/AddResults'; +import SeriesDetail from './components/SeriesDetail'; +import ArtistDetail from './components/ArtistDetail'; +import MovieDownloadPanel from './components/MovieDownloadPanel'; +import AddPanel from './components/AddPanel'; + +export default function LibraryView({ externalQuery, onExternalQueryChange }) { + const state = useLibraryState(externalQuery, onExternalQueryChange); + + return ( + <div className="flex-1 overflow-y-auto scroll-area"> + <LibraryHeader + mode={state.mode} + setMode={state.setMode} + query={state.query} + addType={state.addType} + setAddType={state.setAddType} + activeType={state.activeType} + setActiveType={state.setActiveType} + inputRef={state.inputRef} + handleQueryChange={state.handleQueryChange} + clearQuery={state.clearQuery} + handleRefresh={state.handleRefresh} + refreshing={state.refreshing} + switchToAddMode={state.switchToAddMode} + initialLoaded={state.initialLoaded} + loading={state.loading} + isMissingFilter={state.isMissingFilter} + totalLibrary={state.totalLibrary} + /> + + {state.mode === 'library' && ( + <LibraryGrid + activeType={state.activeType} + initialLoaded={state.initialLoaded} + loading={state.loading} + isMissingFilter={state.isMissingFilter} + query={state.query} + visibleSeries={state.visibleSeries} + visibleMovies={state.visibleMovies} + visibleArtists={state.visibleArtists} + totalLibrary={state.totalLibrary} + queuedSeriesIds={state.queuedSeriesIds} + queuedMovieIds={state.queuedMovieIds} + onSeriesClick={id => state.setDetailView({ type: 'series', id })} + onMovieClick={movie => state.setDetailView({ type: 'movie', data: movie })} + onArtistClick={id => state.setDetailView({ type: 'artist', id })} + onSwitchToAdd={state.switchToAddMode} + /> + )} + + {state.mode === 'add' && ( + <AddResults + loading={state.loading} + query={state.query} + addType={state.addType} + lookupResults={state.lookupResults} + musicSections={state.musicSections} + onSelectItem={state.setAddPanel} + /> + )} + + {state.detailView?.type === 'series' && ( + <SeriesDetail + seriesId={state.detailView.id} + onClose={() => state.setDetailView(null)} + onDelete={state.refreshAfterDelete} + /> + )} + {state.detailView?.type === 'artist' && ( + <ArtistDetail + artistId={state.detailView.id} + onClose={() => state.setDetailView(null)} + onDelete={state.refreshAfterDelete} + /> + )} + {state.detailView?.type === 'movie' && ( + <MovieDownloadPanel + movie={state.detailView.data} + onClose={() => state.setDetailView(null)} + onDelete={state.refreshAfterDelete} + /> + )} + {state.addPanel && ( + <AddPanel + item={state.addPanel} + mediaType={state.addType} + onClose={() => state.setAddPanel(null)} + onAdded={state.refreshAfterAdd} + /> + )} + </div> + ); +} diff --git a/frontend/src/library/components/AddPanel.jsx b/frontend/src/library/components/AddPanel.jsx new file mode 100644 index 0000000..d920439 --- /dev/null +++ b/frontend/src/library/components/AddPanel.jsx @@ -0,0 +1,497 @@ +import { useState, useEffect } from 'react'; +import { formatBytes, extractRating } from '../../utils'; +import { useAnimatedClose } from '../hooks/useAnimatedClose'; +import PosterImg from './PosterImg'; +import Select from './Select'; +import SeasonSelector from './SeasonSelector'; +import ManualSearchView from './ManualSearchView'; + +export default function AddPanel({ item, mediaType, onClose, onAdded }) { + const { closing, close } = useAnimatedClose(onClose); + const [profiles, setProfiles] = useState(null); + const [loading, setLoading] = useState(true); + const [adding, setAdding] = useState(false); + const [result, setResult] = useState(null); + const [config, setConfig] = useState({}); + const [selectedSeasons, setSelectedSeasons] = useState([]); + const [lookupAlbums, setLookupAlbums] = useState(null); + const [albumsLoading, setAlbumsLoading] = useState(false); + const [selectedAlbumIds, setSelectedAlbumIds] = useState([]); + const [albumFilter, setAlbumFilter] = useState(''); + // Manual add is a two-step flow: create the arr record first, then browse releases against that new id. + const [manualStep, setManualStep] = useState(null); // null | 'adding' | 'browse' + const [addedId, setAddedId] = useState(null); + + useEffect(() => { + fetch(`/api/profiles/${mediaType}`, { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : null)) + .then(data => { + setProfiles(data); + if (data) { + const defaults = {}; + if (data.qualityProfiles?.length) { + // Prefer HD-1080p over 'Any' to avoid accidentally grabbing huge remux files + const preferred = data.qualityProfiles.find(p => p.name === 'HD-1080p' || p.name === 'HD - 720p/1080p'); + defaults.qualityProfileId = (preferred || data.qualityProfiles[0]).id; + } + if (data.rootFolders?.length) defaults.rootFolderPath = data.rootFolders[0].path; + if (data.metadataProfiles?.length) defaults.metadataProfileId = data.metadataProfiles[0].id; + if (data.minimumAvailabilities?.length) + defaults.minimumAvailability = data.minimumAvailabilities[2]?.value || data.minimumAvailabilities[0].value; + if (data.seriesTypes?.length) defaults.seriesType = data.seriesTypes[0].value; + if (data.monitorOptions?.length) defaults.monitorOption = data.monitorOptions[0].value; + setConfig(defaults); + } + setLoading(false); + }) + .catch(() => setLoading(false)); + // Pre-select all seasons + if (item.seasons) setSelectedSeasons(item.seasons.filter(s => s.seasonNumber > 0).map(s => s.seasonNumber)); + }, [mediaType, item]); + + const handleManualAdd = async () => { + setManualStep('adding'); + setResult(null); + try { + let body = { ...config, monitored: true }; + if (mediaType === 'movie') { + body.tmdbId = item.tmdbId; + body.searchForMovie = false; + } else { + body.tvdbId = item.tvdbId; + body.searchForMissingEpisodes = false; + if (selectedSeasons.length > 0 && item.seasons?.length) body.selectedSeasons = selectedSeasons; + } + const resp = await fetch(`/api/add/${mediaType}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (resp.ok && data.success) { + setAddedId(data.id); + setManualStep('browse'); + } else { + setResult({ success: false, message: data.error || 'Failed to add' }); + setManualStep(null); + } + } catch (err) { + setResult({ success: false, message: err.message }); + setManualStep(null); + } + }; + + // Fetch albums for music artists + useEffect(() => { + if (mediaType !== 'music' || !item.foreignArtistId) return; + setAlbumsLoading(true); + fetch( + `/api/lookup/music/albums?artistName=${encodeURIComponent(item.artistName)}&foreignArtistId=${encodeURIComponent(item.foreignArtistId || '')}`, + { cache: 'no-store' }, + ) + .then(r => (r.ok ? r.json() : [])) + .then(albums => { + setLookupAlbums(albums); + setSelectedAlbumIds(albums.filter(a => a.albumType !== 'Single').map(a => a.id)); + setAlbumsLoading(false); + }) + .catch(() => { + setLookupAlbums([]); + setAlbumsLoading(false); + }); + }, [mediaType, item.foreignArtistId, item.artistName]); + + const handleAdd = async () => { + setAdding(true); + setResult(null); + try { + let body = { ...config, monitored: true }; + if (mediaType === 'movie') { + body.tmdbId = item.tmdbId; + body.searchForMovie = true; + } else if (mediaType === 'series') { + body.tvdbId = item.tvdbId; + body.searchForMissingEpisodes = true; + if (selectedSeasons.length > 0 && item.seasons?.length) { + body.selectedSeasons = selectedSeasons; + } + } else { + body.foreignArtistId = item.foreignArtistId; + body.artistName = item.artistName; + body.searchForMissingAlbums = true; + if (lookupAlbums?.length > 0) { + body.selectedAlbumTitles = lookupAlbums + .filter(a => selectedAlbumIds.includes(a.id)) + .map(a => a.title.replace(/ - Single$/, '')); + } + } + const resp = await fetch(`/api/add/${mediaType}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (resp.ok && data.success) { + setResult({ + success: true, + message: data.albumsMonitored + ? `Added "${data.artistName}" — ${data.albumsMonitored}/${data.totalAlbums} albums monitored, searching Soulseek...` + : `Added "${data.title || data.artistName}" — searching for downloads...`, + }); + setAdding(false); + setTimeout(() => { + if (onAdded) onAdded(item); + }, 3000); + return; + } else { + setResult({ success: false, message: data.error || 'Failed to add' }); + } + } catch (err) { + setResult({ success: false, message: err.message }); + } + setAdding(false); + }; + + const rating = extractRating(item.ratings); + const title = mediaType === 'music' ? item.artistName : item.title; + const fallbackIcon = mediaType === 'movie' ? 'movie' : mediaType === 'series' ? 'tv' : 'album'; + + return ( + <div + className={`fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[20px] ${closing ? 'modal-backdrop-exit' : 'modal-backdrop-enter'}`} + onClick={close} + > + <div + className={`bg-bg-card rounded-2xl border border-border-subtle shadow-xl max-w-lg w-full mx-4 max-h-[85vh] overflow-y-auto scroll-area ${closing ? 'modal-exit' : 'modal-enter'}`} + onClick={e => e.stopPropagation()} + > + {/* Header */} + <div className="flex gap-4 p-5 border-b border-border-subtle"> + <div className="w-24 h-36 rounded-lg overflow-hidden relative flex-none"> + <PosterImg url={item.posterUrl} fallbackIcon={fallbackIcon} title={title} /> + </div> + <div className="flex-1 min-w-0"> + <h3 className="text-[15px] font-semibold text-text-primary">{title}</h3> + <div className="flex items-center gap-2 mt-1 text-[11px] text-text-muted flex-wrap"> + {item.year && <span>{item.year}</span>} + {item.network && <span>· {item.network}</span>} + {item.studio && <span>· {item.studio}</span>} + {item.disambiguation && <span>· {item.disambiguation}</span>} + {rating && ( + <span className="flex items-center gap-0.5 text-accent-orange"> + <span + className="material-symbols-rounded" + style={{ fontSize: 11, fontVariationSettings: "'FILL' 1" }} + > + star + </span> + {rating} + </span> + )} + </div> + {item.overview && ( + <p className="text-[11px] text-text-muted mt-2 line-clamp-3 leading-relaxed">{item.overview}</p> + )} + {item.genres?.length > 0 && ( + <div className="flex flex-wrap gap-1 mt-2"> + {item.genres.slice(0, 4).map(g => ( + <span key={g} className="genre-pill"> + {g} + </span> + ))} + </div> + )} + </div> + <button + onClick={close} + className="p-1 h-fit rounded-md hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors flex-none" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + close + </span> + </button> + </div> + + {/* Config */} + <div className="p-5"> + {loading ? ( + <div className="flex items-center justify-center py-6"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 24 }}> + progress_activity + </span> + </div> + ) : !profiles ? ( + <p className="text-center text-accent-red text-[12px] py-4">Failed to load profiles</p> + ) : ( + <> + <div className="grid grid-cols-2 gap-3"> + {profiles.qualityProfiles?.length > 0 && ( + <Select + label="Quality Profile" + value={config.qualityProfileId || ''} + onChange={v => setConfig(c => ({ ...c, qualityProfileId: parseInt(v) }))} + options={profiles.qualityProfiles.map(p => ({ value: p.id, label: p.name }))} + /> + )} + {profiles.rootFolders?.length > 0 && ( + <Select + label="Root Folder" + value={config.rootFolderPath || ''} + onChange={v => setConfig(c => ({ ...c, rootFolderPath: v }))} + options={profiles.rootFolders.map(f => ({ + value: f.path, + label: `${f.path}${f.freeSpace ? ` (${formatBytes(f.freeSpace)} free)` : ''}`, + }))} + /> + )} + </div> + <div className="grid grid-cols-2 gap-3 mt-3"> + {profiles.minimumAvailabilities && ( + <Select + label="Minimum Availability" + value={config.minimumAvailability || ''} + onChange={v => setConfig(c => ({ ...c, minimumAvailability: v }))} + options={profiles.minimumAvailabilities} + /> + )} + {profiles.seriesTypes && ( + <Select + label="Series Type" + value={config.seriesType || ''} + onChange={v => setConfig(c => ({ ...c, seriesType: v }))} + options={profiles.seriesTypes} + /> + )} + {profiles.monitorOptions && !item.seasons && ( + <Select + label="Monitor" + value={config.monitorOption || ''} + onChange={v => setConfig(c => ({ ...c, monitorOption: v }))} + options={profiles.monitorOptions} + /> + )} + {profiles.metadataProfiles && ( + <Select + label="Metadata Profile" + value={config.metadataProfileId || ''} + onChange={v => setConfig(c => ({ ...c, metadataProfileId: parseInt(v) }))} + options={profiles.metadataProfiles.map(p => ({ value: p.id, label: p.name }))} + /> + )} + </div> + {/* Season selection for TV */} + {item.seasons && ( + <SeasonSelector seasons={item.seasons} selected={selectedSeasons} onChange={setSelectedSeasons} /> + )} + {/* Album selection for Music */} + {mediaType === 'music' && albumsLoading && ( + <div className="mt-3 flex items-center justify-center py-4"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 18 }}> + progress_activity + </span> + <span className="text-[11px] text-text-muted ml-2">Loading discography...</span> + </div> + )} + {mediaType === 'music' && !albumsLoading && lookupAlbums && lookupAlbums.length > 0 && ( + <div className="mt-3"> + <div className="flex items-center justify-between mb-2"> + <label className="text-[11px] text-text-muted font-medium"> + Select Albums ({selectedAlbumIds.length} selected) + </label> + <div className="flex gap-2"> + <button + onClick={() => + setSelectedAlbumIds(lookupAlbums.filter(a => a.albumType !== 'Single').map(a => a.id)) + } + className="text-[10px] text-accent-blue hover:underline" + > + Albums + EPs + </button> + <button + onClick={() => + setSelectedAlbumIds( + selectedAlbumIds.length === lookupAlbums.length ? [] : lookupAlbums.map(a => a.id), + ) + } + className="text-[10px] text-accent-blue hover:underline" + > + {selectedAlbumIds.length === lookupAlbums.length ? 'Deselect All' : 'Select All'} + </button> + </div> + </div> + <div className="relative mb-2"> + <span + className="material-symbols-rounded absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" + style={{ fontSize: 14 }} + > + search + </span> + <input + type="text" + value={albumFilter} + onChange={e => setAlbumFilter(e.target.value)} + placeholder="Filter albums..." + className="w-full pl-7 pr-2 py-1.5 text-[11px] bg-[#e8e8ed]/50 border-none rounded-xl text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-[#007AFF]/30" + /> + </div> + <div className="space-y-0.5 max-h-56 overflow-y-auto scroll-area"> + {lookupAlbums + .filter(a => !albumFilter || a.title.toLowerCase().includes(albumFilter.toLowerCase())) + .map(a => ( + <label + key={a.id} + className="flex items-center gap-3 px-2 py-2.5 rounded-md hover:bg-bg-hover cursor-pointer transition-colors" + > + <input + type="checkbox" + checked={selectedAlbumIds.includes(a.id)} + onChange={() => + setSelectedAlbumIds(prev => + prev.includes(a.id) ? prev.filter(id => id !== a.id) : [...prev, a.id], + ) + } + className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer flex-none" + /> + {a.coverUrl && ( + <img + src={'/api/poster?url=' + encodeURIComponent(a.coverUrl)} + alt="" + className="w-11 h-11 rounded-lg object-cover flex-none" + onError={e => { + e.target.style.display = 'none'; + }} + /> + )} + <div className="flex-1 min-w-0"> + <span className="text-[12px] text-text-primary block truncate"> + {a.title.replace(/ - (Single|EP)$/i, '')} + </span> + <span className="text-[10px] text-text-muted"> + {a.releaseDate ? new Date(a.releaseDate).getFullYear() : ''} + {a.trackCount > 0 ? ' · ' + a.trackCount + ' tracks' : ''} + {a.source === 'musicbrainz' ? ' · MB' : ''} + </span> + </div> + <span + className={ + 'text-[9px] font-medium px-1.5 py-0.5 rounded-full flex-none ' + + (a.albumType === 'Album' + ? 'bg-[#007AFF]/10 text-[#007AFF]' + : a.albumType === 'EP' + ? 'bg-[#AF52DE]/10 text-[#AF52DE]' + : 'bg-bg-surface-2 text-text-muted') + } + > + {a.albumType} + </span> + </label> + ))} + </div> + </div> + )} + </> + )} + + {result && ( + <div + className={`mt-4 px-4 py-2.5 rounded-lg text-[12px] font-medium ${result.success ? 'bg-accent-green/10 text-accent-green border border-accent-green/20' : 'bg-accent-red/10 text-accent-red border border-accent-red/20'}`} + > + <div className="flex items-center gap-2"> + <span className="material-symbols-rounded" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}> + {result.success ? 'check_circle' : 'error'} + </span> + {result.message} + </div> + </div> + )} + + {!result?.success && manualStep !== 'browse' && ( + <div className="mt-4 flex gap-2"> + <button + onClick={handleAdd} + disabled={adding || loading || !profiles || manualStep === 'adding'} + className={`flex-1 py-2.5 rounded-xl text-[13px] font-semibold transition-all duration-150 active:scale-[0.97] flex items-center justify-center gap-2 ${ + adding || loading || !profiles + ? 'bg-bg-surface-2 text-text-muted cursor-not-allowed' + : 'bg-text-primary text-white hover:bg-text-secondary' + }`} + > + {adding ? ( + <> + <span className="material-symbols-rounded animate-spin" style={{ fontSize: 16 }}> + progress_activity + </span>{' '} + Adding... + </> + ) : ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 16 }}> + add + </span>{' '} + Add & Search + </> + )} + </button> + {mediaType !== 'music' && ( + <button + onClick={handleManualAdd} + disabled={adding || loading || !profiles || manualStep === 'adding'} + className={`px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-all duration-150 active:scale-[0.97] flex items-center justify-center gap-2 border ${ + loading || !profiles || manualStep === 'adding' + ? 'border-border-subtle text-text-muted cursor-not-allowed' + : 'border-border-medium text-text-primary hover:bg-bg-hover hover:border-border-strong' + }`} + > + {manualStep === 'adding' ? ( + <> + <span className="material-symbols-rounded animate-spin" style={{ fontSize: 15 }}> + progress_activity + </span>{' '} + Adding... + </> + ) : ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 15 }}> + manage_search + </span>{' '} + Manual + </> + )} + </button> + )} + </div> + )} + {manualStep === 'browse' && addedId && ( + <div className="mt-4"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[12px] font-semibold text-text-primary">Manual Search</span> + <button + onClick={() => { + setManualStep(null); + setAddedId(null); + }} + className="text-[10px] text-text-muted hover:text-text-primary" + > + ← Back + </button> + </div> + <ManualSearchView + service={mediaType === 'movie' ? 'radarr' : 'sonarr'} + id={addedId} + seasonNumber={mediaType === 'series' ? selectedSeasons?.[0] : undefined} + title={title} + onGrabbed={() => { + setResult({ success: true, message: `Added "${title}" — release grabbed, downloading shortly` }); + setManualStep(null); + setTimeout(() => { + if (onAdded) onAdded(item); + }, 3000); + }} + /> + </div> + )} + </div> + </div> + </div> + ); +} diff --git a/frontend/src/library/components/AddResults.jsx b/frontend/src/library/components/AddResults.jsx new file mode 100644 index 0000000..8fa6995 --- /dev/null +++ b/frontend/src/library/components/AddResults.jsx @@ -0,0 +1,124 @@ +import ResultCard from './cards/ResultCard'; + +const flatGridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', + gap: 16, +}; + +const musicGridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', + gap: 12, +}; + +function MusicLookupSections({ musicSections, onSelectItem }) { + const sectionOrder = + musicSections.topCategory === 'albums' + ? ['albums', 'artists', 'singles'] + : musicSections.topCategory === 'singles' + ? ['singles', 'artists', 'albums'] + : ['artists', 'albums', 'singles']; + const sectionDefs = { + artists: { label: 'Artists', items: musicSections.artists, icon: 'person' }, + albums: { label: 'Albums', items: musicSections.albums, icon: 'album' }, + singles: { label: 'Singles & EPs', items: musicSections.singles, icon: 'music_note' }, + }; + + return sectionOrder.map(key => { + const { label, items, icon } = sectionDefs[key]; + if (!items || items.length === 0) return null; + return ( + <div key={key} className="mb-8"> + <div className="flex items-center gap-2 mb-3"> + <span + className="material-symbols-rounded text-text-muted" + style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }} + > + {icon} + </span> + <h3 className="text-[14px] font-semibold text-text-primary">{label}</h3> + <span className="text-[11px] text-text-muted">({items.length})</span> + </div> + <div style={musicGridStyle}> + {items.map((item, i) => ( + <ResultCard + key={`${item.foreignArtistId || item.foreignAlbumId || 'x'}-${i}-${item.title || item.artistName || ''}`} + item={item} + mediaType={key === 'artists' ? 'music' : 'music-album'} + onClick={() => + key === 'artists' + ? onSelectItem(item) + : onSelectItem({ ...item, _isAlbum: true, _albumType: key }) + } + /> + ))} + </div> + </div> + ); + }); +} + +export default function AddResults({ loading, query, addType, lookupResults, musicSections, onSelectItem }) { + return ( + <div className="px-8 py-6 min-h-[760px]"> + {loading && ( + <div className="flex items-center justify-center py-20"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 32 }}> + progress_activity + </span> + </div> + )} + {!loading && query.trim() && lookupResults.length === 0 && ( + <div className="flex flex-col items-center justify-center py-20 text-text-muted"> + <span + className="material-symbols-rounded mb-3" + style={{ fontSize: 48, fontVariationSettings: "'FILL' 1" }} + > + search_off + </span> + <p className="text-[14px]">No results for "{query}"</p> + </div> + )} + {!loading && !query.trim() && ( + <div className="flex flex-col items-center justify-center py-20 text-text-muted"> + <span + className="material-symbols-rounded mb-3" + style={{ fontSize: 48, fontVariationSettings: "'FILL' 1" }} + > + {addType === 'movie' ? 'movie' : addType === 'series' ? 'tv' : 'album'} + </span> + <p className="text-[14px]"> + Search for {addType === 'movie' ? 'movies' : addType === 'series' ? 'TV shows' : 'music'} to add + </p> + <p className="text-[12px] mt-1"> + Results from {addType === 'movie' ? 'Radarr' : addType === 'series' ? 'Sonarr' : 'Lidarr'} + </p> + </div> + )} + {!loading && lookupResults.length > 0 && ( + <div> + {addType === 'music' && musicSections ? ( + <MusicLookupSections musicSections={musicSections} onSelectItem={onSelectItem} /> + ) : ( + <div> + <p className="text-[11px] text-text-muted mb-3"> + {lookupResults.length} result{lookupResults.length !== 1 ? 's' : ''} + </p> + <div style={flatGridStyle}> + {lookupResults.map((item, i) => ( + <ResultCard + key={`${item.tmdbId || item.tvdbId || item.foreignArtistId || 'x'}-${i}-${item.title || item.artistName || ''}`} + item={item} + mediaType={addType} + onClick={() => onSelectItem(item)} + /> + ))} + </div> + </div> + )} + </div> + )} + </div> + ); +} diff --git a/frontend/src/library/components/AlbumSelector.jsx b/frontend/src/library/components/AlbumSelector.jsx new file mode 100644 index 0000000..9844b9c --- /dev/null +++ b/frontend/src/library/components/AlbumSelector.jsx @@ -0,0 +1,41 @@ +export default function AlbumSelector({ albums, selected, onChange }) { + if (!albums?.length) return null; + const toggle = id => { + onChange(selected.includes(id) ? selected.filter(a => a !== id) : [...selected, id]); + }; + const missing = albums.filter(a => a.trackFileCount < a.trackCount); + const selectMissing = () => onChange(missing.map(a => a.id)); + return ( + <div className="mt-3"> + <div className="flex items-center justify-between mb-2"> + <label className="text-[11px] text-text-muted font-medium">Select Albums</label> + {missing.length > 0 && ( + <button onClick={selectMissing} className="text-[10px] text-accent-blue hover:underline"> + Select Missing ({missing.length}) + </button> + )} + </div> + <div className="space-y-1 max-h-48 overflow-y-auto scroll-area"> + {albums.map(a => ( + <label + key={a.id} + className="flex items-center gap-2.5 px-3 py-1.5 rounded-md hover:bg-bg-hover cursor-pointer transition-colors" + > + <input + type="checkbox" + checked={selected.includes(a.id)} + onChange={() => toggle(a.id)} + className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer" + /> + <span className="text-[12px] text-text-primary flex-1 truncate">{a.title}</span> + <span + className={`text-[10px] font-mono ${a.trackFileCount === a.trackCount && a.trackCount > 0 ? 'text-accent-green' : 'text-accent-orange'}`} + > + {a.trackFileCount}/{a.trackCount} + </span> + </label> + ))} + </div> + </div> + ); +} diff --git a/frontend/src/library/components/ArtistDetail.jsx b/frontend/src/library/components/ArtistDetail.jsx new file mode 100644 index 0000000..733786f --- /dev/null +++ b/frontend/src/library/components/ArtistDetail.jsx @@ -0,0 +1,735 @@ +import { useState, useEffect } from 'react'; +import { formatBytes } from '../../utils'; +import { useAnimatedClose } from '../hooks/useAnimatedClose'; + +export default function ArtistDetail({ artistId, onClose, onDelete }) { + const { closing, close } = useAnimatedClose(onClose); + const [albums, setAlbums] = useState(null); + const [artistInfo, setArtistInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedAlbums, setSelectedAlbums] = useState([]); + const [searching, setSearching] = useState(false); + const [searchResult, setSearchResult] = useState(null); + const [fileTree, setFileTree] = useState(null); + const [showFiles, setShowFiles] = useState(false); + const [expandedFolder, setExpandedFolder] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [confirmDeleteAlbum, setConfirmDeleteAlbum] = useState(null); + // Discover-more state + const [discoverAlbums, setDiscoverAlbums] = useState(null); + const [discoverLoading, setDiscoverLoading] = useState(false); + const [showDiscover, setShowDiscover] = useState(false); + const [discoverFilter, setDiscoverFilter] = useState('all'); // all|Album|EP|Single + const [discoverSelected, setDiscoverSelected] = useState([]); + const [addingExtras, setAddingExtras] = useState(false); + const [discoverTextFilter, setDiscoverTextFilter] = useState(''); + + useEffect(() => { + setLoading(true); + Promise.all([ + fetch(`/api/library/artists/${artistId}/albums`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null)), + fetch(`/api/library/artists/${artistId}/files`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null)), + ]) + .then(([albumData, fileData]) => { + setAlbums(albumData?.albums || []); + setArtistInfo(albumData?.artist || null); + setFileTree(fileData); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [artistId]); + + const loadDiscover = async () => { + if (!artistInfo?.artistName) return; + setShowDiscover(true); + if (discoverAlbums) return; + setDiscoverLoading(true); + try { + const url = `/api/lookup/music/albums?artistName=${encodeURIComponent(artistInfo.artistName)}&foreignArtistId=${encodeURIComponent(artistInfo.foreignArtistId || '')}`; + const data = await fetch(url, { cache: 'no-store' }).then(r => (r.ok ? r.json() : [])); + // Dedupe against albums already in Lidarr (normalized title match) + const norm = s => + (s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/\s*\(.*?\)\s*/g, ' ') + .replace(/\s*\[.*?\]\s*/g, ' ') + .replace(/ - (single|ep)$/i, '') + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); + const existing = new Set((albums || []).map(a => norm(a.title))); + const filtered = (Array.isArray(data) ? data : []).filter(a => !existing.has(norm(a.title))); + setDiscoverAlbums(filtered); + } catch (e) { + setDiscoverAlbums([]); + } + setDiscoverLoading(false); + }; + + const submitExtras = async () => { + if (discoverSelected.length === 0 || !discoverAlbums) return; + setAddingExtras(true); + setSearchResult(null); + try { + const titles = discoverAlbums + .filter(a => discoverSelected.includes(a.id)) + .map(a => a.title.replace(/ - Single$/, '')); + const resp = await fetch(`/api/library/artists/${artistId}/albums/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selectedAlbumTitles: titles }), + }); + const data = await resp.json(); + if (resp.ok) { + setSearchResult({ + success: true, + message: `Adding ${titles.length} album(s) — ${data.monitored} matched in Lidarr, ${data.unmatched?.length || 0} via Soulseek`, + }); + setDiscoverSelected([]); + // Refresh library albums after a delay + setTimeout(() => { + fetch(`/api/library/artists/${artistId}/albums`, { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : null)) + .then(d => { + if (d?.albums) setAlbums(d.albums); + }); + // Force re-fetch discover (some moved to library) + setDiscoverAlbums(null); + }, 4000); + } else { + setSearchResult({ success: false, message: data.error || 'Add failed' }); + } + } catch (e) { + setSearchResult({ success: false, message: e.message }); + } + setAddingExtras(false); + }; + + const handleDownload = async () => { + if (selectedAlbums.length === 0) return; + setSearching(true); + setSearchResult(null); + try { + const resp = await fetch('/api/command/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: 'lidarr', id: artistId, albumIds: selectedAlbums }), + }); + const data = await resp.json(); + setSearchResult( + resp.ok + ? { success: true, message: `Search triggered for ${selectedAlbums.length} album(s)` } + : { success: false, message: data.error }, + ); + } catch (err) { + setSearchResult({ success: false, message: err.message }); + } + setSearching(false); + }; + + const isAlbumComplete = a => a.trackFileCount > 0 && a.trackFileCount >= a.trackCount && a.trackCount > 0; + const missingAlbums = albums?.filter(a => !isAlbumComplete(a)) || []; + const downloadedAlbums = albums?.filter(a => isAlbumComplete(a)) || []; + const allAlbumsComplete = !loading && albums?.length > 0 && missingAlbums.length === 0; + + const handleDeleteAlbumFiles = async albumId => { + try { + await fetch(`/api/delete/album/${albumId}/files`, { method: 'DELETE' }); + // Refresh album list + const data = await fetch(`/api/library/artists/${artistId}/albums`, { cache: 'no-store' }).then(r => r.json()); + setAlbums(data?.albums || []); + setSearchResult({ success: true, message: 'Album files removed from disk' }); + } catch (err) { + setSearchResult({ success: false, message: err.message }); + } + }; + + return ( + <div + className={`fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[20px] ${closing ? 'modal-backdrop-exit' : 'modal-backdrop-enter'}`} + onClick={close} + > + <div + className={`bg-bg-card rounded-2xl border border-border-subtle shadow-xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col ${closing ? 'modal-exit' : 'modal-enter'}`} + onClick={e => e.stopPropagation()} + > + <div className="flex items-center justify-between px-5 py-3.5 border-b border-border-subtle"> + <h3 className="text-[15px] font-semibold text-text-primary">Albums</h3> + <div className="flex items-center gap-1"> + <button + onClick={() => setConfirmDelete(!confirmDelete)} + className="p-1 rounded-md hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors" + title="Delete artist" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + delete + </span> + </button> + <button + onClick={close} + className="p-1 rounded-md hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + close + </span> + </button> + </div> + </div> + {confirmDelete && ( + <div className="px-5 py-3 bg-accent-red/5 border-b border-accent-red/20"> + <p className="text-[12px] text-accent-red font-medium mb-2">Delete this artist?</p> + <div className="flex items-center gap-2"> + <button + onClick={async () => { + setDeleting(true); + try { + const resp = await fetch(`/api/delete/music/${artistId}?deleteFiles=true`, { method: 'DELETE' }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.error?.substring(0, 100) || 'Delete failed'); + } + onDelete?.(); + onClose(); + } catch (err) { + setSearchResult({ success: false, message: `Delete failed: ${err.message}` }); + } + setDeleting(false); + setConfirmDelete(false); + }} + disabled={deleting} + className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent-red text-white hover:bg-accent-red/90 disabled:opacity-50" + > + {deleting ? 'Deleting...' : 'Delete + Remove Files'} + </button> + <button + onClick={async () => { + setDeleting(true); + try { + const resp = await fetch(`/api/delete/music/${artistId}?deleteFiles=false`, { method: 'DELETE' }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.error?.substring(0, 100) || 'Delete failed'); + } + onDelete?.(); + onClose(); + } catch (err) { + setSearchResult({ success: false, message: `Delete failed: ${err.message}` }); + } + setDeleting(false); + setConfirmDelete(false); + }} + disabled={deleting} + className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-bg-surface border border-border-subtle text-text-primary hover:bg-bg-hover disabled:opacity-50" + > + Remove from Lidarr Only + </button> + <button + onClick={() => setConfirmDelete(false)} + className="px-3 py-1.5 rounded-lg text-[11px] text-text-muted hover:text-text-primary" + > + Cancel + </button> + </div> + </div> + )} + <div className="flex-1 overflow-y-auto scroll-area p-4"> + {loading ? ( + <div className="flex items-center justify-center py-12"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 24 }}> + progress_activity + </span> + </div> + ) : albums.length === 0 ? ( + <p className="text-center text-text-muted text-[13px] py-8">No albums found</p> + ) : ( + <div className="space-y-2"> + {albums.map(a => { + const isMissing = a.trackFileCount < a.trackCount; + const isSelected = selectedAlbums.includes(a.id); + return ( + <div + key={a.id} + className={`flex items-center gap-3 p-3 rounded-lg border hover:bg-bg-hover/50 transition-colors ${isAlbumComplete(a) ? 'border-accent-green/20 bg-accent-green/5' : 'border-border-subtle'}`} + > + {!isAlbumComplete(a) ? ( + <input + type="checkbox" + checked={isSelected} + onChange={() => + setSelectedAlbums(prev => (isSelected ? prev.filter(id => id !== a.id) : [...prev, a.id])) + } + className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer flex-none" + /> + ) : ( + <span className="material-symbols-rounded text-accent-green flex-none" style={{ fontSize: 16 }}> + check_circle + </span> + )} + <div className="w-12 h-12 rounded-md overflow-hidden relative flex-none bg-bg-surface-2"> + {a.coverUrl ? ( + <img + src={ + a.coverUrl.startsWith('/api/') + ? a.coverUrl + : `/api/poster?url=${encodeURIComponent(a.coverUrl)}` + } + alt={a.title} + className="w-full h-full object-cover" + /> + ) : ( + <div className="w-full h-full flex items-center justify-center"> + <span + className="material-symbols-rounded text-border-medium" + style={{ fontSize: 24, fontVariationSettings: "'FILL' 1" }} + > + album + </span> + </div> + )} + </div> + <div className="flex-1 min-w-0"> + <p className="text-[13px] font-medium text-text-primary truncate">{a.title}</p> + <p className="text-[11px] text-text-muted"> + {a.releaseDate ? new Date(a.releaseDate).getFullYear() : 'Unknown'} + {' · '} + <span + className={ + a.trackFileCount === a.trackCount && a.trackCount > 0 + ? 'text-accent-green' + : 'text-accent-orange' + } + > + {a.trackFileCount}/{a.trackCount} tracks + </span> + {a.sizeOnDisk > 0 && ` · ${formatBytes(a.sizeOnDisk)}`} + </p> + </div> + {a.percentOfTracks > 0 && ( + <div className="flex items-center gap-2 flex-none"> + <div className="w-16 h-1.5 rounded-full bg-bg-surface-2 overflow-hidden"> + <div + className="h-full rounded-full" + style={{ + width: `${a.percentOfTracks}%`, + background: a.percentOfTracks >= 100 ? '#16a34a' : '#d97706', + }} + /> + </div> + <span className="text-[10px] font-mono text-text-muted">{Math.round(a.percentOfTracks)}%</span> + </div> + )} + {isAlbumComplete(a) && + (confirmDeleteAlbum === a.id ? ( + <div className="flex items-center gap-1 flex-none"> + <button + onClick={() => { + handleDeleteAlbumFiles(a.id); + setConfirmDeleteAlbum(null); + }} + className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent-red text-white hover:bg-accent-red/90" + > + Delete + </button> + <button + onClick={() => setConfirmDeleteAlbum(null)} + className="px-2 py-0.5 rounded text-[10px] text-text-muted hover:text-text-primary" + > + Cancel + </button> + </div> + ) : ( + <button + onClick={() => setConfirmDeleteAlbum(a.id)} + className="p-1 rounded hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors flex-none" + title="Remove files from disk" + > + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + delete + </span> + </button> + ))} + </div> + ); + })} + </div> + )} + </div> + {/* Discover more section */} + {!loading && artistInfo?.artistName && ( + <div className="border-t border-border-subtle"> + <button + onClick={() => (showDiscover ? setShowDiscover(false) : loadDiscover())} + className="w-full flex items-center gap-2 px-5 py-2.5 hover:bg-bg-hover/50 transition-colors text-left" + > + <span + className="material-symbols-rounded text-accent-blue" + style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }} + > + add_circle + </span> + <span className="text-[11px] font-semibold text-text-primary flex-1"> + Discover More (EPs, Singles & missing albums) + </span> + <span + className="material-symbols-rounded text-text-muted" + style={{ + fontSize: 14, + transition: 'transform 0.2s', + transform: showDiscover ? 'rotate(180deg)' : 'none', + }} + > + expand_more + </span> + </button> + {showDiscover && ( + <div className="px-5 pb-4"> + {discoverLoading ? ( + <div className="flex items-center justify-center py-6"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 22 }}> + progress_activity + </span> + </div> + ) : !discoverAlbums || discoverAlbums.length === 0 ? ( + <p className="text-[11px] text-text-muted py-3"> + No additional releases found from iTunes/MusicBrainz. + </p> + ) : ( + <> + <div className="flex items-center justify-between mb-2"> + <label className="text-[11px] text-text-muted font-medium"> + Select Albums ({discoverSelected.length} selected) + </label> + <div className="flex gap-2"> + <button + onClick={() => + setDiscoverSelected(discoverAlbums.filter(a => a.albumType !== 'Single').map(a => a.id)) + } + className="text-[10px] text-accent-blue hover:underline" + > + Albums + EPs + </button> + <button + onClick={() => + setDiscoverSelected(discoverAlbums.filter(a => a.albumType === 'EP').map(a => a.id)) + } + className="text-[10px] text-accent-blue hover:underline" + > + EPs only + </button> + <button + onClick={() => + setDiscoverSelected(discoverAlbums.filter(a => a.albumType === 'Single').map(a => a.id)) + } + className="text-[10px] text-accent-blue hover:underline" + > + Singles only + </button> + <button + onClick={() => + setDiscoverSelected( + discoverSelected.length === discoverAlbums.length ? [] : discoverAlbums.map(a => a.id), + ) + } + className="text-[10px] text-accent-blue hover:underline" + > + {discoverSelected.length === discoverAlbums.length ? 'Deselect All' : 'Select All'} + </button> + </div> + </div> + <div className="flex items-center gap-1.5 mb-2 flex-wrap"> + {['all', 'Album', 'EP', 'Single'].map(t => { + const count = + t === 'all' ? discoverAlbums.length : discoverAlbums.filter(a => a.albumType === t).length; + return ( + <button + key={t} + onClick={() => setDiscoverFilter(t)} + className={`text-[10px] px-2 py-0.5 rounded-full border ${discoverFilter === t ? 'bg-accent-blue/15 border-accent-blue/40 text-accent-blue' : 'border-border-subtle text-text-muted hover:text-text-primary'}`} + > + {t === 'all' ? 'All' : t} <span className="opacity-60">{count}</span> + </button> + ); + })} + </div> + <div className="relative mb-2"> + <span + className="material-symbols-rounded absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" + style={{ fontSize: 14 }} + > + search + </span> + <input + type="text" + value={discoverTextFilter} + onChange={e => setDiscoverTextFilter(e.target.value)} + placeholder="Filter releases..." + className="w-full pl-7 pr-2 py-1.5 text-[11px] bg-[#e8e8ed]/50 border-none rounded-xl text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-[#007AFF]/30" + /> + </div> + <div className="space-y-0.5 max-h-72 overflow-y-auto scroll-area"> + {discoverAlbums + .filter(a => discoverFilter === 'all' || a.albumType === discoverFilter) + .filter( + a => !discoverTextFilter || a.title.toLowerCase().includes(discoverTextFilter.toLowerCase()), + ) + .map(a => { + const isSel = discoverSelected.includes(a.id); + return ( + <label + key={a.id} + className="flex items-center gap-3 px-2 py-2.5 rounded-md hover:bg-bg-hover cursor-pointer transition-colors" + > + <input + type="checkbox" + checked={isSel} + onChange={() => + setDiscoverSelected(prev => + prev.includes(a.id) ? prev.filter(id => id !== a.id) : [...prev, a.id], + ) + } + className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer flex-none" + /> + {a.coverUrl && ( + <img + src={'/api/poster?url=' + encodeURIComponent(a.coverUrl)} + alt="" + className="w-11 h-11 rounded-lg object-cover flex-none" + onError={e => { + e.target.style.display = 'none'; + }} + /> + )} + <div className="flex-1 min-w-0"> + <span className="text-[12px] text-text-primary block truncate"> + {a.title.replace(/ - (Single|EP)$/i, '')} + </span> + <span className="text-[10px] text-text-muted"> + {a.releaseDate ? new Date(a.releaseDate).getFullYear() : ''} + {a.trackCount > 0 ? ' · ' + a.trackCount + ' tracks' : ''} + {a.source === 'musicbrainz' ? ' · MB' : ''} + </span> + </div> + <span + className={ + 'text-[9px] font-medium px-1.5 py-0.5 rounded-full flex-none ' + + (a.albumType === 'Album' + ? 'bg-[#007AFF]/10 text-[#007AFF]' + : a.albumType === 'EP' + ? 'bg-[#AF52DE]/10 text-[#AF52DE]' + : 'bg-bg-surface-2 text-text-muted') + } + > + {a.albumType} + </span> + </label> + ); + })} + </div> + <div className="flex items-center justify-end gap-2 mt-3"> + <button + onClick={() => setDiscoverSelected([])} + disabled={discoverSelected.length === 0} + className="text-[11px] text-text-muted hover:text-text-primary disabled:opacity-40 px-2 py-1" + > + Clear + </button> + <button + onClick={submitExtras} + disabled={discoverSelected.length === 0 || addingExtras} + className={`px-4 py-2 rounded-xl text-[12px] font-semibold transition-all duration-150 active:scale-[0.97] flex items-center gap-2 ${ + discoverSelected.length === 0 || addingExtras + ? 'bg-bg-surface-2 text-text-muted cursor-not-allowed' + : 'bg-text-primary text-white hover:bg-text-secondary' + }`} + > + {addingExtras ? ( + <> + <span className="material-symbols-rounded animate-spin" style={{ fontSize: 14 }}> + progress_activity + </span>{' '} + Adding… + </> + ) : ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + add + </span>{' '} + Add {discoverSelected.length} to Lidarr + </> + )} + </button> + </div> + </> + )} + </div> + )} + </div> + )} + {/* File tree section */} + {!loading && fileTree && ( + <div className="border-t border-border-subtle"> + <button + onClick={() => setShowFiles(!showFiles)} + className="w-full flex items-center gap-2 px-5 py-2.5 hover:bg-bg-hover/50 transition-colors text-left" + > + <span + className="material-symbols-rounded text-text-muted" + style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }} + > + folder_open + </span> + <span className="text-[11px] font-semibold text-text-primary flex-1">Files on Disk</span> + {fileTree.path && ( + <span className="text-[9px] font-mono text-text-muted truncate max-w-[200px]">{fileTree.path}</span> + )} + <span + className="material-symbols-rounded text-text-muted" + style={{ fontSize: 14, transition: 'transform 0.2s', transform: showFiles ? 'rotate(180deg)' : 'none' }} + > + expand_more + </span> + </button> + {showFiles && ( + <div className="px-5 pb-3 max-h-64 overflow-y-auto scroll-area"> + {fileTree.folders?.length === 0 ? ( + <p className="text-[11px] text-text-muted py-2">No files found on disk</p> + ) : ( + <div className="space-y-1"> + {fileTree.folders.map(folder => ( + <div key={folder.name} className="rounded-lg border border-border-subtle overflow-hidden"> + <button + onClick={() => setExpandedFolder(expandedFolder === folder.name ? null : folder.name)} + className="w-full flex items-center gap-2 px-3 py-2 hover:bg-bg-hover/30 transition-colors text-left" + > + <span + className="material-symbols-rounded text-accent-blue flex-none" + style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }} + > + folder + </span> + <span className="text-[11px] text-text-primary font-medium flex-1 truncate"> + {folder.name} + </span> + <span className="text-[9px] font-mono text-text-muted"> + {folder.fileCount} files · {formatBytes(folder.totalSize)} + </span> + <span + className="material-symbols-rounded text-text-muted" + style={{ + fontSize: 12, + transition: 'transform 0.2s', + transform: expandedFolder === folder.name ? 'rotate(180deg)' : 'none', + }} + > + expand_more + </span> + </button> + {expandedFolder === folder.name && ( + <div className="border-t border-border-subtle bg-bg-surface/50"> + {folder.files.map((f, fi) => ( + <div + key={fi} + className="flex items-center gap-2 px-3 py-1 border-b border-border-subtle last:border-b-0" + > + <span + className="material-symbols-rounded text-text-muted flex-none" + style={{ fontSize: 11, fontVariationSettings: "'FILL' 1" }} + > + {f.name.endsWith('.flac') + ? 'audio_file' + : f.name.endsWith('.mp3') + ? 'audio_file' + : 'description'} + </span> + <span className="text-[10px] text-text-secondary truncate flex-1">{f.name}</span> + <span className="text-[9px] font-mono text-text-muted flex-none"> + {formatBytes(f.size)} + </span> + </div> + ))} + </div> + )} + </div> + ))} + </div> + )} + </div> + )} + </div> + )} + {/* Footer: completion or download controls */} + {!loading && albums?.length > 0 && ( + <div className="px-5 py-3 border-t border-border-subtle bg-bg-surface"> + {allAlbumsComplete ? ( + <div className="flex items-center gap-2 py-1"> + <span className="material-symbols-rounded text-accent-green" style={{ fontSize: 20 }}> + check_circle + </span> + <div> + <p className="text-[12px] font-semibold text-accent-green">All albums downloaded</p> + <p className="text-[10px] text-text-muted"> + {downloadedAlbums.length} album{downloadedAlbums.length !== 1 ? 's' : ''} · click trash icon to + remove files + </p> + </div> + </div> + ) : ( + <> + {searchResult && ( + <div + className={`mb-2 px-3 py-2 rounded-lg text-[11px] font-medium ${searchResult.success ? 'bg-accent-green/10 text-accent-green' : 'bg-accent-red/10 text-accent-red'}`} + > + {searchResult.message} + </div> + )} + <div className="flex items-center justify-between"> + <div> + <span className="text-[11px] text-text-muted"> + {selectedAlbums.length} album(s) selected · {missingAlbums.length} missing + </span> + {selectedAlbums.length === 0 && ( + <button + onClick={() => setSelectedAlbums(missingAlbums.map(a => a.id))} + className="text-[10px] text-accent-blue hover:underline ml-2" + > + Select all missing + </button> + )} + </div> + <button + onClick={handleDownload} + disabled={selectedAlbums.length === 0 || searching} + className={`flex items-center gap-1.5 px-4 py-2 rounded-xl text-[12px] font-semibold transition-all duration-150 active:scale-[0.97] ${ + selectedAlbums.length === 0 || searching + ? 'bg-bg-surface-2 text-text-muted cursor-not-allowed' + : 'bg-accent-blue text-white hover:bg-accent-blue/90' + }`} + > + {searching ? ( + <> + <span className="material-symbols-rounded animate-spin" style={{ fontSize: 14 }}> + progress_activity + </span>{' '} + Searching... + </> + ) : ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + download + </span>{' '} + Attempt Download + </> + )} + </button> + </div> + </> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/frontend/src/library/components/LibraryGrid.jsx b/frontend/src/library/components/LibraryGrid.jsx new file mode 100644 index 0000000..17300f4 --- /dev/null +++ b/frontend/src/library/components/LibraryGrid.jsx @@ -0,0 +1,123 @@ +import SeriesCard from './cards/SeriesCard'; +import MovieCard from './cards/MovieCard'; +import ArtistCard from './cards/ArtistCard'; + +const sectionHeaderStyle = { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + marginBottom: 16, +}; + +const gridStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', + gap: 16, +}; + +function GridSection({ title, count, children, sectionKey }) { + return ( + <div key={sectionKey} className="mb-8 library-grid-fade"> + <div style={sectionHeaderStyle}> + <h2 style={{ fontSize: 20, fontWeight: 700, color: '#fff', letterSpacing: '-0.3px' }}>{title}</h2> + <span style={{ fontSize: 12, color: 'rgba(235,235,245,0.50)', fontVariantNumeric: 'tabular-nums' }}> + {count} + </span> + </div> + <div style={gridStyle}>{children}</div> + </div> + ); +} + +export default function LibraryGrid({ + activeType, + initialLoaded, + loading, + isMissingFilter, + query, + visibleSeries, + visibleMovies, + visibleArtists, + totalLibrary, + queuedSeriesIds, + queuedMovieIds, + onSeriesClick, + onMovieClick, + onArtistClick, + onSwitchToAdd, +}) { + return ( + <div className="px-8 py-6 min-h-[760px]"> + {!initialLoaded && loading && ( + <div className="flex flex-col items-center justify-center py-20 text-text-muted"> + <span className="material-symbols-rounded animate-spin mb-3" style={{ fontSize: 32 }}> + progress_activity + </span> + <p className="text-[13px]">Loading library…</p> + </div> + )} + {visibleSeries.length > 0 && ( + <GridSection + sectionKey={`series-${activeType}`} + title={isMissingFilter ? 'Incomplete TV Shows' : 'TV Shows'} + count={visibleSeries.length} + > + {visibleSeries.map(s => ( + <SeriesCard + key={s.id} + series={s} + onClick={() => onSeriesClick(s.id)} + queued={queuedSeriesIds.has(s.id)} + /> + ))} + </GridSection> + )} + {visibleMovies.length > 0 && ( + <GridSection + sectionKey={`movies-${activeType}`} + title={isMissingFilter ? 'Missing Films' : 'Films'} + count={visibleMovies.length} + > + {visibleMovies.map(m => ( + <MovieCard + key={m.id} + movie={m} + onClick={() => onMovieClick(m)} + queued={queuedMovieIds.has(m.id)} + /> + ))} + </GridSection> + )} + {visibleArtists.length > 0 && ( + <GridSection sectionKey={`artists-${activeType}`} title="Music" count={visibleArtists.length}> + {visibleArtists.map(a => ( + <ArtistCard key={a.id} artist={a} onClick={() => onArtistClick(a.id)} /> + ))} + </GridSection> + )} + {initialLoaded && !loading && totalLibrary === 0 && ( + <div className="flex flex-col items-center justify-center py-20 text-text-muted"> + <span + className="material-symbols-rounded mb-3" + style={{ fontSize: 48, fontVariationSettings: "'FILL' 1" }} + > + {isMissingFilter ? 'task_alt' : 'search_off'} + </span> + <p className="text-[14px]"> + {isMissingFilter + ? 'Nothing missing — library is complete!' + : `No media found${query ? ` for "${query}"` : ''}`} + </p> + {!isMissingFilter && ( + <p className="text-[12px] mt-1"> + Try a different search, or switch to{' '} + <button onClick={onSwitchToAdd} className="text-accent-blue hover:underline"> + Add New + </button> + </p> + )} + </div> + )} + </div> + ); +} diff --git a/frontend/src/library/components/LibraryHeader.jsx b/frontend/src/library/components/LibraryHeader.jsx new file mode 100644 index 0000000..4989c7b --- /dev/null +++ b/frontend/src/library/components/LibraryHeader.jsx @@ -0,0 +1,140 @@ +import { TYPE_FILTERS, ADD_TYPE_FILTERS } from '../constants'; + +export default function LibraryHeader({ + mode, + setMode, + query, + addType, + setAddType, + activeType, + setActiveType, + inputRef, + handleQueryChange, + clearQuery, + handleRefresh, + refreshing, + switchToAddMode, + initialLoaded, + loading, + isMissingFilter, + totalLibrary, +}) { + return ( + <div className="sticky top-0 z-10 bg-bg-base/80 backdrop-blur-[20px] border-b border-border-subtle px-8 py-4"> + <div className="flex items-center gap-4 mb-3"> + <button + onClick={() => setMode('library')} + className={`flex items-center gap-1.5 text-[13px] font-medium pb-0.5 border-b-2 transition-colors ${ + mode === 'library' + ? 'text-text-primary border-text-primary' + : 'text-text-muted border-transparent hover:text-text-primary' + }`} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 18, fontVariationSettings: mode === 'library' ? "'FILL' 1" : "'FILL' 0" }} + > + video_library + </span> + My Library + </button> + <button + onClick={switchToAddMode} + className={`flex items-center gap-1.5 text-[13px] font-medium pb-0.5 border-b-2 transition-colors ${ + mode === 'add' + ? 'text-text-primary border-text-primary' + : 'text-text-muted border-transparent hover:text-text-primary' + }`} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 18, fontVariationSettings: mode === 'add' ? "'FILL' 1" : "'FILL' 0" }} + > + add_circle + </span> + Add New + </button> + </div> + + <div className="flex items-center gap-4"> + <div className="flex-1 relative"> + <span + className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-rounded text-text-muted" + style={{ fontSize: 20 }} + > + search + </span> + <input + ref={inputRef} + type="text" + value={query} + onChange={e => handleQueryChange(e.target.value)} + placeholder={ + mode === 'library' + ? 'Search your library...' + : `Search for ${addType === 'movie' ? 'movies' : addType === 'series' ? 'TV shows' : 'artists'} to add...` + } + className="search-input w-full pl-10 pr-10 py-2.5 bg-bg-card border border-border-subtle rounded-xl text-[13px] text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent-blue focus:ring-1 focus:ring-accent-blue/30" + /> + {query && ( + <button + onClick={clearQuery} + className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary" + > + <span className="material-symbols-rounded" style={{ fontSize: 18 }}> + close + </span> + </button> + )} + </div> + {mode === 'library' && ( + <button + onClick={handleRefresh} + disabled={refreshing} + title="Refresh library" + className="flex items-center justify-center w-9 h-9 rounded-xl bg-bg-card border border-border-subtle text-text-muted hover:text-text-primary hover:border-border-medium transition-colors flex-none" + > + <span className={`material-symbols-rounded${refreshing ? ' animate-spin' : ''}`} style={{ fontSize: 18 }}> + refresh + </span> + </button> + )} + + <div className="flex gap-1"> + {(mode === 'library' ? TYPE_FILTERS : ADD_TYPE_FILTERS).map(f => { + const selected = mode === 'library' ? activeType === f.key : addType === f.key; + const onSelect = () => (mode === 'library' ? setActiveType(f.key) : setAddType(f.key)); + return ( + <button + key={f.key} + onClick={onSelect} + className={`pill-springy flex items-center gap-1.5 px-3 py-2 rounded-full text-[12px] font-medium ${ + selected + ? 'bg-white/10 text-text-primary ring-1 ring-white/15' + : 'text-text-muted hover:text-text-primary hover:bg-bg-hover' + }`} + > + <span + className="material-symbols-rounded" + style={{ fontSize: 16, fontVariationSettings: selected ? "'FILL' 1" : "'FILL' 0" }} + > + {f.icon} + </span> + {f.label} + </button> + ); + })} + </div> + </div> + {mode === 'library' && initialLoaded && ( + <p className="text-[11px] text-text-muted mt-2"> + {loading + ? 'Searching...' + : isMissingFilter + ? `${totalLibrary} item${totalLibrary !== 1 ? 's' : ''} need attention` + : `${totalLibrary} result${totalLibrary !== 1 ? 's' : ''}`} + </p> + )} + </div> + ); +} diff --git a/frontend/src/library/components/ManualSearchView.jsx b/frontend/src/library/components/ManualSearchView.jsx new file mode 100644 index 0000000..685c635 --- /dev/null +++ b/frontend/src/library/components/ManualSearchView.jsx @@ -0,0 +1,361 @@ +import { useState, useEffect, useMemo } from 'react'; +import { formatBytes, detectQualityLabel } from '../../utils'; +import { QUALITY_ORDER } from '../../constants'; +import { detectResolution, detectSource, detectCodec } from '../utils/releaseDetection'; + +export default function ManualSearchView({ service, id, seasonNumber, title, onGrabbed }) { + const [releases, setReleases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sortBy, setSortBy] = useState('smart'); + const [sortDir, setSortDir] = useState('desc'); + const [exactMatchOnly, setExactMatchOnly] = useState(false); + const [qualityFilters, setQualityFilters] = useState([]); + const [minSeeders, setMinSeeders] = useState(0); + const [showCount, setShowCount] = useState(50); + const [grabbing, setGrabbing] = useState(null); + const [grabResult, setGrabResult] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + setReleases([]); + setQualityFilters([]); + setMinSeeders(0); + setShowCount(50); + let url; + if (id) { + url = `/api/manual-search?service=${service}&id=${id}${seasonNumber ? `&seasonNumber=${seasonNumber}` : ''}`; + } else if (title) { + let q = title; + if (seasonNumber && service === 'sonarr') q += ` S${String(seasonNumber).padStart(2, '0')}`; + url = `/api/fast-search?query=${encodeURIComponent(q)}&service=${service}`; + } + fetch(url) + .then(r => (r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || r.statusText)))) + .then(data => { + setReleases(Array.isArray(data) ? data : []); + setLoading(false); + }) + .catch(e => { + setError(String(e)); + setLoading(false); + }); + }, [service, id, seasonNumber, title]); + + const toggleSort = col => { + if (sortBy === col) setSortDir(d => (d === 'desc' ? 'asc' : 'desc')); + else { + setSortBy(col); + setSortDir('desc'); + } + }; + + const toggleQuality = q => { + setQualityFilters(prev => (prev.includes(q) ? prev.filter(x => x !== q) : [...prev, q])); + setShowCount(50); + }; + + // Build sorted+filtered set + const { sorted, allQualities } = useMemo(() => { + let filtered = exactMatchOnly ? releases.filter(r => !r.rejected) : releases; + if (qualityFilters.length > 0) filtered = filtered.filter(r => qualityFilters.includes(detectQualityLabel(r))); + if (minSeeders > 0) filtered = filtered.filter(r => (r.seeders || 0) >= minSeeders); + + const _maxSeeders = Math.max(1, ...filtered.map(r => r.seeders || 0)); + const RES_SCORE = { '2160p': 4, '1080p': 3, '720p': 2, '480p': 1 }; + const SRC_SCORE = { Remux: 5, Bluray: 4, WEBDL: 3, WEBRip: 2, HDTV: 1, DVD: 1, CAM: 0, Unknown: 1 }; + const smartScore = r => { + const combined = (r.quality || '') + ' ' + (r.title || ''); + const resScore = RES_SCORE[detectResolution(combined)] ?? 0; + const srcScore = SRC_SCORE[detectSource(combined)] ?? 1; + // resolution is primary (5x weight), source is secondary tiebreaker + const qualNorm = (resScore * 5 + srcScore) / 25; + // penalize files >40 GB (remux overkill) or suspiciously tiny <300 MB + const sizeGB = (r.size || 0) / 1073741824; + const sizePenalty = + sizeGB > 40 ? Math.max(0.4, 1 - (sizeGB - 40) / 100) : sizeGB > 0 && sizeGB < 0.3 ? 0.65 : 1.0; + // seeder floor: dead torrents rank last regardless of quality + const seeders = r.seeders || 0; + const seederFloor = seeders < 5 ? 0.2 : seeders < 15 ? 0.6 : seeders < 30 ? 0.85 : 1.0; + const seedNorm = seeders / _maxSeeders; + return (qualNorm * sizePenalty * 0.65 + seedNorm * 0.35) * seederFloor; + }; + const sorted = [...filtered].sort((a, b) => { + let cmp = 0; + if (sortBy === 'smart') cmp = smartScore(b) - smartScore(a); + else if (sortBy === 'seeders') cmp = (b.seeders || 0) - (a.seeders || 0); + else if (sortBy === 'size') cmp = (b.size || 0) - (a.size || 0); + else if (sortBy === 'quality') + cmp = (QUALITY_ORDER[detectQualityLabel(a)] ?? 99) - (QUALITY_ORDER[detectQualityLabel(b)] ?? 99); + else cmp = (b.seeders || 0) - (a.seeders || 0) || (b.size || 0) - (a.size || 0); + return sortDir === 'asc' ? -cmp : cmp; + }); + // Unique quality labels across ALL releases (not filtered) for the chips + const allQualities = [...new Set(releases.map(r => detectQualityLabel(r)))].sort( + (a, b) => (QUALITY_ORDER[a] ?? 99) - (QUALITY_ORDER[b] ?? 99), + ); + return { sorted, allQualities }; + }, [releases, exactMatchOnly, qualityFilters, minSeeders, sortBy, sortDir]); + + const visible = sorted.slice(0, showCount); + const hasMore = sorted.length > showCount; + + const handleGrab = async r => { + setGrabbing(r.guid); + setGrabResult(null); + try { + const resp = await fetch('/api/grab', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + service, + guid: r.guid, + indexerId: r.indexerId, + downloadUrl: r.downloadUrl || undefined, + title: r.title, + }), + }); + if (resp.ok) { + setGrabResult({ success: true, message: `Grabbed "${r.title}"` }); + onGrabbed?.(); + } else { + const e = await resp.json().catch(() => ({})); + setGrabResult({ success: false, message: e.error || 'Grab failed' }); + } + } catch (e) { + setGrabResult({ success: false, message: e.message }); + } + setGrabbing(null); + }; + + return ( + <div className="mt-3"> + {/* ── Top control bar: Exact Match + Sort ── */} + <div className="flex items-center gap-2 mb-2 flex-wrap"> + <button + onClick={() => setExactMatchOnly(v => !v)} + className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold border transition-colors ${exactMatchOnly ? 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue' : 'bg-bg-surface border-border-subtle text-text-muted hover:text-text-primary'}`} + > + <span className="material-symbols-rounded" style={{ fontSize: 11, fontVariationSettings: "'FILL' 1" }}> + filter_alt + </span> + Exact Match + </button> + <div className="flex items-center gap-1 ml-auto"> + <span className="text-[10px] text-text-muted mr-0.5">Sort:</span> + {[ + ['smart', 'Smart'], + ['seeders', 'Seeds'], + ['size', 'Size'], + ['quality', 'Quality'], + ].map(([val, label]) => ( + <button + key={val} + onClick={() => toggleSort(val)} + className={`px-2 py-1 rounded-lg text-[10px] font-semibold border transition-colors inline-flex items-center gap-0.5 ${sortBy === val ? 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue' : 'bg-bg-surface border-border-subtle text-text-muted hover:text-text-primary'}`} + > + {label} + {sortBy === val && <span style={{ fontSize: 9 }}>{sortDir === 'desc' ? '↓' : '↑'}</span>} + </button> + ))} + </div> + </div> + + {/* ── Quality chips + Min seeders (only when results are loaded) ── */} + {!loading && releases.length > 0 && ( + <div className="flex flex-wrap gap-x-3 gap-y-1.5 mb-2 pb-2 border-b border-border-subtle/50"> + {allQualities.length > 1 && ( + <div className="flex items-center gap-1 flex-wrap"> + <span className="text-[9px] font-bold text-text-muted uppercase tracking-widest">Q:</span> + {allQualities.map(q => ( + <button + key={q} + onClick={() => toggleQuality(q)} + className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-colors ${qualityFilters.includes(q) ? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue' : 'bg-bg-surface border-border-subtle text-text-muted hover:text-text-primary'}`} + > + {q} + </button> + ))} + {qualityFilters.length > 0 && ( + <button + onClick={() => setQualityFilters([])} + className="px-2 py-0.5 rounded-full text-[10px] font-semibold border border-border-subtle bg-bg-surface text-text-muted hover:text-text-primary transition-colors" + > + ✕ + </button> + )} + </div> + )} + <div className="flex items-center gap-1"> + <span className="text-[9px] font-bold text-text-muted uppercase tracking-widest">Seeds:</span> + {[ + [0, 'Any'], + [10, '10+'], + [25, '25+'], + [50, '50+'], + ].map(([val, label]) => ( + <button + key={val} + onClick={() => { + setMinSeeders(val); + setShowCount(50); + }} + className={`px-2 py-0.5 rounded-full text-[10px] font-semibold border transition-colors ${minSeeders === val ? 'bg-accent-green/15 border-accent-green/40 text-accent-green' : 'bg-bg-surface border-border-subtle text-text-muted hover:text-text-primary'}`} + > + {label} + </button> + ))} + </div> + </div> + )} + + {loading ? ( + <div className="flex items-center justify-center py-6 gap-2"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 18 }}> + progress_activity + </span> + <span className="text-[12px] text-text-muted">Searching indexers...</span> + </div> + ) : error ? ( + <p className="text-[12px] text-accent-red text-center py-4">{error}</p> + ) : sorted.length === 0 ? ( + <div className="py-4 text-center"> + <p className="text-[12px] text-text-muted mb-2"> + {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'} + </p> + {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 ( + <div className="inline-flex flex-col items-start gap-1 bg-white/[0.04] rounded-lg px-4 py-2.5 text-left"> + <div className="text-[9px] font-bold text-text-muted uppercase tracking-widest mb-1"> + Why rejected + </div> + {entries.map(([cat, count]) => ( + <div key={cat} className="flex items-center gap-2"> + <span className="text-[11px] font-bold text-amber-400 font-mono w-7 text-right">{count}×</span> + <span className="text-[11px] text-text-muted">{cat}</span> + </div> + ))} + </div> + ); + })()} + </div> + ) : ( + <> + {/* ── Result count ── */} + <div className="flex items-center gap-2 mb-1.5"> + <span className="text-[10px] text-text-muted flex-1"> + Showing {visible.length} of {sorted.length} result{sorted.length !== 1 ? 's' : ''} + {releases.length > sorted.length ? ` · ${releases.length - sorted.length} hidden by filters` : ''} + </span> + {sorted.length > 0 && !grabResult?.success && ( + <button + onClick={() => handleGrab(sorted[0])} + disabled={!!grabbing} + className="flex-none flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-bold bg-accent-blue/10 border border-accent-blue/25 text-accent-blue hover:bg-accent-blue/20 disabled:opacity-40 transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 12, fontVariationSettings: "'FILL' 1" }}> + bolt + </span> + Grab Best + </button> + )} + </div> + <div className="space-y-1 max-h-64 overflow-y-auto scroll-area"> + {visible.map((r, i) => ( + <div + key={r.guid || i} + className={`flex items-center gap-2 px-3 py-2 rounded-lg bg-bg-surface border transition-colors hover:bg-bg-hover/50 ${i === 0 && sortBy === 'smart' ? 'border-accent-blue/30' : 'border-border-subtle'}`} + > + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-1.5 min-w-0"> + {i === 0 && sortBy === 'smart' && ( + <span className="flex-none text-[9px] font-bold px-1.5 py-0.5 rounded-full bg-accent-blue/15 border border-accent-blue/30 text-accent-blue leading-none"> + ★ + </span> + )} + <div className="text-[11px] font-medium text-text-primary truncate">{r.title}</div> + </div> + <div className="flex items-center gap-2 mt-0.5 flex-wrap"> + <span className="text-[10px] text-text-muted">{r.indexer}</span> + <span className="text-[10px] font-medium text-accent-blue">{r.quality}</span> + {detectCodec(r.title) && ( + <span + className={`text-[9px] font-mono font-bold px-1 py-0.5 rounded ${detectCodec(r.title) === 'AV1' ? 'bg-purple-500/15 text-purple-400' : detectCodec(r.title) === 'x265' ? 'bg-accent-green/10 text-accent-green' : 'bg-white/[0.06] text-text-muted'}`} + > + {detectCodec(r.title)} + </span> + )} + <span className="text-[10px] font-mono text-text-muted">{formatBytes(r.size)}</span> + <span + className={`text-[10px] font-semibold ${r.seeders >= 10 ? 'text-accent-green' : r.seeders >= 3 ? 'text-accent-orange' : 'text-accent-red'}`} + > + {r.seeders}↑ {r.leechers}↓ + </span> + <span className="text-[10px] text-text-muted"> + {r.ageHours < 24 ? `${Math.round(r.ageHours)}h` : `${Math.round(r.ageHours / 24)}d`} + </span> + </div> + </div> + <button + onClick={() => handleGrab(r)} + disabled={!!grabbing} + className={`flex-none px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-colors whitespace-nowrap ${grabbing === r.guid ? 'bg-bg-surface-2 text-text-muted cursor-not-allowed' : grabResult?.success ? 'bg-accent-green/15 text-accent-green border border-accent-green/30 cursor-not-allowed' : 'bg-accent-blue text-white hover:bg-accent-blue/90'}`} + > + {grabbing === r.guid ? '...' : 'Grab'} + </button> + </div> + ))} + </div> + {hasMore && ( + <button + onClick={() => setShowCount(c => c + 50)} + className="mt-2 w-full py-1.5 rounded-lg text-[11px] font-semibold border border-border-subtle bg-bg-surface text-text-muted hover:text-text-primary transition-colors" + > + Show more ({sorted.length - showCount} remaining) + </button> + )} + </> + )} + {grabResult && ( + <div + className={`mt-2 px-3 py-2 rounded-lg text-[11px] font-medium ${grabResult.success ? 'bg-accent-green/10 text-accent-green border border-accent-green/20' : 'bg-accent-red/10 text-accent-red border border-accent-red/20'}`} + > + {grabResult.message} + </div> + )} + </div> + ); +} diff --git a/frontend/src/library/components/MovieDownloadPanel.jsx b/frontend/src/library/components/MovieDownloadPanel.jsx new file mode 100644 index 0000000..588376a --- /dev/null +++ b/frontend/src/library/components/MovieDownloadPanel.jsx @@ -0,0 +1,245 @@ +import { useState, useEffect } from 'react'; +import { formatBytes } from '../../utils'; +import { useAnimatedClose } from '../hooks/useAnimatedClose'; +import PosterImg from './PosterImg'; +import ManualSearchView from './ManualSearchView'; + +export default function MovieDownloadPanel({ movie, onClose, onDelete }) { + const { closing, close } = useAnimatedClose(onClose); + const [searching, setSearching] = useState(false); + const [result, setResult] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [manualMode, setManualMode] = useState(false); + const [fileInfo, setFileInfo] = useState(null); + + useEffect(() => { + if (!movie.hasFile) return; + fetch(`/api/library/movie/${movie.id}/file`, { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : null)) + .then(data => setFileInfo(data)) + .catch(() => {}); + }, [movie.id, movie.hasFile]); + + const handleSearch = async () => { + setSearching(true); + setResult(null); + try { + const resp = await fetch('/api/command/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: 'radarr', id: movie.id }), + }); + const data = await resp.json(); + setResult( + resp.ok + ? { success: true, message: 'Radarr is now searching for this movie' } + : { success: false, message: data.error }, + ); + } catch (err) { + setResult({ success: false, message: err.message }); + } + setSearching(false); + }; + + return ( + <div + className={`fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[20px] ${closing ? 'modal-backdrop-exit' : 'modal-backdrop-enter'}`} + onClick={close} + > + <div + className={`bg-bg-card rounded-2xl border border-border-subtle shadow-xl max-w-md w-full mx-4 ${closing ? 'modal-exit' : 'modal-enter'}`} + onClick={e => e.stopPropagation()} + > + <div className="flex gap-4 p-5"> + <div className="w-20 h-28 rounded-lg overflow-hidden relative flex-none"> + <PosterImg url={movie.posterUrl} fallbackIcon="movie" title={movie.title} /> + </div> + <div className="flex-1 min-w-0"> + <h3 className="text-[15px] font-semibold text-text-primary"> + {movie.title} {movie.year && `(${movie.year})`} + </h3> + <p className="text-[11px] text-text-muted mt-1"> + {movie.hasFile ? `Downloaded · ${movie.quality || 'Unknown quality'}` : 'Missing from disk'} + </p> + {movie.sizeOnDisk > 0 && <p className="text-[11px] text-text-muted">{formatBytes(movie.sizeOnDisk)}</p>} + {fileInfo && ( + <div className="mt-2 space-y-1"> + {fileInfo.resolution && ( + <div className="flex flex-wrap gap-1.5"> + {fileInfo.resolution && ( + <span className="text-[10px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-secondary"> + {fileInfo.resolution} + </span> + )} + {fileInfo.videoCodec && ( + <span className="text-[10px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-secondary"> + {fileInfo.videoCodec} + </span> + )} + {fileInfo.dynamicRange && ( + <span className="text-[10px] font-mono bg-accent-blue/10 border border-accent-blue/20 rounded px-1.5 py-0.5 text-accent-blue"> + {fileInfo.dynamicRange} + </span> + )} + {fileInfo.audioCodec && ( + <span className="text-[10px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-secondary"> + {fileInfo.audioCodec} + {fileInfo.audioChannels ? ` ${fileInfo.audioChannels}ch` : ''} + </span> + )} + {fileInfo.runTime && <span className="text-[10px] text-text-muted">{fileInfo.runTime}</span>} + </div> + )} + {fileInfo.path && ( + <p className="text-[10px] font-mono text-text-muted break-all leading-relaxed" title={fileInfo.path}> + <span className="text-text-muted/50">📁 </span> + {fileInfo.path} + </p> + )} + </div> + )} + </div> + <div className="flex flex-col gap-1 flex-none"> + <button + onClick={() => setConfirmDelete(!confirmDelete)} + className="p-1 rounded-md hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors" + title="Delete movie" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + delete + </span> + </button> + <button + onClick={close} + className="p-1 rounded-md hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + close + </span> + </button> + </div> + </div> + {confirmDelete && ( + <div className="px-5 py-3 bg-accent-red/5 border-b border-border-subtle"> + <p className="text-[12px] text-accent-red font-medium mb-2">Delete this movie?</p> + <div className="flex items-center gap-2"> + <button + onClick={async () => { + setDeleting(true); + try { + const resp = await fetch(`/api/delete/movie/${movie.id}?deleteFiles=true`, { method: 'DELETE' }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.error?.substring(0, 100) || 'Delete failed'); + } + onDelete?.(); + onClose(); + } catch (err) { + setResult({ success: false, message: `Delete failed: ${err.message}` }); + } + setDeleting(false); + setConfirmDelete(false); + }} + disabled={deleting} + className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent-red text-white hover:bg-accent-red/90 disabled:opacity-50" + > + {deleting ? 'Deleting...' : 'Delete + Remove Files'} + </button> + <button + onClick={async () => { + setDeleting(true); + try { + const resp = await fetch(`/api/delete/movie/${movie.id}?deleteFiles=false`, { method: 'DELETE' }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.error?.substring(0, 100) || 'Delete failed'); + } + onDelete?.(); + onClose(); + } catch (err) { + setResult({ success: false, message: `Delete failed: ${err.message}` }); + } + setDeleting(false); + setConfirmDelete(false); + }} + disabled={deleting} + className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-bg-surface border border-border-subtle text-text-primary hover:bg-bg-hover disabled:opacity-50" + > + Remove from Radarr Only + </button> + <button + onClick={() => setConfirmDelete(false)} + className="px-3 py-1.5 rounded-lg text-[11px] text-text-muted hover:text-text-primary" + > + Cancel + </button> + </div> + </div> + )} + <div className="px-5 pb-5"> + {result && ( + <div + className={`mb-3 px-3 py-2 rounded-lg text-[11px] font-medium ${result.success ? 'bg-accent-green/10 text-accent-green' : 'bg-accent-red/10 text-accent-red'}`} + > + {result.message} + </div> + )} + <div className="flex gap-2 mt-1"> + <button + onClick={handleSearch} + disabled={searching} + className={`flex-1 py-2.5 rounded-xl text-[12px] font-semibold transition-all duration-150 active:scale-[0.97] flex items-center justify-center gap-2 ${ + searching + ? 'bg-bg-surface-2 text-text-muted cursor-not-allowed' + : movie.hasFile + ? 'bg-bg-surface border border-border-medium text-text-primary hover:bg-bg-hover' + : 'bg-accent-blue text-white hover:bg-accent-blue/90' + }`} + > + {searching ? ( + <> + <span className="material-symbols-rounded animate-spin" style={{ fontSize: 14 }}> + progress_activity + </span>{' '} + Searching... + </> + ) : movie.hasFile ? ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + upgrade + </span>{' '} + Search for Upgrade + </> + ) : ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + download + </span>{' '} + Auto + </> + )} + </button> + <button + onClick={() => setManualMode(v => !v)} + className={`px-4 py-2.5 rounded-xl text-[12px] font-semibold transition-all duration-150 active:scale-[0.97] flex items-center justify-center gap-1.5 border ${manualMode ? 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue' : 'border-border-medium text-text-primary hover:bg-bg-hover'}`} + > + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + manage_search + </span> + Manual + </button> + </div> + {manualMode && ( + <ManualSearchView + service="radarr" + id={movie.id} + title={movie.title} + onGrabbed={() => setResult({ success: true, message: 'Release grabbed — downloading shortly' })} + /> + )} + </div> + </div> + </div> + ); +} diff --git a/frontend/src/library/components/PosterImg.jsx b/frontend/src/library/components/PosterImg.jsx new file mode 100644 index 0000000..bbc5603 --- /dev/null +++ b/frontend/src/library/components/PosterImg.jsx @@ -0,0 +1,32 @@ +import { useState, useEffect } from 'react'; +import { gradientFor } from '../../utils'; + +export default function PosterImg({ url, fallbackIcon, title }) { + const [failed, setFailed] = useState(false); + useEffect(() => { + setFailed(false); + }, [url]); + if (!url || failed) { + return ( + <div className="absolute inset-0 flex items-center justify-center" style={{ background: gradientFor(title) }}> + <span + className="material-symbols-rounded" + style={{ fontSize: 40, fontVariationSettings: "'FILL' 1", color: 'rgba(255,255,255,0.25)' }} + > + {fallbackIcon} + </span> + </div> + ); + } + const src = url.startsWith('/api/') ? url : `/api/poster?url=${encodeURIComponent(url)}`; + return ( + <img + src={src} + alt={title} + decoding="async" + onError={() => setFailed(true)} + className="absolute inset-0 w-full h-full object-cover" + style={{ opacity: 1, transition: 'opacity 320ms ease-out' }} + /> + ); +} diff --git a/frontend/src/library/components/SeasonSelector.jsx b/frontend/src/library/components/SeasonSelector.jsx new file mode 100644 index 0000000..c38e20e --- /dev/null +++ b/frontend/src/library/components/SeasonSelector.jsx @@ -0,0 +1,39 @@ +export default function SeasonSelector({ seasons, selected, onChange }) { + if (!seasons?.length) return null; + const toggleSeason = sn => { + onChange(selected.includes(sn) ? selected.filter(s => s !== sn) : [...selected, sn]); + }; + const allSelected = seasons.every(s => selected.includes(s.seasonNumber)); + const toggleAll = () => { + onChange(allSelected ? [] : seasons.map(s => s.seasonNumber)); + }; + return ( + <div className="mt-3"> + <div className="flex items-center justify-between mb-2"> + <label className="text-[11px] text-text-muted font-medium">Select Seasons</label> + <button onClick={toggleAll} className="text-[10px] text-accent-blue hover:underline"> + {allSelected ? 'Deselect All' : 'Select All'} + </button> + </div> + <div className="space-y-1 max-h-40 overflow-y-auto scroll-area"> + {seasons + .filter(s => s.seasonNumber > 0) + .map(s => ( + <label + key={s.seasonNumber} + className="flex items-center gap-2.5 px-3 py-1.5 rounded-md hover:bg-bg-hover cursor-pointer transition-colors" + > + <input + type="checkbox" + checked={selected.includes(s.seasonNumber)} + onChange={() => toggleSeason(s.seasonNumber)} + className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer" + /> + <span className="text-[12px] text-text-primary flex-1">Season {s.seasonNumber}</span> + {s.episodeCount > 0 && <span className="text-[10px] text-text-muted font-mono">{s.episodeCount} ep</span>} + </label> + ))} + </div> + </div> + ); +} diff --git a/frontend/src/library/components/Select.jsx b/frontend/src/library/components/Select.jsx new file mode 100644 index 0000000..79d7e45 --- /dev/null +++ b/frontend/src/library/components/Select.jsx @@ -0,0 +1,18 @@ +export default function Select({ label, value, onChange, options, className }) { + return ( + <div className={className}> + <label className="block text-[11px] text-text-muted mb-1 font-medium">{label}</label> + <select + value={value} + onChange={e => onChange(e.target.value)} + className="add-select w-full px-3 py-2 bg-bg-card border border-border-subtle rounded-lg text-[12px] text-text-primary focus:outline-none focus:border-accent-blue focus:ring-1 focus:ring-accent-blue/30 appearance-none cursor-pointer" + > + {options.map(o => ( + <option key={o.value ?? o.id} value={o.value ?? o.id}> + {o.label ?? o.name} + </option> + ))} + </select> + </div> + ); +} diff --git a/frontend/src/library/components/SeriesDetail.jsx b/frontend/src/library/components/SeriesDetail.jsx new file mode 100644 index 0000000..89433bd --- /dev/null +++ b/frontend/src/library/components/SeriesDetail.jsx @@ -0,0 +1,478 @@ +import { useState, useEffect } from 'react'; +import { formatBytes } from '../../utils'; +import { useAnimatedClose } from '../hooks/useAnimatedClose'; +import ManualSearchView from './ManualSearchView'; + +export default function SeriesDetail({ seriesId, onClose, onDelete }) { + const { closing, close } = useAnimatedClose(onClose); + const [episodes, setEpisodes] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [expandedSeason, setExpandedSeason] = useState(null); + const [expandedEpisode, setExpandedEpisode] = useState(null); + const [selectedSeasons, setSelectedSeasons] = useState([]); + const [searching, setSearching] = useState(false); + const [searchResult, setSearchResult] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const [manualMode, setManualMode] = useState(false); + + const fetchEpisodes = (isRefresh = false) => { + if (isRefresh) setRefreshing(true); + else setLoading(true); + fetch(`/api/library/series/${seriesId}/episodes`, { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : null)) + .then(data => { + setEpisodes(data?.seasons || {}); + }) + .catch(() => {}) + .finally(() => { + setLoading(false); + setRefreshing(false); + }); + }; + + useEffect(() => { + fetchEpisodes(); + }, [seriesId]); + + const seasonNums = episodes + ? Object.keys(episodes) + .map(Number) + .sort((a, b) => a - b) + : []; + const missingSeason = sn => (episodes[sn] || []).filter(e => !e.hasFile).length; + const totalMissing = seasonNums.reduce((acc, sn) => acc + missingSeason(sn), 0); + const allComplete = !loading && seasonNums.length > 0 && totalMissing === 0; + + const selectedComplete = selectedSeasons.filter(sn => missingSeason(sn) === 0); + const selectedStillMissing = selectedSeasons.filter(sn => missingSeason(sn) > 0); + + const handleDownload = async () => { + if (selectedSeasons.length === 0) return; + setSearching(true); + setSearchResult(null); + try { + const resp = await fetch('/api/command/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: 'sonarr', id: seriesId, seasonNumbers: selectedSeasons }), + }); + const data = await resp.json(); + setSearchResult( + resp.ok + ? { success: true, message: `Search triggered for ${selectedSeasons.length} season(s) — check back soon` } + : { success: false, message: data.error }, + ); + } catch (err) { + setSearchResult({ success: false, message: err.message }); + } + setSearching(false); + }; + + const handleDelete = async deleteFiles => { + setDeleting(true); + try { + const resp = await fetch(`/api/delete/series/${seriesId}?deleteFiles=${deleteFiles}`, { method: 'DELETE' }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.error?.substring(0, 100) || 'Delete failed'); + } + onDelete?.(); + onClose(); + } catch (err) { + setSearchResult({ success: false, message: `Delete failed: ${err.message}` }); + } + setDeleting(false); + setConfirmDelete(false); + }; + + return ( + <div + className={`fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-[20px] ${closing ? 'modal-backdrop-exit' : 'modal-backdrop-enter'}`} + onClick={close} + > + <div + className={`bg-bg-card rounded-2xl border border-border-subtle shadow-xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col ${closing ? 'modal-exit' : 'modal-enter'}`} + onClick={e => e.stopPropagation()} + > + <div className="flex items-center justify-between px-5 py-3.5 border-b border-border-subtle"> + <h3 className="text-[15px] font-semibold text-text-primary">Seasons & Episodes</h3> + <div className="flex items-center gap-1"> + <button + onClick={() => fetchEpisodes(true)} + disabled={refreshing} + className="p-1 rounded-md hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors" + title="Refresh" + > + <span className={`material-symbols-rounded ${refreshing ? 'animate-spin' : ''}`} style={{ fontSize: 18 }}> + refresh + </span> + </button> + <button + onClick={() => setConfirmDelete(!confirmDelete)} + className="p-1 rounded-md hover:bg-accent-red/10 text-text-muted hover:text-accent-red transition-colors" + title="Delete series" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + delete + </span> + </button> + <button + onClick={close} + className="p-1 rounded-md hover:bg-bg-hover text-text-muted hover:text-text-primary transition-colors" + > + <span className="material-symbols-rounded" style={{ fontSize: 20 }}> + close + </span> + </button> + </div> + </div> + {confirmDelete && ( + <div className="px-5 py-3 bg-accent-red/5 border-b border-accent-red/20"> + <p className="text-[12px] text-accent-red font-medium mb-2">Delete this series?</p> + <div className="flex items-center gap-2"> + <button + onClick={() => handleDelete(true)} + disabled={deleting} + className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-accent-red text-white hover:bg-accent-red/90 disabled:opacity-50" + > + {deleting ? 'Deleting...' : 'Delete + Remove Files'} + </button> + <button + onClick={() => handleDelete(false)} + disabled={deleting} + className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-bg-surface border border-border-subtle text-text-primary hover:bg-bg-hover disabled:opacity-50" + > + Remove from Sonarr Only + </button> + <button + onClick={() => setConfirmDelete(false)} + className="px-3 py-1.5 rounded-lg text-[11px] text-text-muted hover:text-text-primary" + > + Cancel + </button> + </div> + </div> + )} + <div className="flex-1 overflow-y-auto scroll-area p-4"> + {loading ? ( + <div className="flex items-center justify-center py-12"> + <span className="material-symbols-rounded animate-spin text-text-muted" style={{ fontSize: 24 }}> + progress_activity + </span> + </div> + ) : seasonNums.length === 0 ? ( + <p className="text-center text-text-muted text-[13px] py-8">No episodes found</p> + ) : ( + <div className="space-y-1.5"> + {seasonNums.map(sn => { + const eps = episodes[sn]; + const downloaded = eps.filter(e => e.hasFile).length; + const missing = eps.length - downloaded; + const isOpen = expandedSeason === sn; + const isSelected = selectedSeasons.includes(sn); + const seasonComplete = missing === 0; + return ( + <div + key={sn} + className={`rounded-lg border overflow-hidden ${seasonComplete ? 'border-accent-green/20' : 'border-border-subtle'}`} + > + <div + className={`flex items-center gap-2 px-4 py-2.5 hover:bg-bg-hover transition-colors ${seasonComplete ? 'bg-accent-green/5' : ''}`} + > + {missing > 0 ? ( + <input + type="checkbox" + checked={isSelected} + onChange={() => + setSelectedSeasons(prev => (isSelected ? prev.filter(s => s !== sn) : [...prev, sn])) + } + className="w-3.5 h-3.5 rounded border-border-medium text-accent-blue focus:ring-accent-blue/30 cursor-pointer" + /> + ) : ( + <span className="material-symbols-rounded text-accent-green" style={{ fontSize: 16 }}> + check_circle + </span> + )} + <button + onClick={() => setExpandedSeason(isOpen ? null : sn)} + className="flex items-center gap-2 flex-1 text-left" + > + <span + className="material-symbols-rounded text-text-muted" + style={{ + fontSize: 18, + transition: 'transform 0.2s', + transform: isOpen ? 'rotate(90deg)' : 'none', + }} + > + chevron_right + </span> + <span className="text-[13px] font-semibold text-text-primary"> + {sn === 0 ? 'Specials' : `Season ${sn}`} + </span> + {seasonComplete && ( + <span className="text-[10px] font-semibold text-accent-green">Complete</span> + )} + </button> + <span className="text-[11px] font-mono text-text-muted"> + <span className={seasonComplete ? 'text-accent-green' : 'text-accent-orange'}> + {downloaded} + </span> + /{eps.length} + </span> + <div className="w-16 h-1 rounded-full bg-bg-surface-2 overflow-hidden"> + <div + className="h-full rounded-full" + style={{ + width: `${eps.length > 0 ? (downloaded / eps.length) * 100 : 0}%`, + background: seasonComplete ? '#16a34a' : '#d97706', + }} + /> + </div> + </div> + {isOpen && ( + <div className="border-t border-border-subtle"> + {eps.map(ep => { + const epExpanded = expandedEpisode === ep.id; + return ( + <div key={ep.id} className="border-b border-border-subtle last:border-b-0"> + <div + className={`px-4 py-2 hover:bg-bg-hover/50 ${ep.hasFile ? 'cursor-pointer' : ''}`} + onClick={() => ep.hasFile && setExpandedEpisode(epExpanded ? null : ep.id)} + > + <div className="flex items-center gap-2 text-[12px]"> + {ep.imageUrl && ( + <div className="w-[72px] h-[40px] rounded flex-none overflow-hidden relative bg-bg-surface-2 flex-shrink-0"> + <img + src={ep.imageUrl} + alt="" + className="absolute inset-0 w-full h-full object-cover" + loading="eager" + onError={e => { + e.target.style.display = 'none'; + e.target.parentElement.style.display = 'none'; + }} + /> + </div> + )} + <span className="font-mono text-text-muted w-8 text-right flex-none"> + E{String(ep.episodeNumber).padStart(2, '0')} + </span> + <span + className={`w-2 h-2 rounded-full flex-none ${ep.hasFile ? 'bg-accent-green' : 'bg-border-medium'}`} + /> + <span className="text-text-primary truncate flex-1">{ep.title || 'TBA'}</span> + {ep.runTime && ( + <span className="text-[10px] font-mono text-text-muted flex-none"> + {ep.runTime} + </span> + )} + {!ep.runTime && ep.runtime && ( + <span className="text-[10px] font-mono text-text-muted flex-none"> + {ep.runtime}m + </span> + )} + {ep.quality && ( + <span className="text-[10px] font-mono text-accent-blue flex-none"> + {ep.quality} + </span> + )} + {ep.size > 0 && ( + <span className="text-[10px] font-mono text-text-muted flex-none"> + {formatBytes(ep.size)} + </span> + )} + {ep.hasFile && ( + <span + className="material-symbols-rounded text-text-muted/50 flex-none" + style={{ fontSize: 13 }} + > + {epExpanded ? 'expand_less' : 'expand_more'} + </span> + )} + </div> + </div> + {epExpanded && ep.hasFile && ( + <div className="px-4 pb-3 bg-bg-surface/50 border-t border-border-subtle/50"> + <div className="ml-11 pt-2 space-y-1.5"> + <div className="flex flex-wrap gap-1.5 items-center"> + {ep.resolution && ( + <span className="text-[9px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-muted"> + {ep.resolution} + </span> + )} + {ep.videoCodec && ( + <span className="text-[9px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-muted"> + {ep.videoCodec} + </span> + )} + {ep.dynamicRange && ep.dynamicRange !== 'SDR' && ( + <span className="text-[9px] font-mono bg-accent-orange/10 border border-accent-orange/30 rounded px-1.5 py-0.5 text-accent-orange"> + {ep.dynamicRange} + </span> + )} + {ep.audioCodec && ( + <span className="text-[9px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-muted"> + {ep.audioCodec} + {ep.audioChannels ? ` ${ep.audioChannels}ch` : ''} + </span> + )} + {ep.audioLanguages && ( + <span className="text-[9px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-muted"> + {ep.audioLanguages} + </span> + )} + {ep.subtitles && ( + <span className="text-[9px] font-mono bg-bg-surface border border-border-subtle rounded px-1.5 py-0.5 text-text-muted"> + Sub: {ep.subtitles} + </span> + )} + </div> + {ep.filePath && ( + <div className="flex items-start gap-1.5"> + <span + className="material-symbols-rounded text-text-muted/60 mt-px flex-none" + style={{ fontSize: 12 }} + > + folder + </span> + <span className="text-[10px] font-mono text-text-muted/70 break-all leading-relaxed"> + {ep.filePath} + </span> + </div> + )} + </div> + </div> + )} + </div> + ); + })} + </div> + )} + </div> + ); + })} + </div> + )} + </div> + {/* Footer: completion status or download controls */} + {!loading && seasonNums.length > 0 && ( + <div className="px-5 py-3 border-t border-border-subtle bg-bg-surface"> + {allComplete ? ( + <div className="flex items-center gap-2 py-1"> + <span className="material-symbols-rounded text-accent-green" style={{ fontSize: 20 }}> + check_circle + </span> + <div> + <p className="text-[12px] font-semibold text-accent-green">All content downloaded</p> + <p className="text-[10px] text-text-muted"> + {seasonNums.filter(sn => sn > 0).length} season(s) ·{' '} + {seasonNums.reduce((a, sn) => a + (episodes[sn]?.length || 0), 0)} episodes + </p> + </div> + </div> + ) : ( + <> + {selectedSeasons.length > 0 && selectedComplete.length > 0 && ( + <div className="mb-2 px-3 py-2 rounded-lg text-[11px] font-medium bg-accent-green/10 text-accent-green"> + Season(s){' '} + {selectedComplete.map(sn => (sn === 0 ? 'Specials' : `S${String(sn).padStart(2, '0')}`)).join(', ')}{' '} + — complete + {selectedStillMissing.length > 0 && ( + <span className="text-accent-orange"> + {' '} + ·{' '} + {selectedStillMissing + .map(sn => (sn === 0 ? 'Specials' : `S${String(sn).padStart(2, '0')}`)) + .join(', ')}{' '} + still downloading ({selectedStillMissing.reduce((a, sn) => a + missingSeason(sn), 0)} ep + missing) + </span> + )} + </div> + )} + {searchResult && ( + <div + className={`mb-2 px-3 py-2 rounded-lg text-[11px] font-medium ${searchResult.success ? 'bg-accent-green/10 text-accent-green' : 'bg-accent-red/10 text-accent-red'}`} + > + {searchResult.message} + </div> + )} + <div className="flex items-center justify-between"> + <span className="text-[11px] text-text-muted"> + {totalMissing} episode{totalMissing !== 1 ? 's' : ''} missing · {selectedSeasons.length} season(s) + selected + {selectedSeasons.length === 0 && ( + <button + onClick={() => setSelectedSeasons(seasonNums.filter(sn => missingSeason(sn) > 0))} + className="text-accent-blue hover:underline ml-2" + > + Select all missing + </button> + )} + </span> + <div className="flex items-center gap-2"> + <button + onClick={() => setManualMode(v => !v)} + disabled={selectedSeasons.length === 0} + className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[12px] font-semibold transition-all duration-150 active:scale-[0.97] border ${ + selectedSeasons.length === 0 + ? 'border-border-subtle text-text-muted cursor-not-allowed' + : manualMode + ? 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue' + : 'border-border-subtle text-text-muted hover:text-text-primary hover:border-border-medium' + }`} + > + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + manage_search + </span> + Manual + </button> + <button + onClick={handleDownload} + disabled={selectedSeasons.length === 0 || searching} + className={`flex items-center gap-1.5 px-4 py-2 rounded-xl text-[12px] font-semibold transition-all duration-150 active:scale-[0.97] ${ + selectedSeasons.length === 0 || searching + ? 'bg-bg-surface-2 text-text-muted cursor-not-allowed' + : 'bg-accent-blue text-white hover:bg-accent-blue/90' + }`} + > + {searching ? ( + <> + <span className="material-symbols-rounded animate-spin" style={{ fontSize: 14 }}> + progress_activity + </span>{' '} + Searching... + </> + ) : ( + <> + <span className="material-symbols-rounded" style={{ fontSize: 14 }}> + download + </span>{' '} + Auto + </> + )} + </button> + </div> + </div> + {manualMode && selectedSeasons.length > 0 && ( + <ManualSearchView + service="sonarr" + id={seriesId} + seasonNumber={selectedSeasons[0]} + title={null} + onGrabbed={() => + setSearchResult({ success: true, message: 'Release grabbed — downloading shortly' }) + } + /> + )} + </> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/frontend/src/library/components/cards/ArtistCard.jsx b/frontend/src/library/components/cards/ArtistCard.jsx new file mode 100644 index 0000000..bbd7be7 --- /dev/null +++ b/frontend/src/library/components/cards/ArtistCard.jsx @@ -0,0 +1,62 @@ +import { memo } from 'react'; +import PosterImg from '../PosterImg'; + +function _ArtistCard({ artist, onClick }) { + return ( + <div onClick={onClick} className="poster-card" style={{ borderRadius: 12 }}> + <div style={{ aspectRatio: '1/1', position: 'relative', overflow: 'hidden', borderRadius: 12 }}> + <PosterImg url={artist.posterUrl} fallbackIcon="album" title={artist.artistName} /> + <div className="poster-film-grain" /> + <div + style={{ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '60%', + background: 'linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)', + pointerEvents: 'none', + zIndex: 1, + }} + /> + <div style={{ position: 'absolute', bottom: 10, left: 10, right: 10, zIndex: 3 }}> + <div + style={{ + fontSize: 11, + fontWeight: 800, + color: 'rgba(255,255,255,0.92)', + letterSpacing: '-0.01em', + lineHeight: 1.2, + textShadow: '0 2px 12px rgba(0,0,0,0.8)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + marginBottom: 4, + }} + > + {artist.artistName.toUpperCase()} + </div> + {artist.albumCount > 0 && ( + <span + style={{ + fontSize: 9.5, + fontWeight: 600, + color: + artist.downloadedAlbumCount > 0 && artist.downloadedAlbumCount < artist.albumCount + ? 'rgba(255,159,10,0.9)' + : 'rgba(235,235,245,0.55)', + letterSpacing: '0.03em', + }} + > + {artist.downloadedAlbumCount > 0 + ? `${artist.downloadedAlbumCount}/${artist.albumCount}` + : artist.albumCount}{' '} + album{artist.albumCount !== 1 ? 's' : ''} + </span> + )} + </div> + </div> + </div> + ); +} +export default memo(_ArtistCard, (a, b) => a.artist === b.artist); diff --git a/frontend/src/library/components/cards/MovieCard.jsx b/frontend/src/library/components/cards/MovieCard.jsx new file mode 100644 index 0000000..a9fcbd4 --- /dev/null +++ b/frontend/src/library/components/cards/MovieCard.jsx @@ -0,0 +1,94 @@ +import { memo } from 'react'; +import PosterImg from '../PosterImg'; + +function _MovieCard({ movie, onClick, queued }) { + return ( + <div onClick={onClick} className="poster-card" style={{ borderRadius: 12 }}> + <div style={{ aspectRatio: '2/3', position: 'relative', overflow: 'hidden', borderRadius: 12 }}> + <PosterImg url={movie.posterUrl} fallbackIcon="movie" title={movie.title} /> + <div className="poster-film-grain" /> + <div + style={{ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '65%', + background: 'linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)', + pointerEvents: 'none', + zIndex: 1, + }} + /> + {/* QUEUED badge (top-left) */} + {queued && ( + <div + style={{ + position: 'absolute', + top: 8, + left: 8, + background: 'rgba(10,132,255,0.25)', + border: '1px solid rgba(10,132,255,0.5)', + color: '#0A84FF', + fontSize: 9, + fontWeight: 700, + padding: '2px 6px', + borderRadius: 5, + zIndex: 6, + backdropFilter: 'blur(8px)', + letterSpacing: '0.04em', + }} + > + QUEUED + </div> + )} + {/* Downloaded / Downloading / Missing badge (top-right) */} + <div + style={{ + position: 'absolute', + top: 8, + right: 8, + background: movie.hasFile + ? 'rgba(48,209,88,0.18)' + : queued + ? 'rgba(10,132,255,0.18)' + : 'rgba(255,159,10,0.2)', + border: `1px solid ${movie.hasFile ? 'rgba(48,209,88,0.4)' : queued ? 'rgba(10,132,255,0.4)' : 'rgba(255,159,10,0.4)'}`, + color: movie.hasFile ? '#30D158' : queued ? '#0A84FF' : '#FF9F0A', + fontSize: 9.5, + fontWeight: 700, + padding: '3px 7px', + borderRadius: 6, + zIndex: 6, + backdropFilter: 'blur(8px)', + }} + > + {movie.hasFile ? 'DOWNLOADED' : queued ? 'DOWNLOADING' : 'MISSING'} + </div> + <div style={{ position: 'absolute', bottom: 10, left: 10, right: 10, zIndex: 3 }}> + <div + style={{ + fontSize: 11, + fontWeight: 800, + color: 'rgba(255,255,255,0.92)', + letterSpacing: '-0.01em', + lineHeight: 1.2, + textShadow: '0 2px 12px rgba(0,0,0,0.8)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + marginBottom: 4, + }} + > + {movie.title.toUpperCase()} + </div> + <span style={{ fontSize: 9.5, fontWeight: 600, color: 'rgba(235,235,245,0.55)', letterSpacing: '0.03em' }}> + {[movie.quality, movie.year && String(movie.year)].filter(Boolean).join(' · ') || + (movie.year && String(movie.year)) || + ''} + </span> + </div> + </div> + </div> + ); +} +export default memo(_MovieCard, (a, b) => a.movie === b.movie && a.queued === b.queued); diff --git a/frontend/src/library/components/cards/ResultCard.jsx b/frontend/src/library/components/cards/ResultCard.jsx new file mode 100644 index 0000000..3882582 --- /dev/null +++ b/frontend/src/library/components/cards/ResultCard.jsx @@ -0,0 +1,73 @@ +import { memo } from 'react'; +import { extractRating } from '../../../utils'; +import PosterImg from '../PosterImg'; + +export default memo(function ResultCard({ item, mediaType, onClick }) { + const rating = extractRating(item.ratings); + const isAlbum = mediaType === 'music-album'; + const isMusic = mediaType === 'music' || isAlbum; + const title = isAlbum ? item.title : mediaType === 'music' ? item.artistName : item.title; + const subtitle = isAlbum ? item.artistName : null; + const fallbackIcon = mediaType === 'movie' ? 'movie' : mediaType === 'series' ? 'tv' : 'album'; + const imgHeight = isMusic ? 160 : 280; + return ( + <div + onClick={item.inLibrary && !isAlbum ? undefined : onClick} + className={`library-card rounded-2xl overflow-hidden bg-bg-card border border-border-subtle ${item.inLibrary && !isAlbum ? 'opacity-60' : 'cursor-pointer'}`} + > + <div className="relative" style={{ height: imgHeight }}> + <PosterImg url={item.posterUrl} fallbackIcon={fallbackIcon} title={title} /> + <div className="poster-overlay absolute inset-x-0 bottom-0" style={{ height: '50%' }} /> + {item.inLibrary && !isAlbum && ( + <div className="absolute top-2.5 right-2.5 px-2 py-0.5 rounded bg-accent-green/90"> + <span className="text-[9px] font-bold text-white tracking-wider flex items-center gap-1"> + <span className="material-symbols-rounded" style={{ fontSize: 11, fontVariationSettings: "'FILL' 1" }}> + check + </span> + IN LIBRARY + </span> + </div> + )} + {isAlbum && item.albumType && ( + <div + className="absolute top-2 left-2 px-1.5 py-0.5 rounded text-[8px] font-bold tracking-wider" + style={{ + background: + item.albumType === 'Album' + ? 'rgba(10,132,255,0.85)' + : item.albumType === 'EP' + ? 'rgba(175,82,222,0.85)' + : 'rgba(255,159,10,0.85)', + color: '#fff', + }} + > + {item.albumType.toUpperCase()} + </div> + )} + </div> + <div className="px-2.5 pt-2 pb-2.5"> + <h3 className="text-[12px] font-semibold text-text-primary line-clamp-2 leading-snug">{title}</h3> + {subtitle && <p className="text-[10px] text-text-muted truncate">{subtitle}</p>} + <div className="flex items-center gap-1.5 mt-0.5 text-[10px] text-text-muted"> + {item.releaseDate && <span>{new Date(item.releaseDate).getFullYear()}</span>} + {item.year && !item.releaseDate && <span>{item.year}</span>} + {item.network && <span>· {item.network}</span>} + {item.studio && <span>· {item.studio}</span>} + {item.seasonCount && <span>· {item.seasonCount}S</span>} + {item.disambiguation && <span>· {item.disambiguation}</span>} + {rating && ( + <span className="flex items-center gap-0.5 text-accent-orange ml-auto"> + <span className="material-symbols-rounded" style={{ fontSize: 10, fontVariationSettings: "'FILL' 1" }}> + star + </span> + {rating} + </span> + )} + </div> + {!isAlbum && item.overview && ( + <p className="text-[10px] text-text-muted mt-1 line-clamp-2 leading-relaxed">{item.overview}</p> + )} + </div> + </div> + ); +}); diff --git a/frontend/src/library/components/cards/SeriesCard.jsx b/frontend/src/library/components/cards/SeriesCard.jsx new file mode 100644 index 0000000..9374902 --- /dev/null +++ b/frontend/src/library/components/cards/SeriesCard.jsx @@ -0,0 +1,104 @@ +import { memo } from 'react'; +import PosterImg from '../PosterImg'; + +function _SeriesCard({ series, onClick, queued }) { + const total = series.totalEpisodeCount || 0; + const have = series.episodeFileCount || 0; + const isComplete = total > 0 && have >= total; + const pct = total > 0 ? Math.min(100, Math.round((have / total) * 100)) : 0; + const badgeLabel = total === 0 ? null : isComplete ? 'Complete' : `${have}/${total} eps`; + const barColor = isComplete ? '#30D158' : '#FF9F0A'; + + return ( + <div onClick={onClick} className="poster-card" style={{ borderRadius: 12 }}> + <div style={{ aspectRatio: '2/3', position: 'relative', overflow: 'hidden', borderRadius: 12 }}> + <PosterImg url={series.posterUrl} fallbackIcon="tv" title={series.title} /> + <div className="poster-film-grain" /> + <div + style={{ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '65%', + background: 'linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)', + pointerEvents: 'none', + zIndex: 1, + }} + /> + {/* QUEUED badge */} + {queued && ( + <div + style={{ + position: 'absolute', + top: 8, + left: 8, + background: 'rgba(10,132,255,0.25)', + border: '1px solid rgba(10,132,255,0.5)', + color: '#0A84FF', + fontSize: 9, + fontWeight: 700, + padding: '2px 6px', + borderRadius: 5, + zIndex: 6, + backdropFilter: 'blur(8px)', + letterSpacing: '0.04em', + }} + > + QUEUED + </div> + )} + <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 4 }}> + {/* Completion bar */} + {total > 0 && ( + <div style={{ height: 3, background: 'rgba(255,255,255,0.12)', margin: '0 0 6px 0' }}> + <div style={{ height: '100%', width: `${pct}%`, background: barColor, transition: 'width 0.3s ease' }} /> + </div> + )} + <div style={{ padding: '0 10px 10px 10px' }}> + <div + style={{ + fontSize: 11, + fontWeight: 800, + color: 'rgba(255,255,255,0.92)', + letterSpacing: '-0.01em', + lineHeight: 1.2, + textShadow: '0 2px 12px rgba(0,0,0,0.8)', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + marginBottom: 4, + }} + > + {series.title.toUpperCase()} + </div> + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}> + {series.seasonCount > 0 && ( + <span + style={{ fontSize: 9.5, fontWeight: 600, color: 'rgba(235,235,245,0.55)', letterSpacing: '0.03em' }} + > + {series.seasonCount} Season{series.seasonCount !== 1 ? 's' : ''} + </span> + )} + {badgeLabel && ( + <span + style={{ + fontSize: 9, + fontWeight: 700, + color: isComplete ? '#30D158' : '#FF9F0A', + letterSpacing: '0.03em', + marginLeft: 'auto', + whiteSpace: 'nowrap', + }} + > + {badgeLabel} + </span> + )} + </div> + </div> + </div> + </div> + </div> + ); +} +export default memo(_SeriesCard, (a, b) => a.series === b.series && a.queued === b.queued); diff --git a/frontend/src/library/constants.js b/frontend/src/library/constants.js new file mode 100644 index 0000000..ea3308b --- /dev/null +++ b/frontend/src/library/constants.js @@ -0,0 +1,13 @@ +export const TYPE_FILTERS = [ + { key: 'all', label: 'All', icon: 'apps' }, + { key: 'series', label: 'TV', icon: 'tv' }, + { key: 'movie', label: 'Movies', icon: 'movie' }, + { key: 'music', label: 'Music', icon: 'album' }, + { key: 'missing', label: 'Missing', icon: 'warning' }, +]; + +export const ADD_TYPE_FILTERS = [ + { key: 'movie', label: 'Movies', icon: 'movie' }, + { key: 'series', label: 'TV', icon: 'tv' }, + { key: 'music', label: 'Music', icon: 'album' }, +]; diff --git a/frontend/src/library/hooks/useAnimatedClose.js b/frontend/src/library/hooks/useAnimatedClose.js new file mode 100644 index 0000000..c3acdc5 --- /dev/null +++ b/frontend/src/library/hooks/useAnimatedClose.js @@ -0,0 +1,12 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +export function useAnimatedClose(onClose, duration = 200) { + const [closing, setClosing] = useState(false); + const timerRef = useRef(null); + const close = useCallback(() => { + setClosing(true); + timerRef.current = setTimeout(onClose, duration); + }, [onClose, duration]); + useEffect(() => () => clearTimeout(timerRef.current), []); + return { closing, close }; +} diff --git a/frontend/src/library/hooks/useLibraryState.js b/frontend/src/library/hooks/useLibraryState.js new file mode 100644 index 0000000..2215eef --- /dev/null +++ b/frontend/src/library/hooks/useLibraryState.js @@ -0,0 +1,218 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; + +export function useLibraryState(externalQuery, onExternalQueryChange) { + const [mode, setMode] = useState('library'); + const [query, setQuery] = useState(externalQuery || ''); + const [activeType, setActiveType] = useState('all'); + const [results, setResults] = useState({ series: [], movies: [], artists: [] }); + const [lookupResults, setLookupResults] = useState([]); + const [musicSections, setMusicSections] = useState(null); + const [loading, setLoading] = useState(false); + const [initialLoaded, setInitialLoaded] = useState(false); + const [detailView, setDetailView] = useState(null); + const [addPanel, setAddPanel] = useState(null); + const [addType, setAddType] = useState('movie'); + const [queuedSeriesIds, setQueuedSeriesIds] = useState(new Set()); + const [queuedMovieIds, setQueuedMovieIds] = useState(new Set()); + const [refreshing, setRefreshing] = useState(false); + const debounceRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (externalQuery !== undefined && externalQuery !== query) { + setQuery(externalQuery); + setMode('library'); + } + }, [externalQuery]); + + const doLibrarySearch = useCallback((q, type) => { + setLoading(true); + fetch(`/api/library/search?q=${encodeURIComponent(q)}&type=${type}`, { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : { series: [], movies: [], artists: [] })) + .then(data => { + setResults(data); + setLoading(false); + setInitialLoaded(true); + }) + .catch(() => { + setLoading(false); + setInitialLoaded(true); + }); + }, []); + + const doLookupSearch = useCallback((q, type) => { + if (!q.trim()) { + setLookupResults([]); + setMusicSections(null); + return; + } + setLoading(true); + const endpoint = type === 'movie' ? 'movie' : type === 'series' ? 'series' : 'music'; + fetch(`/api/lookup/${endpoint}?term=${encodeURIComponent(q)}`, { cache: 'no-store' }) + .then(r => + r.ok ? r.json() : type === 'music' ? { artists: [], albums: [], singles: [], topCategory: 'artists' } : [], + ) + .then(data => { + if (type === 'music' && data && !Array.isArray(data)) { + setMusicSections(data); + setLookupResults([...data.artists, ...data.albums, ...data.singles]); + } else { + setMusicSections(null); + setLookupResults(Array.isArray(data) ? data : []); + } + setLoading(false); + }) + .catch(() => { + setLookupResults([]); + setMusicSections(null); + setLoading(false); + }); + }, []); + + const fetchQueue = useCallback(() => { + fetch('/api/arr-queue', { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : [])) + .then(items => { + const sIds = new Set(); + const mIds = new Set(); + for (const item of items) { + if (item.service === 'sonarr' && item.seriesId) sIds.add(item.seriesId); + if (item.service === 'radarr' && item.movieId) mIds.add(item.movieId); + } + setQueuedSeriesIds(sIds); + setQueuedMovieIds(mIds); + }) + .catch(() => {}); + }, []); + + const handleRefresh = useCallback(() => { + setRefreshing(true); + fetch('/api/library/refresh', { cache: 'no-store' }) + .then(() => { + doLibrarySearch(query, activeType); + fetchQueue(); + }) + .catch(() => {}) + .finally(() => setRefreshing(false)); + }, [query, activeType, doLibrarySearch, fetchQueue]); + + useEffect(() => { + doLibrarySearch('', 'all'); + fetchQueue(); + const poll = setInterval(() => { + doLibrarySearch('', 'all'); + fetchQueue(); + }, 60000); + return () => clearInterval(poll); + }, []); + + useEffect(() => { + clearTimeout(debounceRef.current); + if (mode === 'library') { + if (!initialLoaded) return; + debounceRef.current = setTimeout(() => doLibrarySearch(query, activeType), 300); + } else { + if (!query.trim()) { + setLookupResults([]); + return; + } + debounceRef.current = setTimeout(() => doLookupSearch(query, addType), 400); + } + return () => clearTimeout(debounceRef.current); + }, [query, activeType, mode, addType]); + + useEffect(() => { + setLookupResults([]); + setMusicSections(null); + if (mode === 'library') { + setQuery(''); + doLibrarySearch('', activeType); + } + }, [mode]); + + const { missingSeries, missingMovies } = useMemo( + () => ({ + missingSeries: results.series + .filter(s => s.monitored && s.totalEpisodeCount > 0 && s.episodeFileCount < s.totalEpisodeCount) + .sort((a, b) => b.totalEpisodeCount - b.episodeFileCount - (a.totalEpisodeCount - a.episodeFileCount)), + missingMovies: results.movies.filter(m => m.monitored && !m.hasFile), + }), + [results.series, results.movies], + ); + + const isMissingFilter = activeType === 'missing'; + + const visibleSeries = isMissingFilter + ? missingSeries + : activeType === 'all' || activeType === 'series' + ? results.series + : []; + const visibleMovies = isMissingFilter + ? missingMovies + : activeType === 'all' || activeType === 'movie' + ? results.movies + : []; + const visibleArtists = isMissingFilter ? [] : activeType === 'all' || activeType === 'music' ? results.artists : []; + + const totalLibrary = visibleSeries.length + visibleMovies.length + visibleArtists.length; + + const handleQueryChange = value => { + setQuery(value); + onExternalQueryChange?.(value); + }; + + const clearQuery = () => { + setQuery(''); + onExternalQueryChange?.(''); + inputRef.current?.focus(); + }; + + const switchToAddMode = () => { + const typeMap = { series: 'series', movie: 'movie', music: 'music' }; + if (typeMap[activeType]) setAddType(typeMap[activeType]); + setMode('add'); + }; + + const refreshAfterDelete = () => { + setDetailView(null); + doLibrarySearch('', activeType); + }; + + const refreshAfterAdd = () => { + setAddPanel(null); + doLibrarySearch('', 'all'); + }; + + return { + mode, + setMode, + query, + activeType, + setActiveType, + loading, + initialLoaded, + detailView, + setDetailView, + addPanel, + setAddPanel, + addType, + setAddType, + queuedSeriesIds, + queuedMovieIds, + refreshing, + inputRef, + lookupResults, + musicSections, + isMissingFilter, + visibleSeries, + visibleMovies, + visibleArtists, + totalLibrary, + handleRefresh, + handleQueryChange, + clearQuery, + switchToAddMode, + refreshAfterDelete, + refreshAfterAdd, + }; +} diff --git a/frontend/src/library/utils/releaseDetection.js b/frontend/src/library/utils/releaseDetection.js new file mode 100644 index 0000000..53a7b15 --- /dev/null +++ b/frontend/src/library/utils/releaseDetection.js @@ -0,0 +1,26 @@ +export function detectResolution(text) { + if (/2160p|4k(?!\w)|uhd/i.test(text)) return '2160p'; + if (/1080p/i.test(text)) return '1080p'; + if (/720p/i.test(text)) return '720p'; + if (/576p|480p/i.test(text)) return '480p'; + return null; +} + +export function detectSource(text) { + if (/remux/i.test(text)) return 'Remux'; + if (/blu-?ray|bdrip|brrip|bdremux/i.test(text)) return 'Bluray'; + if (/web-?dl|webdl/i.test(text)) return 'WEBDL'; + if (/webrip/i.test(text)) return 'WEBRip'; + if (/hdtv/i.test(text)) return 'HDTV'; + if (/dvdrip|dvdscr|dvd/i.test(text)) return 'DVD'; + if (/\bcam\b|telesync|\bts\b|telecine/i.test(text)) return 'CAM'; + return 'Unknown'; +} + +export function detectCodec(title) { + const t = title || ''; + if (/\bAV1\b/i.test(t)) return 'AV1'; + if (/\bHEVC\b|\bx265\b|h\.?265/i.test(t)) return 'x265'; + if (/\bx264\b|\bh\.?264\b|\bAVC\b/i.test(t)) return 'x264'; + return null; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 47742c1..303ff4d 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -6,5 +6,5 @@ import './index.css'; ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> - </React.StrictMode> + </React.StrictMode>, ); diff --git a/frontend/src/test/utils.test.js b/frontend/src/test/utils.test.js index bcf0bac..9c7cc95 100644 --- a/frontend/src/test/utils.test.js +++ b/frontend/src/test/utils.test.js @@ -46,7 +46,7 @@ describe('formatETA', () => { it('formats seconds to human-readable', () => { expect(formatETA(3661)).toBe('1h 1m'); expect(formatETA(60)).toBe('1m'); - expect(formatETA(30)).toBe('30s'); + expect(formatETA(30)).toBe('<1m'); }); }); diff --git a/frontend/src/utils.js b/frontend/src/utils.js index e3e5c88..63bd113 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,3 +1,5 @@ +import { TORRENT_UNKNOWN_ETA_SECONDS } from './constants'; + /** Cleans a raw container/service name into a human-readable title. */ export function cleanName(name) { return name @@ -14,7 +16,7 @@ const _BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; /** Formats bytes into a human-readable string (e.g. "1.4 GB"). */ export function formatBytes(bytes) { - if (!bytes) return '0 B'; + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; const k = 1024; const i = Math.min(_BYTE_UNITS.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); return _nfBytes.format(bytes / Math.pow(k, i)) + ' ' + _BYTE_UNITS[i]; @@ -28,9 +30,9 @@ export function formatSpeed(bps) { /** Formats a seconds-remaining value into a human-readable ETA string (e.g. "1h 23m"). */ export function formatETA(seconds) { - // qBittorrent uses 8640000 as its "unknown ETA" sentinel. - if (!seconds || seconds === 8640000 || seconds < 0) return '--'; - if (seconds < 60) return `${Math.max(1, Math.floor(seconds))}s`; + // qBittorrent uses this sentinel for unknown ETA. + if (!seconds || seconds === TORRENT_UNKNOWN_ETA_SECONDS || seconds < 0) return '--'; + if (seconds < 60) return '<1m'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); if (h > 0) return `${h}h ${m}m`; @@ -42,8 +44,16 @@ export function getTorrentState(t) { if (t.state === 'error') return 'error'; // qBittorrent can report finished-but-moved data as missingFiles. if (t.state === 'missingFiles') return 'completed'; - if (t.state === 'pausedDL' || t.state === 'pausedUP' || t.state === 'stoppedDL' || t.state === 'stoppedUP') return 'paused'; - if (t.progress >= 100 || t.state === 'uploading' || t.state === 'stalledUP' || t.state === 'forcedUP' || t.state === 'queuedUP') return 'seeding'; + if (t.state === 'pausedDL' || t.state === 'pausedUP' || t.state === 'stoppedDL' || t.state === 'stoppedUP') + return 'paused'; + if ( + t.progress >= 100 || + t.state === 'uploading' || + t.state === 'stalledUP' || + t.state === 'forcedUP' || + t.state === 'queuedUP' + ) + return 'seeding'; return 'downloading'; } @@ -64,7 +74,7 @@ export const GRADIENTS = [ /** Returns a deterministic gradient CSS string for a given title string. */ export function gradientFor(str) { let h = 0; - for (const c of (str || '')) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff; + for (const c of str || '') h = (h * 31 + c.charCodeAt(0)) & 0xffffffff; return GRADIENTS[Math.abs(h) % GRADIENTS.length]; } @@ -87,6 +97,73 @@ export function extractRating(ratings) { return null; } +/** Resolves a poster URL to the dashboard proxy path when needed. */ +export function posterSrc(url) { + if (!url) return null; + return url.startsWith('/api/') ? url : `/api/poster?url=${encodeURIComponent(url)}`; +} + +/** Strips release-name noise from activity log reason text. */ +export function cleanActivityReason(s, svc) { + if (!s) return ''; + let r = s.trim(); + r = r.replace(/\b[A-Z0-9][\w.-]+\.(mkv|mp4|avi|flac|mp3|srt|scr)\b/gi, ''); + r = r.replace(/\b\d{3,4}p\b/gi, ''); + r = r.replace( + /\b(WEB-?DL|WEB|BluRay|REMUX|HDTV|WEBRip|DVDRip|x265|x264|h264|h265|HEVC|FLAC|DTS|AAC|DD5\.1|Atmos|DV|HDR|AMZN|NF)\b/gi, + '', + ); + r = r.replace(/-[A-Z0-9]{2,}$/gi, ''); + r = r.replace(/\.(scr|exe)\b/gi, ''); + r = r.replace(/\s+/g, ' ').trim(); + + const friendly = { + 'qbittorrent is reporting missing files': 'Missing files in qBittorrent', + 'qbittorrent is reporting completed download': 'Download completed', + 'unable to parse': 'Could not identify release', + 'no files found': 'No matching files', + }; + const lower = r.toLowerCase(); + for (const [k, v] of Object.entries(friendly)) if (lower.includes(k)) return v; + if (!r) return svc ? `${svc} update` : ''; + return r.length > 60 ? r.slice(0, 58) + '…' : r; +} + +/** Parses an activity log item into a display subject + reason pair. */ +export function formatActivityMessage(item) { + const title = item.context?.title || item.context?.artistName || null; + const raw = item.message || ''; + const svc = item.context?.service; + + const epMatch = raw.match(/^(.*?)\s+(S\d{1,2}E\d{1,3}):?\s*(.*)$/i); + if (epMatch) { + const [, , ep, rest] = epMatch; + const subject = title ? `${title} · ${ep}` : ep; + return { subject, reason: cleanActivityReason(rest, svc) }; + } + const colonIdx = raw.indexOf(':'); + if (title && colonIdx > 0) { + return { subject: title, reason: cleanActivityReason(raw.slice(colonIdx + 1).trim(), svc) }; + } + if (title) return { subject: title, reason: cleanActivityReason(raw, svc) }; + return { subject: raw, reason: '' }; +} + +/** Formats a unix/ms timestamp as a short locale date, or null when invalid. */ +export function formatShortDate(ts) { + if (!ts) return null; + const d = new Date(ts); + return isNaN(d.getTime()) ? null : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** Formats runtime minutes as "Xh Ym" or "Ym". */ +export function formatRuntime(mins) { + if (!mins) return null; + const h = Math.floor(mins / 60); + const m = mins % 60; + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + /** Detects a video quality label (e.g. "4K", "1080p", "WEB") from a release object's quality and title fields. */ export function detectQualityLabel(r) { const q = (r.quality || '') + ' ' + (r.title || ''); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 6c8b9ea..a5924d2 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,55 +1,65 @@ /** @type {import("tailwindcss").Config} */ export default { - content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"], + content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'], plugins: [ // Tailwind resolves these CommonJS plugins even though this package is ESM. - require("@tailwindcss/forms"), - require("@tailwindcss/container-queries"), + require('@tailwindcss/forms'), + require('@tailwindcss/container-queries'), ], theme: { extend: { colors: { - "bg-base": "var(--bg-base)", - "bg-card": "var(--surface)", - "bg-card-hover": "var(--surface-hover)", - "bg-surface": "var(--surface)", - "bg-surface-2": "var(--surface-hover)", - "bg-hover": "var(--surface-hover)", - "border-subtle": "var(--border-subtle)", - "border-medium": "var(--border-medium)", - "text-primary": "var(--text-primary)", - "text-secondary":"var(--text-secondary)", - "text-muted": "var(--text-muted)", - "accent-green": "#30d158", - "accent-red": "#ff375f", - "accent-orange": "#ff9f0a", - "accent-blue": "#0a84ff", - "accent-purple": "#bf5af2", + 'bg-base': 'var(--bg-base)', + 'bg-card': 'var(--surface)', + 'bg-card-hover': 'var(--surface-hover)', + 'bg-surface': 'var(--surface)', + 'bg-surface-2': 'var(--surface-hover)', + 'bg-hover': 'var(--surface-hover)', + 'border-subtle': 'var(--border-subtle)', + 'border-medium': 'var(--border-medium)', + 'text-primary': 'var(--text-primary)', + 'text-secondary': 'var(--text-secondary)', + 'text-muted': 'var(--text-muted)', + 'accent-green': '#30d158', + 'accent-red': '#ff375f', + 'accent-orange': '#ff9f0a', + 'accent-blue': '#0a84ff', + 'accent-purple': '#bf5af2', }, borderRadius: { - // Coherent scale; keep tailwind defaults working, add token-aligned steps - "sm": "4px", - "md": "6px", - "lg": "10px", - "xl": "14px", - "2xl": "20px", + // Token-aligned radius steps without replacing Tailwind's built-ins. + sm: '4px', + md: '6px', + lg: '10px', + xl: '14px', + '2xl': '20px', }, boxShadow: { - // Subtle, layered elevation tokens used across cards / modals / panels - "subtle": "0 1px 2px rgba(0,0,0,0.20)", - "elevated": "0 4px 12px rgba(0,0,0,0.32)", - "panel": "0 12px 40px rgba(0,0,0,0.45)", - "modal": "0 24px 64px rgba(0,0,0,0.55)", - "glow-blue":"0 0 0 1px rgba(10,132,255,0.30), 0 4px 16px rgba(10,132,255,0.25)", + // Shared elevation tokens for cards, panels, and modals. + subtle: '0 1px 2px rgba(0,0,0,0.20)', + elevated: '0 4px 12px rgba(0,0,0,0.32)', + panel: '0 12px 40px rgba(0,0,0,0.45)', + modal: '0 24px 64px rgba(0,0,0,0.55)', + 'glow-blue': '0 0 0 1px rgba(10,132,255,0.30), 0 4px 16px rgba(10,132,255,0.25)', }, transitionTimingFunction: { - // Spring-like easing for satisfying micro-interactions: use as `ease-spring` - "spring": "cubic-bezier(0.22, 0.61, 0.36, 1)", - "snappy": "cubic-bezier(0.4, 0, 0.2, 1)", + // Mirrors the JS easing tokens for utility-class usage. + spring: 'cubic-bezier(0.22, 0.61, 0.36, 1)', + snappy: 'cubic-bezier(0.4, 0, 0.2, 1)', }, fontFamily: { - "display": ["Inter", "-apple-system", "BlinkMacSystemFont", "SF Pro Display", "SF Pro Text", "Helvetica Neue", "Helvetica", "Arial", "sans-serif"], - "mono": ["JetBrains Mono", "monospace"], + display: [ + 'Inter', + '-apple-system', + 'BlinkMacSystemFont', + 'SF Pro Display', + 'SF Pro Text', + 'Helvetica Neue', + 'Helvetica', + 'Arial', + 'sans-serif', + ], + mono: ['JetBrains Mono', 'monospace'], }, }, }, diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 62ebf81..ec46e60 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,11 +7,24 @@ export default defineConfig({ rollupOptions: { output: { // Keep React in a stable chunk so app changes do not churn vendor code. - manualChunks(id) { if (id.includes('node_modules/react-dom') || id.includes('node_modules/react/') || id.endsWith('/react') || id.endsWith('/react-dom')) return 'react'; if (id.includes('node_modules')) return 'vendor'; } - } + manualChunks(id) { + if ( + id.includes('node_modules/react-dom') || + id.includes('node_modules/react/') || + id.endsWith('/react') || + id.endsWith('/react-dom') + ) { + return 'react'; + } + + if (id.includes('node_modules')) { + return 'vendor'; + } + }, + }, }, cssCodeSplit: true, - minify: 'esbuild' + minify: 'esbuild', }, server: { host: '0.0.0.0', @@ -19,12 +32,12 @@ export default defineConfig({ proxy: { '/api': { target: process.env.VITE_API_URL || 'http://localhost:3000', - changeOrigin: true - } - } + changeOrigin: true, + }, + }, }, preview: { host: '0.0.0.0', - port: 5173 - } + port: 5173, + }, }); diff --git a/install.sh b/install.sh index 80764bc..2cc6eca 100755 --- a/install.sh +++ b/install.sh @@ -1,433 +1,65 @@ #!/usr/bin/env bash set -euo pipefail -REPO_URL="https://github.com/ceelo510/vibarr.git" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# ────────────────────────────────────────────────────────────────────────────── +# arr-dashboard — one-command install +# ────────────────────────────────────────────────────────────────────────────── -if [ -z "${INSTALL_DIR:-}" ] && [ -f "$SCRIPT_DIR/docker-compose.yml" ] && [ -d "$SCRIPT_DIR/backend" ] && [ -d "$SCRIPT_DIR/frontend" ]; then - INSTALL_DIR="$SCRIPT_DIR" - USING_CHECKOUT=1 -else - INSTALL_DIR="${INSTALL_DIR:-$HOME/vibarr}" - USING_CHECKOUT=0 -fi - -DEFAULT_INSTALLER_STATE_HOST_PATH="./backend/installer-state.json" -DEFAULT_INSTALLER_STATE_PATH="/app/installer-state.json" -DEFAULT_DASHBOARD_PORT=8888 -DEFAULT_ACTIVITY_LOG_JSON='[]' -DEFAULT_BANDWIDTH_LIFETIME_JSON='{"baseline":{"dl":0,"ul":0},"lastSession":{"dl":0,"ul":0}}' -DEFAULT_INSTALLER_STATE_JSON='{"managed":false,"installedAt":null,"serviceConfig":{},"services":{},"setup":null,"lastInstallError":null}' +REPO_URL="https://github.com/anomalyco/arr-dashboard.git" +INSTALL_DIR="${INSTALL_DIR:-$HOME/arr-dashboard}" +COMPOSE_FILE="docker-compose.yml" BOLD='\033[1m' DIM='\033[2m' GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' -NC='\033[0m' -DOCKER_PREFIX=() -SUDO_DOCKER_FALLBACK=0 +NC='\033[0m' # No Color info() { echo -e "${GREEN}==>${NC} ${BOLD}$1${NC}"; } warn() { echo -e "${YELLOW}==>${NC} $1"; } err() { echo -e "${RED}ERROR:${NC} $1" >&2; } step() { echo -e " ${DIM}$1${NC}"; } -retry_command() { - local attempts=$1 - local sleep_seconds=$2 - local label=$3 - shift 3 - - local attempt=1 - local exit_code=0 - while [ "$attempt" -le "$attempts" ]; do - if "$@"; then - return 0 - fi - exit_code=$? - if [ "$attempt" -lt "$attempts" ]; then - warn "$label failed (attempt ${attempt}/${attempts}). Retrying in ${sleep_seconds}s..." - sleep "$sleep_seconds" - fi - attempt=$((attempt + 1)) - done - - return "$exit_code" -} - -has_tty() { - [ -t 0 ] && [ -t 1 ] -} - -require_tty() { - local reason=$1 - if has_tty; then - return 0 - fi - err "$reason" - echo " Run ./install.sh from an interactive shell after cloning the repo," - echo " or download the script and execute it locally without piping it to bash." - exit 1 -} - -docker_cmd() { - "${DOCKER_PREFIX[@]}" docker "$@" -} - -docker_compose() { - docker_cmd compose "$@" -} - -docker_compose_label() { - if [ "${#DOCKER_PREFIX[@]}" -gt 0 ]; then - printf 'sudo docker compose\n' - else - printf 'docker compose\n' - fi -} - -is_supported_debian_like() { - [ -r /etc/os-release ] || return 1 - # shellcheck disable=SC1091 - . /etc/os-release - case "${ID:-}" in - ubuntu|debian) return 0 ;; - esac - printf '%s\n' "${ID_LIKE:-}" | grep -qi 'debian' -} - -resolve_docker_repo_os() { - # shellcheck disable=SC1091 - . /etc/os-release - case "${ID:-}" in - ubuntu|debian) - printf '%s\n' "$ID" - return 0 - ;; - esac - if printf '%s\n' "${ID_LIKE:-}" | grep -qi 'ubuntu'; then - printf 'ubuntu\n' - return 0 - fi - if printf '%s\n' "${ID_LIKE:-}" | grep -qi 'debian'; then - printf 'debian\n' - return 0 - fi - return 1 -} - -resolve_docker_repo_codename() { - local repo_os=$1 - local codename="" - # shellcheck disable=SC1091 - . /etc/os-release - if [ "$repo_os" = "ubuntu" ] && [ -n "${UBUNTU_CODENAME:-}" ]; then - codename="$UBUNTU_CODENAME" - else - codename="${VERSION_CODENAME:-}" - fi - [ -n "$codename" ] || return 1 - printf '%s\n' "$codename" -} - -bootstrap_docker_with_sudo() { - local reason=$1 - local prompt repo_os repo_codename arch - - require_tty "Docker bootstrap needs an interactive shell." - - if ! is_supported_debian_like; then - err "Automatic Docker bootstrap is only supported on Ubuntu/Debian-like systems." - echo " Install Docker Engine and the Docker Compose plugin manually, then rerun ./install.sh." - exit 1 - fi - - if ! command -v sudo &>/dev/null; then - err "Docker bootstrap requires sudo on Ubuntu/Debian-like systems." - echo " Install Docker manually or rerun as a user with sudo access." - exit 1 - fi - - case "$reason" in - missing_cli) - prompt="Docker Engine and the Docker Compose plugin are missing. Install them now with sudo?" - ;; - missing_compose) - prompt="Docker is installed, but the Docker Compose plugin is missing. Install or repair Docker now with sudo?" - ;; - daemon_unreachable) - prompt="Docker is installed, but the daemon is unreachable. Install or repair Docker now with sudo?" - ;; - *) - prompt="Install or repair Docker Engine and the Docker Compose plugin now with sudo?" - ;; - esac - - echo - warn "$prompt" - read -r -p " Proceed? [Y/n] " REPLY - if [[ ! "${REPLY:-Y}" =~ ^[Yy]?$ ]]; then - err "Docker is required to continue." - exit 1 - fi - - repo_os="$(resolve_docker_repo_os)" || { - err "Could not determine the correct Docker apt repository for this OS." - exit 1 - } - repo_codename="$(resolve_docker_repo_codename "$repo_os")" || { - err "Could not determine the correct apt codename for this OS." - exit 1 - } - arch="$(dpkg --print-architecture)" - - echo - info "Installing Docker Engine and Compose plugin" - sudo apt-get update - sudo apt-get install -y ca-certificates curl gnupg - sudo install -m 0755 -d /etc/apt/keyrings - curl -fsSL "https://download.docker.com/linux/${repo_os}/gpg" | sudo tee /etc/apt/keyrings/docker.asc >/dev/null - sudo chmod a+r /etc/apt/keyrings/docker.asc - printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' "$arch" "$repo_os" "$repo_codename" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null - sudo apt-get update - sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - if command -v systemctl &>/dev/null; then - sudo systemctl enable --now docker - fi - if getent group docker >/dev/null 2>&1 && ! id -nG "$USER" | tr ' ' '\n' | grep -qx docker; then - sudo usermod -aG docker "$USER" - step "Added $USER to the docker group" - fi -} - -finalize_docker_access() { - if ! command -v docker &>/dev/null; then - err "Docker is still not installed after bootstrap." - exit 1 - fi - - if docker compose version &>/dev/null && docker info &>/dev/null; then - DOCKER_PREFIX=() - SUDO_DOCKER_FALLBACK=0 - return 0 - fi - - if command -v sudo &>/dev/null && sudo docker compose version &>/dev/null && sudo docker info &>/dev/null; then - DOCKER_PREFIX=(sudo) - SUDO_DOCKER_FALLBACK=1 - return 0 - fi - - err "Docker is installed, but the CLI still cannot reach a working daemon." - echo " Try: sudo systemctl status docker" - echo " Then rerun ./install.sh." - exit 1 -} - -read_env_value() { - local key=$1 - [ -f .env ] || return 0 - awk -F= -v key="$key" '$1 == key { sub(/^[^=]+=/, "", $0); print $0; exit }' .env -} - -set_env_value() { - local var=$1 value=$2 - if grep -q "^${var}=" .env; then - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|^${var}=.*|${var}=${value}|" .env - else - sed -i "s|^${var}=.*|${var}=${value}|" .env - fi - elif grep -q "^# ${var}=" .env; then - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|^# ${var}=.*|${var}=${value}|" .env - else - sed -i "s|^# ${var}=.*|${var}=${value}|" .env - fi - else - echo "${var}=${value}" >> .env - fi -} - -to_bool() { - case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in - 1|true|yes|on) return 0 ;; - *) return 1 ;; - esac -} - -resolve_installer_enabled() { - local enabled - enabled="$(read_env_value "INSTALLER_ENABLED")" - enabled="${enabled//$'\r'/}" - if [ -z "$enabled" ]; then - printf 'true\n' - else - printf '%s\n' "$enabled" - fi -} - -resolve_installer_state_host_path() { - local host_path - host_path="$(read_env_value "INSTALLER_STATE_HOST_PATH")" - host_path="${host_path//$'\r'/}" - if [ -z "$host_path" ]; then - printf '%s\n' "$DEFAULT_INSTALLER_STATE_HOST_PATH" - else - printf '%s\n' "$host_path" - fi -} - -resolve_dashboard_port() { - local dashboard_port - dashboard_port="$(read_env_value "DASHBOARD_PORT")" - dashboard_port="${dashboard_port//$'\r'/}" - if [[ "$dashboard_port" =~ ^[0-9]+$ ]]; then - printf '%s\n' "$dashboard_port" - return - fi - printf '%s\n' "$DEFAULT_DASHBOARD_PORT" -} - -ensure_json_file() { - local file_path=$1 - local default_json=$2 - local dir - dir="$(dirname "$file_path")" - mkdir -p "$dir" - if [ -s "$file_path" ]; then - return 0 - fi - printf '%s\n' "$default_json" > "$file_path" -} - -run_path_command() { - if "$@"; then - return 0 - fi - if command -v sudo &>/dev/null; then - sudo "$@" - return 0 - fi - return 1 -} - -ensure_download_directory() { - local dir_path=$1 - run_path_command mkdir -p "$dir_path" - run_path_command chown 1000:1001 "$dir_path" - run_path_command chmod 2775 "$dir_path" -} - -ensure_qbittorrent_download_layout() { - ensure_download_directory "/docker/downloads" - ensure_download_directory "/docker/downloads/unsorted" -} - -expand_path_from_install_dir() { - local path=$1 - case "$path" in - ./*) printf '%s/%s\n' "$INSTALL_DIR" "${path#./}" ;; - *) printf '%s\n' "$path" ;; - esac -} - -binding_probe_host() { - case "$1" in - ""|0.0.0.0|::|[::]) printf '127.0.0.1\n' ;; - *) printf '%s\n' "$1" ;; - esac -} - -binding_display_host() { - case "$1" in - ""|0.0.0.0|::|[::]) printf 'localhost\n' ;; - *) printf '%s\n' "$1" ;; - esac -} - -resolve_frontend_binding() { - local published host port - published="$(docker_compose port frontend 80 2>/dev/null | head -n 1 | tr -d '\r')" || return 1 - [ -n "$published" ] || return 1 - - if [[ "$published" == \[*\]:* ]]; then - host="${published%%]*}" - host="${host#[}" - port="${published##*]:}" - elif [[ "$published" == *:* ]]; then - host="${published%:*}" - port="${published##*:}" - else - host="127.0.0.1" - port="$published" - fi - - [ -n "$port" ] || return 1 - printf '%s %s\n' "$host" "$port" -} - -probe_http_path() { - local host=$1 - local port=$2 - local path=$3 - local status_line status_code - - exec 3<>"/dev/tcp/${host}/${port}" || return 1 - printf 'GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n' "$path" "$host" >&3 - IFS= read -r status_line <&3 || true - exec 3<&- - exec 3>&- - - status_code="$(printf '%s' "$status_line" | awk '{print $2}')" - case "$status_code" in - 200|204|301|302|304) return 0 ;; - *) return 1 ;; - esac -} - cleanup() { local exit_code=$? if [ $exit_code -ne 0 ]; then echo err "Installation failed (exit code $exit_code)." - echo " Check the messages above, then inspect:" - echo " cd $INSTALL_DIR && $(docker_compose_label) logs -f backend frontend" - echo " https://github.com/ceelo510/vibarr/issues" + echo " Check the messages above and try again, or open an issue at:" + echo " https://github.com/anomalyco/arr-dashboard/issues" fi exit $exit_code } trap cleanup EXIT +# ── Prerequisites ──────────────────────────────────────────────────────────── + echo -info "Vibarr installer" +info "arr-dashboard installer" echo -docker_issue="" -if ! command -v docker &>/dev/null; then - docker_issue="missing_cli" -elif ! docker compose version &>/dev/null; then - docker_issue="missing_compose" -elif ! docker info &>/dev/null; then - docker_issue="daemon_unreachable" -fi - -if [ -n "$docker_issue" ]; then - bootstrap_docker_with_sudo "$docker_issue" +# Docker +if command -v docker &>/dev/null; then + step "✓ Docker found" +else + err "Docker is not installed. Install it first:" + echo " https://docs.docker.com/engine/install/" + exit 1 fi -finalize_docker_access -step "Docker CLI found" -step "Docker Compose found" -if [ "$SUDO_DOCKER_FALLBACK" -eq 1 ]; then - step "Docker daemon reachable via sudo" +# Docker Compose +if docker compose version &>/dev/null; then + step "✓ Docker Compose found" else - step "Docker daemon reachable" + err "Docker Compose v2 is required (docker compose, not docker-compose)." + echo " Install: https://docs.docker.com/compose/install/" + exit 1 fi +# Git if command -v git &>/dev/null; then - step "git found" + step "✓ git found" else err "git is required. Install it:" echo " apt install git # Debian/Ubuntu" @@ -435,186 +67,109 @@ else exit 1 fi -if [ "$USING_CHECKOUT" -eq 1 ]; then - info "Using checked-out repository at $INSTALL_DIR" - cd "$INSTALL_DIR" -elif [ -d "$INSTALL_DIR" ]; then +# ── Clone / Update ─────────────────────────────────────────────────────────── + +if [ -d "$INSTALL_DIR" ]; then warn "Directory $INSTALL_DIR already exists." - if [ ! -d "$INSTALL_DIR/.git" ]; then - err "$INSTALL_DIR exists but is not a git checkout." - echo " Move it aside or set INSTALL_DIR to a different path." - exit 1 - fi - require_tty "Updating an existing installation requires confirmation." - read -r -p " Update existing installation with git pull --ff-only? [Y/n] " REPLY + read -r -p " Update existing installation? [Y/n] " REPLY if [[ ! "$REPLY" =~ ^[Yy]?$ ]]; then info "Aborted." exit 0 fi cd "$INSTALL_DIR" - info "Updating repository" - git pull --ff-only + git pull --ff-only 2>/dev/null || true else - info "Cloning vibarr to $INSTALL_DIR" + info "Cloning arr-dashboard to $INSTALL_DIR" git clone "$REPO_URL" "$INSTALL_DIR" cd "$INSTALL_DIR" fi +# ── Environment ────────────────────────────────────────────────────────────── + if [ -f .env ]; then - warn ".env already exists - reusing existing configuration." - INSTALLER_STATE_HOST_PATH="$(resolve_installer_state_host_path)" - if to_bool "$(resolve_installer_enabled)"; then - WEB_INSTALLER_ENABLED=1 - else - WEB_INSTALLER_ENABLED=0 - fi + warn ".env already exists — skipping configuration." else - require_tty "First-run configuration is interactive." echo info "Configuration" - echo " Web onboarding is enabled by default for a clean VM." - echo " Choose manual mode only if you already have Arr API keys." + echo " Paste your API keys below (press Enter to skip optional ones)." echo cp .env.example .env - prompt_key() { - local var=$1 label=$2 default=${3:-} - local val="" - while [ -z "$val" ]; do - read -r -p " $label [$default]: " val - val="${val:-$default}" - if [ -z "$val" ]; then - err "$label is required." - fi - done - set_env_value "$var" "$val" + set_env() { + local var=$1 val=$2 + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s/^${var}=/${var}=${val}/" .env + else + sed -i "s/^${var}=/${var}=${val}/" .env + fi } - prompt_optional() { + prompt_optional_value() { local var=$1 label=$2 - local val="" read -r -p " $label (optional): " val if [ -n "$val" ]; then - set_env_value "$var" "$val" + set_env "$var" "$val" fi } - read -r -p " Use web onboarding in the Settings view after first boot? [Y/n] " USE_WEB_SETUP - USE_WEB_SETUP="${USE_WEB_SETUP:-Y}" - WEB_INSTALLER_ENABLED=1 - - if [[ "$USE_WEB_SETUP" =~ ^[Nn]$ ]]; then - WEB_INSTALLER_ENABLED=0 - set_env_value "INSTALLER_ENABLED" "false" - prompt_key "RADARR_API_KEY" "Radarr API key" - prompt_key "SONARR_API_KEY" "Sonarr API key" - prompt_key "LIDARR_API_KEY" "Lidarr API key" - else - echo - info "Web onboarding enabled." - echo " The first-run UI lives at the dashboard root URL; open Settings there to continue." - set_env_value "INSTALLER_ENABLED" "true" - set_env_value "INSTALLER_STATE_PATH" "$DEFAULT_INSTALLER_STATE_PATH" - set_env_value "INSTALLER_STATE_HOST_PATH" "$DEFAULT_INSTALLER_STATE_HOST_PATH" - fi + prompt_optional() { + local var=$1 label=$2 + read -r -p " $label (optional): " val + if [ -n "$val" ]; then + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s/^# ${var}=/${var}=${val}/" .env + else + sed -i "s/^# ${var}=/${var}=${val}/" .env + fi + fi + } + bootstrap_token="$(openssl rand -hex 24 2>/dev/null || node -e "console.log(require('crypto').randomBytes(24).toString('hex'))")" + set_env "SETUP_BOOTSTRAP_TOKEN" "$bootstrap_token" + set_env "INSTALLER_ENABLED" "true" + prompt_optional_value "RADARR_API_KEY" "Radarr API key" + prompt_optional_value "SONARR_API_KEY" "Sonarr API key" + prompt_optional_value "LIDARR_API_KEY" "Lidarr API key" prompt_optional "QBITTORRENT_USER" "qBittorrent username" prompt_optional "QBITTORRENT_PASS" "qBittorrent password" - prompt_optional "SLSKD_API_KEY" "SLSKD API key" + prompt_optional "SLSKD_API_KEY" "SLSKD API key" prompt_optional "PROWLARR_API_KEY" "Prowlarr API key" - INSTALLER_STATE_HOST_PATH="$(resolve_installer_state_host_path)" step "Configuration saved to .env" fi -ensure_json_file "backend/activity-log.json" "$DEFAULT_ACTIVITY_LOG_JSON" -ensure_json_file "backend/bandwidth-lifetime.json" "$DEFAULT_BANDWIDTH_LIFETIME_JSON" -if [ -z "${INSTALLER_STATE_HOST_PATH:-}" ]; then - INSTALLER_STATE_HOST_PATH="$(resolve_installer_state_host_path)" -fi -ensure_json_file "$INSTALLER_STATE_HOST_PATH" "$DEFAULT_INSTALLER_STATE_JSON" -ensure_qbittorrent_download_layout +# ── Create support files ───────────────────────────────────────────────────── -echo -info "Validating compose config" -docker_compose config >/dev/null +touch backend/activity-log.json +touch backend/bandwidth-lifetime.json -echo -info "Building and starting containers" -step "Docker image pulls can fail transiently on fresh VMs; the installer retries automatically." -retry_command 4 5 "docker compose build" docker_compose build --no-cache -retry_command 3 5 "docker compose up" docker_compose up -d --remove-orphans +# ── Build and Start ────────────────────────────────────────────────────────── echo -info "Waiting for backend health" -for _ in $(seq 1 30); do - sleep 2 - if docker_compose exec -T backend node -e "require('http').get('http://localhost:3000/api/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" >/dev/null 2>&1; then - BACKEND_READY=1 - break - fi -done - -if [ "${BACKEND_READY:-0}" -ne 1 ]; then - warn "Backend health check timed out." - echo " cd $INSTALL_DIR && $(docker_compose_label) logs backend" - exit 1 -fi +info "Building and starting containers" +docker compose build --no-cache +docker compose up -d echo -info "Waiting for frontend" -for _ in $(seq 1 30); do +info "Waiting for backend to become healthy..." +for i in $(seq 1 15); do sleep 2 - binding="$(resolve_frontend_binding || true)" - [ -n "${binding:-}" ] || continue - - bind_host="${binding%% *}" - bind_port="${binding##* }" - probe_host="$(binding_probe_host "$bind_host")" - display_host="$(binding_display_host "$bind_host")" - - if ! probe_http_path "$probe_host" "$bind_port" "/"; then - continue - fi - - if [ "${WEB_INSTALLER_ENABLED:-0}" -eq 1 ] && ! probe_http_path "$probe_host" "$bind_port" "/api/setup/state"; then - continue + if curl -sf --max-time 3 http://localhost:3000/api/health >/dev/null 2>&1; then + info "arr-dashboard is running!" + echo + echo " Frontend: http://localhost:8888" + echo " Backend: http://localhost:3000" + echo " Setup token: $bootstrap_token" + echo + echo " To stop: cd $INSTALL_DIR && docker compose down" + echo " To update: cd $INSTALL_DIR && git pull && docker compose build --no-cache && docker compose up -d --force-recreate" + echo " Logs: docker compose logs -f" + echo + exit 0 fi - - DASHBOARD_URL="http://${display_host}:${bind_port}" - break done -if [ -z "${DASHBOARD_URL:-}" ]; then - warn "Frontend did not answer on the published dashboard URL." - echo " cd $INSTALL_DIR && $(docker_compose_label) logs frontend" - exit 1 -fi - -ACTIVITY_LOG_PATH="$(expand_path_from_install_dir "./backend/activity-log.json")" -BANDWIDTH_LIFETIME_PATH="$(expand_path_from_install_dir "./backend/bandwidth-lifetime.json")" -INSTALLER_STATE_PATH_DISPLAY="$(expand_path_from_install_dir "$INSTALLER_STATE_HOST_PATH")" - -echo -info "Vibarr is running" -echo " Dashboard: ${DASHBOARD_URL}" -echo " API: ${DASHBOARD_URL}/api" -if [ "${WEB_INSTALLER_ENABLED:-0}" -eq 1 ]; then - echo " Setup: ${DASHBOARD_URL} (open Settings to continue onboarding)" -fi -echo " Backend: internal-only (proxied through the frontend)" -echo -echo " Runtime files:" -echo " Activity: ${ACTIVITY_LOG_PATH}" -echo " Bandwidth: ${BANDWIDTH_LIFETIME_PATH}" -echo " Installer: ${INSTALLER_STATE_PATH_DISPLAY}" -echo -if [ "$SUDO_DOCKER_FALLBACK" -eq 1 ]; then - echo " Note: Docker is working through sudo in this shell. Re-login or run newgrp docker" - echo " later if you want to drop the sudo prefix for future Docker commands." -fi -echo " Logs: cd $INSTALL_DIR && $(docker_compose_label) logs -f backend frontend" -echo " Stop: cd $INSTALL_DIR && $(docker_compose_label) down" -echo " Update: cd $INSTALL_DIR && git pull --ff-only && $(docker_compose_label) build --no-cache && $(docker_compose_label) up -d --force-recreate" -echo +warn "Backend health check timed out. Check logs:" +echo " cd $INSTALL_DIR && docker compose logs backend" +exit 1