diff --git a/.dockerignore b/.dockerignore index f657e9a..f7cca59 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,24 @@ # Git .git .gitignore +.github # IDE .vscode .idea +.claude + +# Secrets / env files +.env +.env.* +*.pem +*.key +*.pfx + +# Local-only docs / scratch +CLAUDE.md +PR_DESCRIPTION.md +tmp/ # Go build artifacts *.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff26f15..b3c7d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,9 @@ on: - '.gitignore' - 'example/**' +permissions: + contents: read + jobs: check-changes: runs-on: ubuntu-latest @@ -212,7 +215,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.26' - name: Build binary env: diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a607..73d487b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,180 +1,152 @@ # Changelog -## [Unreleased] - 2025-07-05 +## [1.3.2] - 2026-04-21 + +### Security +- Plex client error messages now redact `X-Plex-Token`, `apikey`, and `api_key` query-string values. Previously a transport failure (e.g. TLS cert rejection) would bubble up Go's `*url.Error`, which embeds the full request URL including the Plex token, leaking it into logs/Loki. All `c.httpClient.Do` calls are now routed through a `safeDo` wrapper that scrubs the error string before returning. + +## [1.3.1] - 2026-04-21 + +### Security +- Plex TLS certificate verification is now enabled by default. Previously, `InsecureSkipVerify` was hardcoded to `true`, which silently trusted any server certificate. Set `PLEX_INSECURE_SKIP_VERIFY=true` to opt back into skip-verify (e.g., for self-signed Plex certs). A `[WARN]` line is logged at startup when the flag is on. Resolves CodeQL `go/disabled-certificate-check`. +- Export paths are now validated to stay within `EXPORT_LOCATION`. Library names that resolve to `.`, `..`, or empty after sanitization are rejected, and a `safeJoin` helper verifies every export path stays inside the export root before writing. Resolves CodeQL `go/path-injection`. +- `.github/workflows/release.yml` now declares a least-privilege top-level `permissions: contents: read`. Per-job elevated permissions on `create-release`, `build-binaries`, and `publish-docker` are unchanged. Resolves CodeQL `actions/missing-workflow-permissions`. ### Added +- `PLEX_INSECURE_SKIP_VERIFY` environment variable (default `false`). -#### Radarr/Sonarr Integration -- ✅ Created Radarr API client module (`internal/radarr/`) with full API support - - Movie search by title, year, TMDb ID, IMDb ID, and file path - - Automatic TMDb ID extraction from Radarr database - - Connection testing and system status endpoints - -- ✅ Created Sonarr API client module (`internal/sonarr/`) with full API support - - TV series search by title, year, TMDb ID, TVDb ID, IMDb ID, and file path - - Episode fetching and file path matching - - Connection testing and system status endpoints - -- ✅ Updated configuration system to support Radarr/Sonarr - - Added `USE_RADARR` and `USE_SONARR` environment variables - - Added `RADARR_URL`, `RADARR_API_KEY`, `SONARR_URL`, `SONARR_API_KEY` configuration - - Added validation for Radarr/Sonarr settings when enabled - -- ✅ Enhanced TMDb ID extraction to use multiple sources - - Primary: Plex metadata (existing functionality preserved) - - Secondary: Radarr/Sonarr API matching (new) - - Fallback: File path regex matching (existing functionality preserved) - - Added source tracking to show where TMDb ID was found - -- ✅ Updated media processor to integrate with Radarr/Sonarr - - Modified `extractMovieTMDbID` to query Radarr when enabled - - Modified `extractTVShowTMDbID` to query Sonarr when enabled - - Added multiple matching strategies: title/year, IMDb ID, TVDb ID, file path - -- ✅ Updated main application to initialize Radarr/Sonarr clients - - Added connection testing on startup - - Graceful handling when Radarr/Sonarr are not configured - -- ✅ Created comprehensive docker-compose.yml example - - Includes all existing configuration options - - Added Radarr/Sonarr configuration examples with defaults - -#### Verbose Logging & Debugging - -- ✅ Added verbose logging feature - - New `VERBOSE_LOGGING` environment variable (default: false) - - Shows detailed TMDb ID lookup process for each item - - Displays all available Plex GUIDs (IMDb, TMDb, TVDb) - - Shows Radarr lookup attempts with title, file path, and IMDb ID matching - - Shows Sonarr lookup attempts with title, TVDb ID, IMDb ID, and file path matching - - Indicates source of successful TMDb ID matches - - Helps troubleshoot matching issues - -- ✅ Added progress tracking for large libraries - - Shows percentage progress for libraries with >100 items - - Displays current processing status - - Shows summary of skipped items in verbose mode - -- ✅ Enhanced label/genre application logging - - Shows when keywords are being applied to Plex - - Displays Plex API call timing in verbose mode - - Shows current and new keywords being merged - - Confirms successful application to Plex +### Changed +- Bumped Docker build base images to `golang:1.26-alpine` (builder) and `alpine:3.22` (runtime) to pick up patched Go stdlib and Alpine packages. Clears 1 CRITICAL + 8 HIGH CVEs surfaced by Trivy on the previous `golang:1.23-alpine` / `alpine:3.21` base (CVE-2025-68121, CVE-2025-58183, CVE-2025-61726/28/29, CVE-2026-25679/32280/32281/32283). +- `go.mod` now requires Go 1.26; `release.yml` `actions/setup-go` pinned to `1.26`. +- `.dockerignore` hardened to exclude `.env*`, `*.pem`, `*.key`, `.claude`, `.github`, `CLAUDE.md`, `PR_DESCRIPTION.md`, `tmp/`. -#### Persistent Storage +## [1.3.0] - 2026-04-12 -- ✅ Added persistent storage for processed items - - Prevents reprocessing items after container restarts - - JSON file-based storage with atomic writes - - Tracks which field (label/genre) was updated for each item - - Configurable data directory via DATA_DIR environment variable - - Docker volume support for data persistence - - Storage directory defaults to `/data` in container +### Added +- `POST /scan` endpoint on the webhook server for manual scan triggers. + - `POST /scan` runs a full scan across all non-excluded libraries. + - `POST /scan?library=` scans a single library; `library` + accepts the numeric Plex section ID or a case-insensitive library + title. + - Returns `202 Accepted` immediately; the scan runs in the + background. Returns `409 Conflict` if a scan is already in + progress, `404 Not Found` if the library param does not match, and + `405 Method Not Allowed` for non-POST requests. + - Works in `WEBHOOK_ONLY=true` mode — enables ad-hoc catchup scans + without toggling environment variables. + +## [1.2.3] - 2026-04-12 + +### Fixed +- Every Plex webhook delivery was being rejected with `HTTP 400` because + `PlexWebhookPayload.Metadata.GUID` was typed as `string`, while Plex + sends it as an array of objects (`[{id:"imdb://..."}, ...]`) for + multi-provider items. `json.Unmarshal` failed before reaching the + event handler. The unused field has been removed; unknown JSON fields + are ignored by the decoder, so the shape no longer matters. + +## [1.2.2] - 2026-04-12 + +### Changed +- Webhook 400 responses now log the specific failure reason (parse error, + missing payload field, or JSON unmarshal error) along with + `Content-Type`, form/file part keys, and a payload snippet. Previously + all three paths returned 400 with no log, making Plex delivery failures + invisible. -#### Error Handling & Connection Testing +## [1.2.1] - 2026-04-12 -- ✅ Added TMDb API connection testing on startup - - Validates API token before processing begins - - Provides clear error messages for authentication failures - - Shows detailed error responses for debugging +### Added +- `MOVIE_LIBRARY_EXCLUDE` / `TV_LIBRARY_EXCLUDE`: comma-separated library + IDs to skip when `*_PROCESS_ALL=true`. Excluded libraries are filtered + from both timer-driven processing and webhook routing. +- `WEBHOOK_ONLY=true`: skips the startup full scan and the periodic timer + entirely, leaving the webhook server as the only trigger. Requires + `WEBHOOK_ENABLED=true`. + +### Fixed +- Webhook items are no longer silently dropped when a full library scan is + in progress. `ProcessSingleItem` now waits for the per-library slot to + free up (polling every 5s, bounded to a 2-hour deadline) instead of + logging a fake "queuing for next cycle" and returning early. -- ✅ Improved error handling throughout - - Better error messages for TMDb API failures - - Clear indication of authentication vs other errors - - Verbose mode shows why items are skipped +## [1.2.0] - 2026-04-10 + +### Added + +#### Plex Webhook Support +- Webhook listener for real-time processing (WEBHOOK_ENABLED, WEBHOOK_PORT) +- Handles library.new and library.on.deck events +- Configurable debounce window (WEBHOOK_DEBOUNCE, default 30s) +- Prevents concurrent processing of the same library +- Health check endpoint at /health +- Runs alongside the existing timer + +#### Keyword Prefix +- KEYWORD_PREFIX env var to prepend text to keywords (e.g. "- ") +- Useful when UPDATE_FIELD=genre to separate TMDb keywords from real genres + +#### Batch Processing +- BATCH_SIZE (default 100) and BATCH_DELAY (default 10s) env vars +- Prevents API flooding on large libraries (4000+ items) +- ITEM_DELAY (default 500ms) controls per-item pacing + +#### Version Tracking +- Version constant in internal/version/version.go +- Logs version on startup ### Changed +- Removed all emoji from log output; replaced with bracketed tags +- Extracted Clients struct for processor initialization +- Added keyword cache by TMDb ID to avoid redundant API calls +- Eliminated redundant Plex API call after keyword sync for export -- Modified `NewProcessor` to accept optional Radarr/Sonarr clients and return error -- Enhanced TMDb ID detection to show source (Plex metadata, Radarr, Sonarr, or file path) -- Processor initialization now includes persistent storage setup -- Main application now tests all API connections on startup +## 2025-07-05 -### Documentation +### Added -- ✅ Updated README.md with comprehensive documentation - - Added Radarr/Sonarr Integration section with benefits and configuration - - Added Verbose Logging section with examples - - Updated environment variables documentation - - Added persistent storage information - - Updated docker-compose examples +#### Radarr/Sonarr Integration +- Radarr API client (internal/radarr/) -- movie lookup by title, year, TMDb ID, IMDb ID, file path +- Sonarr API client (internal/sonarr/) -- series lookup by title, year, TMDb ID, TVDb ID, IMDb ID, file path +- USE_RADARR, USE_SONARR, RADARR_URL, RADARR_API_KEY, SONARR_URL, SONARR_API_KEY env vars +- TMDb ID extraction chain: Plex metadata -> Radarr/Sonarr -> file path regex +- Connection testing on startup for all enabled services -- ✅ Created detailed CHANGELOG.md - - Comprehensive list of all changes - - Organized by feature area - - Technical implementation details +#### Verbose Logging +- VERBOSE_LOGGING env var (default false) +- Shows TMDb ID lookup source, Plex GUIDs, matching attempts +- Progress percentage for libraries over 100 items -#### Keyword Normalization +#### Persistent Storage +- JSON file storage for processed items (DATA_DIR env var) +- Tracks rating key, TMDb ID, update field, last processed time +- Skips already-processed items unless FORCE_UPDATE=true +- Runs in ephemeral mode when DATA_DIR is not set -- ✅ Added intelligent keyword normalization feature - - Automatically normalizes TMDb keywords for consistent formatting - - Pattern-based recognition for dynamic handling without hardcoding - - Smart title casing with proper article and preposition handling - - Automatic duplicate removal after normalization - -- ✅ Pattern Recognition Features - - **Critical Replacements**: Known abbreviations (sci-fi → Sci-Fi, romcom → Romantic Comedy) - - **Acronym Detection**: Automatically uppercases known acronyms (FBI, CIA, DEA, etc.) - - **Agency Patterns**: Detects agency roles (dea agent → DEA Agent) - - **Parenthetical Acronyms**: Handles acronyms in parentheses (central intelligence agency (cia) → Central Intelligence Agency (CIA)) - - **Century Patterns**: Properly formats centuries (5th century bc → 5th Century BC) - - **City/State Patterns**: Handles location formatting (san francisco, california → San Francisco, California) - - **Relationship Patterns**: Adds "Relationship" where appropriate (father daughter → Father Daughter Relationship) - - **Credit Stinger Terms**: Expands compound terms (duringcreditsstinger → During Credits Stinger) - -- ✅ Added comprehensive test suite - - 90+ test cases covering various normalization scenarios - - Tests for edge cases, mixed case preservation, and pattern matching - - Ensures consistent behavior across different keyword types - -- ✅ Smart duplicate cleaning functionality - - Automatically removes old unnormalized keywords when adding normalized versions - - Preserves manually set keywords in Plex - - Prevents accumulation of duplicate keywords (e.g., both "sci-fi" and "Sci-Fi") - - Shows cleaning activity in verbose logging mode +#### Keyword Normalization +- Pattern-based normalization: sci-fi -> Sci-Fi, romcom -> Romantic Comedy +- Acronym detection (FBI, CIA, DEA, etc.) +- Century formatting (5th century bc -> 5th Century BC) +- City/state, relationship, and credit stinger patterns +- 90+ test cases +- Duplicate cleaning: removes old unnormalized keywords when normalized versions are added #### Force Update Mode +- FORCE_UPDATE env var (default false) +- Reprocesses all items regardless of storage state -- ✅ Added force update functionality - - New `FORCE_UPDATE` environment variable (default: false) - - Reprocesses all items regardless of previous processing status - - Useful for applying keyword normalization to existing libraries - - Shows clear indication when force update mode is active - - Bypasses both storage checks and "already has keywords" logic +#### Export Functionality +- EXPORT_LABELS, EXPORT_LOCATION, EXPORT_MODE (txt/json) env vars +- Generates file lists per label per library +- JSON mode outputs a single structured export.json +- TXT mode creates per-library subdirectories with summary.txt ### Changed - -- Modified `NewProcessor` to accept optional Radarr/Sonarr clients and return error -- Enhanced TMDb ID detection to show source (Plex metadata, Radarr, Sonarr, or file path) -- Processor initialization now includes persistent storage setup -- Main application now tests all API connections on startup -- TMDb client now normalizes all keywords before returning them -- Updated keyword display to show normalization in verbose mode -- Enhanced keyword synchronization with smart duplicate cleaning -- Force update mode bypasses all previous processing checks - -### Documentation - -- ✅ Updated README.md with comprehensive documentation - - Added Radarr/Sonarr Integration section with benefits and configuration - - Added Verbose Logging section with examples - - Added Keyword Normalization section with pattern examples - - Added Force Update Mode section with use cases and examples - - Added Smart Duplicate Cleaning documentation - - Updated environment variables documentation - - Added persistent storage information - - Updated docker-compose examples - -- ✅ Created detailed CHANGELOG.md - - Comprehensive list of all changes - - Organized by feature area - - Technical implementation details - -### Technical Details -- Radarr/Sonarr clients use API v3 endpoints -- Implemented robust error handling and fallback mechanisms -- No breaking changes - Radarr/Sonarr integration is fully optional -- Maintains backward compatibility with existing file path matching -- Verbose logging provides detailed insights without affecting normal operation -- Keyword normalization uses regex patterns for scalability -- All features are designed to be non-breaking and backward compatible \ No newline at end of file +- NewProcessor accepts optional Radarr/Sonarr clients and returns error +- TMDb client normalizes keywords before returning them +- Removal delay reduced from 500ms to configurable ITEM_DELAY + +### Technical Notes +- Radarr/Sonarr use API v3 +- All new features are optional and backward compatible +- No breaking changes to existing configuration diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9236c57 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +## Critical Rules + +- NEVER create mock data or simplified components unless explicitly told to +- NEVER replace existing complex components with simplified versions -- fix the actual problem +- ALWAYS work with the existing codebase -- do not create new simplified alternatives +- ALWAYS find and fix the root cause instead of creating workarounds +- ALWAYS track changes in CHANGELOG.md +- ALWAYS refer to CHANGELOG.md when working on tasks +- ALWAYS verify the app builds successfully before declaring done +- Fix all review findings -- never skip items as "not worth the churn" + +## Build and Test + +Go is not installed locally. Use Docker for all build verification: + +```bash +docker build -t labelarr:test . # Verify compilation +docker build --platform linux/amd64 -t ttlequals0/labelarr:latest . # Production build +docker push ttlequals0/labelarr:latest # Push to Docker Hub +``` + +Format Go code via Docker (macOS `sed` is aliased to `gsed`): + +```bash +docker run --rm -v $(pwd):/app -w /app golang:1.21-alpine gofmt -w +``` + +Deploy to Portainer after pushing the Docker image: + +```bash +curl -X POST https://portainer.ttlequals0.com/api/stacks/webhooks/01e4c5be-188c-4ce7-9213-d42b31a78851 +``` + +## Architecture + +Go 1.21 service. No external Go dependencies (stdlib only). Runs as a Docker container. + +``` +cmd/labelarr/main.go Entry point, client init, processing loop +internal/config/config.go Env var loading, Config struct, validation +internal/media/processor.go Core processing logic (batch iteration, keyword sync, TMDb ID extraction) +internal/plex/client.go Plex API client (libraries, items, label/genre updates) +internal/tmdb/client.go TMDb API client (keyword fetching, connection testing) +internal/radarr/client.go Radarr API client (movie lookup, cached library fetch) +internal/sonarr/client.go Sonarr API client (series lookup, cached library fetch) +internal/webhook/server.go Plex webhook HTTP server (debounce, graceful shutdown) +internal/export/export.go Label-based file path export (txt/json) +internal/storage/storage.go JSON persistence for processed items +internal/utils/normalize.go Keyword normalization (90+ test cases) +``` + +## Coding Conventions + +- No emoji in Go source. Log tags use bracketed format: `[OK]`, `[ERROR]`, `[SKIP]`, `[WARN]`, `[INFO]`, `[LOOKUP]`, etc. +- `[ERROR]` is reserved for actual failures (API errors, decode errors). Expected lookup misses use `[SKIP]`. +- No WHAT comments (comments that describe what the next line does). Only add comments explaining WHY. +- ASCII only in all output -- no em dashes, smart quotes, or unicode arrows. +- Use `fmt.Printf` for all logging (no log package). + +## Key Patterns + +- `NewProcessor` accepts a `Clients` struct, not individual client args +- Radarr/Sonarr clients cache `GetAllMovies()`/`GetAllSeries()` results. Call `ClearCache()` to refresh. +- `Processor.ClearCaches()` resets keyword cache + Radarr/Sonarr caches. Called at the start of each processing cycle. +- `Processor.ProcessAllItems()` has a per-library mutex guard -- safe to call from both timer and webhook goroutines. +- Keyword cache (`keywordCache`) is protected by `sync.RWMutex`. +- Batch iteration uses `makeBatches()` / `logStart()` / `pauseAfterBatch()` helpers. + +## Gotchas + +- macOS `sed` is aliased to `gsed` (GNU sed). Use `gsed -i` not `sed -i ''`. +- `strings.Title` is deprecated in Go 1.18+. Use manual capitalization instead. +- Plex webhooks require Plex Pass. The webhook payload uses `LibrarySectionType` ("movie"/"show") not `Metadata.Type` ("movie"/"episode") for media type resolution. +- `DATA_DIR` defaults to empty (ephemeral mode). Storage is only created when `DATA_DIR` is explicitly set. +- Docker image: `ttlequals0/labelarr:latest` on Docker Hub. +- Upstream repo: `nullable-eth/labelarr`. Our fork adds batch processing, keyword prefix, webhooks. diff --git a/Dockerfile b/Dockerfile index a764f9b..319ae07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.23-alpine AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app @@ -11,7 +11,7 @@ RUN go mod download RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr ./cmd/labelarr # Runtime stage -FROM alpine:3.21 +FROM alpine:3.22 # Install ca-certificates for HTTPS requests and debugging tools # Using retry logic for QEMU emulation stability during multi-arch builds @@ -27,5 +27,7 @@ COPY --from=builder /app/labelarr . RUN adduser -D -s /bin/bash labelarr USER labelarr -# Run the application +# Webhook server port (only used when WEBHOOK_ENABLED=true) +EXPOSE 9090 + CMD ["./labelarr"] diff --git a/README.md b/README.md index 98aa29d..08b6fc0 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,35 @@ -# Labelarr 🎬📺🏷️ +# Labelarr [![GitHub Release](https://img.shields.io/github/v/release/nullable-eth/labelarr?style=flat-square)](https://github.com/nullable-eth/labelarr/releases/latest) [![Docker Image](https://img.shields.io/badge/docker-ghcr.io-blue?style=flat-square&logo=docker)](https://github.com/nullable-eth/labelarr/pkgs/container/labelarr) [![Go Version](https://img.shields.io/github/go-mod/go-version/nullable-eth/labelarr?style=flat-square)](https://golang.org/) -[![GitHub Actions](https://img.shields.io/github/actions/workflow/status/nullable-eth/labelarr/release.yml?branch=main&style=flat-square)](https://github.com/nullable-eth/labelarr/actions) -**Automatically sync TMDb keywords as Plex labels or genres for movies and TV shows** -Lightweight Docker container that bridges Plex with The Movie Database, adding searchable keywords to your media. - -## 🚀 Quick Start - -### Docker Compose (Recommended) +Syncs TMDb keywords to Plex as labels or genres. Runs as a Docker container on a timer, or reacts to Plex webhooks in real time. + +## Table of Contents + +- [Quick Start](#quick-start) +- [How It Works](#how-it-works) +- [Environment Variables](#environment-variables) +- [Radarr/Sonarr Integration](#radarrsonarr-integration) +- [Webhook Support](#webhook-support) +- [Batch Processing](#batch-processing) +- [Keyword Prefix](#keyword-prefix) +- [Keyword Normalization](#keyword-normalization) +- [Export Functionality](#export-functionality) +- [TMDb ID Detection](#tmdb-id-detection) +- [Removing Keywords](#removing-keywords) +- [Field Locking](#field-locking) +- [Force Update Mode](#force-update-mode) +- [Verbose Logging](#verbose-logging) +- [Persistent Storage](#persistent-storage) +- [Getting API Keys](#getting-api-keys) +- [Troubleshooting](#troubleshooting) +- [Local Development](#local-development) + +## Quick Start ```yaml -version: '3.8' - services: labelarr: image: ghcr.io/nullable-eth/labelarr:latest @@ -22,1197 +37,358 @@ services: restart: unless-stopped volumes: - ./labelarr-data:/data - - ./exports:/data/exports # Mount host directory for export files environment: - # Required - Get from Plex Web (F12 → Network → X-Plex-Token) - PLEX_TOKEN=your_plex_token_here - # Required - Get from https://www.themoviedb.org/settings/api - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token - # Required - Your Plex server details - PLEX_SERVER=plex - PLEX_PORT=32400 - PLEX_REQUIRES_HTTPS=true - # Process all libraries (recommended for first-time users) - MOVIE_PROCESS_ALL=true - TV_PROCESS_ALL=true - # Optional settings - - PROCESS_TIMER=1h - - UPDATE_FIELD=label # or 'genre' - # Optional Radarr/Sonarr integration - # - USE_RADARR=true - # - RADARR_URL=http://radarr:7878 - # - RADARR_API_KEY=your_radarr_api_key - # - USE_SONARR=true - # - SONARR_URL=http://sonarr:8989 - # - SONARR_API_KEY=your_sonarr_api_key - # Optional export functionality - # - EXPORT_LABELS=action,comedy,thriller,documentary,kids - # - EXPORT_LOCATION=/data/exports ``` -**Run:** `docker-compose up -d` - -### What it does - -✅ **Detects TMDb IDs** from Plex metadata, Radarr/Sonarr APIs, or file paths (e.g., `{tmdb-12345}`) -✅ **Fetches keywords** from TMDb API for movies and TV shows -✅ **Normalizes keywords** with proper capitalization and spelling -✅ **Adds as Plex labels/genres** - never removes existing values -✅ **Runs automatically** on configurable timer (default: 1 hour) -✅ **Multi-architecture** support (AMD64 + ARM64) - -### 🎉 New Features in This Fork - -- **🚀 Radarr/Sonarr Integration** - Automatically detect TMDb IDs from your media managers -- **💾 Persistent Storage** - Tracks processed items across container restarts -- **🔍 Verbose Logging** - Detailed debugging information for troubleshooting -- **📝 Keyword Normalization** - Intelligent formatting with pattern recognition -- **🔄 Force Update Mode** - Reprocess all items regardless of previous processing status -- **🧹 Smart Duplicate Cleaning** - Automatically removes old unnormalized keywords when adding normalized versions -- **🔒 Enhanced Error Handling** - Better authentication and connection testing -- **📤 Export Functionality** - Generate file lists for specific labels to sync content or create backups - ---- - -
-

📸 Examples in Plex

+Run `docker-compose up -d`. Labelarr processes your libraries immediately on startup, then repeats every hour. ![Labels](example/labels.png) ![Dynamic Filters](example/dynamic_filter.png) ![Filter](example/filter.png) -
- -
-

🐳 Alternative: Docker Run Command

- -```bash -docker run -d --name labelarr \ - -e PLEX_TOKEN=your_plex_token_here \ - -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e PLEX_SERVER=localhost -e PLEX_PORT=32400 -e PLEX_REQUIRES_HTTPS=true \ - -e MOVIE_PROCESS_ALL=true -e TV_PROCESS_ALL=true \ - ghcr.io/nullable-eth/labelarr:latest -``` - -
- -
-

🐳 Advanced: Running with Plex Container Ensuring Labelarr Waits for Plex

-To avoid Labelarr startup errors when Plex is not yet ready, use Docker Compose's depends_on with condition: service_healthy and add a healthcheck to your Plex service. This ensures Labelarr only starts after Plex is healthy. - -```yaml -version: '3.8' -services: - plex: - image: plexinc/pms-docker:latest - container_name: plex - # ... your plex configuration ... - healthcheck: - test: curl --connect-timeout 15 --silent --show-error --fail http://localhost:32400/identity - interval: 1m00s - timeout: 15s - retries: 3 - start_period: 1m00s - - labelarr: - image: ghcr.io/nullable-eth/labelarr:latest - container_name: labelarr - restart: unless-stopped - depends_on: - plex: - condition: service_healthy - environment: - - PLEX_SERVER=localhost - - PLEX_PORT=32400 - - PLEX_REQUIRES_HTTPS=false - - PLEX_TOKEN=your_plex_token_here - - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token - - MOVIE_PROCESS_ALL=true - - TV_PROCESS_ALL=true -``` +## How It Works -
+1. Fetches all movies/shows from your Plex libraries +2. Finds the TMDb ID for each item (from Plex metadata, Radarr/Sonarr, or file paths) +3. Pulls keywords from the TMDb API +4. Normalizes keyword formatting (capitalization, acronyms, known patterns) +5. Adds keywords as Plex labels or genres -- never removes existing values +6. Tracks what has been processed to skip it next time -
-

📋 Environment Variables

+Runs on a configurable timer (default 1h). With webhooks enabled, also processes immediately when Plex adds new media. -**Required Settings:** +## Environment Variables -- `PLEX_TOKEN` - Get from Plex Web (F12 → Network → X-Plex-Token) -- `TMDB_READ_ACCESS_TOKEN` - Get from [TMDb API Settings](https://www.themoviedb.org/settings/api) -- `PLEX_SERVER` - Your Plex server address (e.g., `localhost`) -- `PLEX_PORT` - Usually `32400` +### Required -**Library Selection** (choose one approach): +| Variable | Description | +|----------|-------------| +| `PLEX_TOKEN` | Plex authentication token | +| `TMDB_READ_ACCESS_TOKEN` | TMDb API read access token | +| `PLEX_SERVER` | Plex server hostname or IP | +| `PLEX_PORT` | Plex server port (usually 32400) | -- `MOVIE_PROCESS_ALL=true` + `TV_PROCESS_ALL=true` - Process all libraries (recommended) -- `MOVIE_LIBRARY_ID=1` + `TV_LIBRARY_ID=2` - Process specific libraries only +### Library Selection -**Optional Settings:** +Pick one approach per media type: -- `PLEX_REQUIRES_HTTPS=true` - Use HTTPS (default: `true`) -- `UPDATE_FIELD=label` - Field to update: `label` or `genre` (default: `label`) -- `PROCESS_TIMER=1h` - How often to run 24h, 5m, 2h30m etc. (default: `1h`) -- `REMOVE=lock` - Clean mode: `lock` or `unlock` (runs once and exits) -- `VERBOSE_LOGGING=true` - Enable detailed lookup information (default: `false`) -- `DATA_DIR=/data` - Directory for persistent storage (default: `/data`) -- `FORCE_UPDATE=true` - Force reprocess all items regardless of previous processing (default: `false`) +| Variable | Description | +|----------|-------------| +| `MOVIE_PROCESS_ALL=true` | Process all movie libraries | +| `MOVIE_LIBRARY_ID=1` | Process a specific movie library by ID | +| `TV_PROCESS_ALL=true` | Process all TV show libraries | +| `TV_LIBRARY_ID=2` | Process a specific TV library by ID | -**Radarr Integration (Optional):** +### Optional -- `USE_RADARR=true` - Enable Radarr integration (default: `false`) -- `RADARR_URL=http://localhost:7878` - Your Radarr instance URL -- `RADARR_API_KEY=your_api_key` - Your Radarr API key +| Variable | Default | Description | +|----------|---------|-------------| +| `PLEX_REQUIRES_HTTPS` | `false` | Use HTTPS for Plex connection | +| `PLEX_INSECURE_SKIP_VERIFY` | `false` | Skip TLS certificate verification for Plex. Only takes effect when `PLEX_REQUIRES_HTTPS=true`. Enable only for self-signed certs; a `[WARN]` line is logged at startup. | +| `UPDATE_FIELD` | `label` | Field to update: `label` or `genre` | +| `PROCESS_TIMER` | `1h` | How often to run (e.g. `30m`, `2h`, `24h`) | +| `VERBOSE_LOGGING` | `false` | Show detailed lookup and matching info | +| `DATA_DIR` | _(none)_ | Directory for persistent storage; ephemeral if unset | +| `FORCE_UPDATE` | `false` | Reprocess all items regardless of storage state | +| `REMOVE` | _(none)_ | Removal mode: `lock` or `unlock` (runs once and exits) | -**Sonarr Integration (Optional):** +### Batch Processing -- `USE_SONARR=true` - Enable Sonarr integration (default: `false`) -- `SONARR_URL=http://localhost:8989` - Your Sonarr instance URL -- `SONARR_API_KEY=your_sonarr_api_key` - Your Sonarr API key +| Variable | Default | Description | +|----------|---------|-------------| +| `BATCH_SIZE` | `100` | Items per batch | +| `BATCH_DELAY` | `10s` | Pause between batches | +| `ITEM_DELAY` | `500ms` | Pause between individual items | -**Export Integration (Optional):** +### Keyword Prefix -- `EXPORT_LABELS=action,comedy,thriller` - Comma-separated list of labels to export file paths for -- `EXPORT_LOCATION=/path/to/export` - Directory where export files will be created -- `EXPORT_MODE=txt` - Export format: `txt` (default) or `json` +| Variable | Default | Description | +|----------|---------|-------------| +| `KEYWORD_PREFIX` | _(none)_ | String prepended to each keyword (e.g. `"- "`) | -
+### Webhook -
-

📖 How It Works

+| Variable | Default | Description | +|----------|---------|-------------| +| `WEBHOOK_ENABLED` | `false` | Start the webhook HTTP server | +| `WEBHOOK_PORT` | `9090` | Port for the webhook listener | +| `WEBHOOK_DEBOUNCE` | `30s` | Debounce window for rapid events | -1. **Movie Processing**: Iterates through all movies in the library -2. **TMDb ID Extraction**: Gets TMDb IDs from: - - Plex metadata Guid field - - File/folder names with `{tmdb-12345}` format -3. **Keyword Fetching**: Retrieves keywords from TMDb API -4. **Label Synchronization**: Adds new keywords as labels (preserves existing labels) -5. **Progress Tracking**: Remembers processed movies to avoid re-processing +### Radarr/Sonarr -
+| Variable | Default | Description | +|----------|---------|-------------| +| `USE_RADARR` | `false` | Enable Radarr integration | +| `RADARR_URL` | _(none)_ | Radarr base URL (e.g. `http://radarr:7878`) | +| `RADARR_API_KEY` | _(none)_ | Radarr API key | +| `USE_SONARR` | `false` | Enable Sonarr integration | +| `SONARR_URL` | _(none)_ | Sonarr base URL (e.g. `http://sonarr:8989`) | +| `SONARR_API_KEY` | _(none)_ | Sonarr API key | -
-

🚀 Radarr/Sonarr Integration

+### Export -Labelarr now supports automatic TMDb ID detection through Radarr and Sonarr APIs, eliminating the need for TMDb IDs in file paths! +| Variable | Default | Description | +|----------|---------|-------------| +| `EXPORT_LABELS` | _(none)_ | Comma-separated labels to export file paths for | +| `EXPORT_LOCATION` | _(none)_ | Directory for export output | +| `EXPORT_MODE` | `txt` | Export format: `txt` or `json` | -### Benefits +## Radarr/Sonarr Integration -- ✅ **No file renaming required** - Works with your existing file structure -- ✅ **Multiple matching methods** - Title, year, IMDb ID, TVDb ID, file path -- ✅ **Automatic fallback** - If Radarr/Sonarr doesn't have the item, falls back to file path detection -- ✅ **Optional integration** - Enable only if you use Radarr/Sonarr +If your file paths don't contain TMDb IDs, Labelarr can look them up through Radarr and Sonarr's APIs. The lookup chain is: -### ⚡ **Performance Considerations** +1. Plex metadata (fastest) +2. Radarr/Sonarr API (title/year match, then IMDb/TVDb ID, then file path) +3. File path regex (fallback) -- **File path detection is faster** - If your file paths consistently contain TMDb IDs, file path detection is significantly faster than API calls -- **Radarr/Sonarr integration adds latency** - Each API lookup introduces network overhead and processing time -- **Recommendation**: Use file path detection (with TMDb IDs in filenames/folders) as your primary method for best performance -- **When to use APIs**: Only enable Radarr/Sonarr integration if your file paths don't contain TMDb IDs or are inconsistently formatted +This means you don't need to rename any files. Enable it by setting `USE_RADARR=true` and/or `USE_SONARR=true` with the corresponding URL and API key. -### How It Works +File path detection is faster than API calls. If your filenames already include TMDb IDs (e.g. `{tmdb-603}`), you don't need this. -1. **For Movies (Radarr)**: - - Matches by title and year - - Falls back to IMDb ID from Plex - - Checks file paths against Radarr's database - - Extracts TMDb ID from matched movie +API keys: Radarr/Sonarr Settings > General > Security > API Key. -2. **For TV Shows (Sonarr)**: - - Matches by title and year - - Uses TVDb ID from Plex if available - - Falls back to IMDb ID - - Checks episode file paths against Sonarr's database - - Extracts TMDb ID from matched series +## Webhook Support -### Configuration Example +**Requires Plex Pass.** Instead of waiting for the next timer tick, Labelarr can react to Plex webhook events immediately. ```yaml -services: - labelarr: - image: ghcr.io/nullable-eth/labelarr:latest - environment: - # ... other config ... - - # Enable Radarr integration - - USE_RADARR=true - - RADARR_URL=http://radarr:7878 - - RADARR_API_KEY=your_radarr_api_key - - # Enable Sonarr integration - - USE_SONARR=true - - SONARR_URL=http://sonarr:8989 - - SONARR_API_KEY=your_sonarr_api_key +environment: + - WEBHOOK_ENABLED=true + - WEBHOOK_PORT=9090 + - WEBHOOK_DEBOUNCE=30s +ports: + - "9090:9090" ``` -### Finding Your API Keys +Configure Plex to send webhooks to `http://labelarr:9090/webhook` (Settings > Webhooks in Plex). Labelarr listens for `library.new` and `library.on.deck` events. -**Radarr**: Settings → General → Security → API Key -**Sonarr**: Settings → General → Security → API Key +When multiple events arrive for the same library in quick succession (common during bulk imports), the debounce window coalesces them into a single processing run. -
+The webhook server runs alongside the existing timer. Both can be active at the same time. -
-

🔍 TMDb ID Detection

+A health check is available at `/health`. -The application can find TMDb IDs from multiple sources and supports flexible formats: +### Manual Scan Trigger -- **Plex Metadata**: Standard TMDb agent IDs -- **Radarr/Sonarr APIs**: Automatic matching (when enabled) -- **File Paths**: Flexible TMDb ID detection in filenames or directory names +`POST /scan` on the webhook server kicks off a scan cycle without waiting for the timer. Useful when operating in `WEBHOOK_ONLY=true` mode or after extended downtime. -### ✅ **Supported Patterns** (Case-Insensitive) - -The TMDb ID detection is very flexible and supports various formats: +```bash +# Full scan of all non-excluded libraries +curl -X POST http://labelarr:9090/scan -**Direct Concatenation:** +# Scan a single library by Plex section ID +curl -X POST "http://labelarr:9090/scan?library=22" -- `/movies/The Matrix (1999) tmdb603/file.mkv` -- `/movies/Inception (2010) TMDB27205/file.mkv` -- `/movies/Avatar (2009) Tmdb19995/file.mkv` +# Scan a single library by name (case-insensitive) +curl -X POST "http://labelarr:9090/scan?library=Movies" +``` -**With Separators:** +Responses: +- `202 Accepted` — scan started in the background +- `409 Conflict` — a scan is already in progress +- `404 Not Found` — `library` param did not match any configured library +- `405 Method Not Allowed` — non-POST request -- `/movies/Interstellar (2014) tmdb:157336/file.mkv` -- `/movies/The Dark Knight (2008) tmdb-155/file.mkv` -- `/movies/Pulp Fiction (1994) tmdb_680/file.mkv` -- `/movies/Fight Club (1999) tmdb=550/file.mkv` -- `/movies/The Shawshank Redemption (1994) tmdb 278/file.mkv` +## Batch Processing -**With Brackets/Braces:** +Large libraries (4000+ items) can overwhelm Radarr/Sonarr APIs with thousands of requests. Batch processing breaks the work into chunks with pauses between them. -- `/movies/Goodfellas (1990) {tmdb634}/file.mkv` -- `/movies/Forrest Gump (1994) [tmdb-13]/file.mkv` -- `/movies/The Godfather (1972) (tmdb:238)/file.mkv` -- `/movies/Taxi Driver (1976) {tmdb=103}/file.mkv` -- `/movies/Casablanca (1942) (tmdb 289)/file.mkv` +```yaml +environment: + - BATCH_SIZE=100 + - BATCH_DELAY=10s + - ITEM_DELAY=500ms +``` -**Mixed Examples:** +With 4000 items and a batch size of 100, Labelarr processes 100 items, pauses 10 seconds, processes the next 100, and so on. The per-item delay (default 500ms) paces individual API calls within each batch. -- `/movies/Citizen Kane (1941) something tmdb: 15678 extra/file.mkv` -- `/movies/Vertigo (1958) {tmdb=194884}/file.mkv` -- `/movies/Psycho (1960) [ tmdb-539 ]/file.mkv` +## Keyword Prefix -### ❌ **Will NOT Match** +When using `UPDATE_FIELD=genre`, TMDb keywords get mixed in with real Plex genres in the filter dropdown. A prefix separates them visually: -- `mytmdb12345` (preceded by alphanumeric characters) -- `tmdb12345abc` (followed by alphanumeric characters) -- `tmdb` (no digits following) +```yaml +environment: + - UPDATE_FIELD=genre + - KEYWORD_PREFIX="- " +``` -### 📁 **Example File Paths** +This turns `Sci-Fi` into `- Sci-Fi` in the genre list, so real genres sort to the top and keyword-derived genres cluster at the bottom. -``` -/movies/The Matrix (1999) [tmdb-603]/The Matrix.mkv -/movies/Inception (2010) (tmdb:27205)/Inception.mkv -/movies/Avatar (2009) tmdb19995/Avatar.mkv -/movies/Interstellar (2014) TMDB_157336/Interstellar.mkv -/movies/Edge Case - {tmdb=12345}/file.mkv -/movies/Colon: [tmdb:54321]/file.mkv -/movies/Semicolon; (tmdb;67890)/file.mkv -/movies/Underscore_tmdb_11111/file.mkv -/movies/ExtraSuffix tmdb-22222_extra/file.mkv -/movies/Direct tmdb194884 format/file.mkv -``` +The prefix is applied consistently during both add and remove operations. -
+## Keyword Normalization -
-

📤 Export Functionality

+TMDb keywords come in inconsistent formats. Labelarr normalizes them before applying: -Labelarr can automatically export file paths for media items that have specific labels, creating organized lists perfect for syncing content to alternate locations or creating backup sets. +- Title casing with proper article/preposition handling +- Acronym detection: `fbi` -> `FBI`, `cia` -> `CIA` +- Known replacements: `sci-fi` / `scifi` / `sci fi` -> `Sci-Fi`, `romcom` -> `Romantic Comedy` +- Relationship patterns: `father daughter` -> `Father Daughter Relationship` +- Century formatting: `5th century bc` -> `5th Century BC` +- Location formatting: `san francisco, california` -> `San Francisco, California` +- Credit stingers: `duringcreditsstinger` -> `During Credits Stinger` -### 🎯 **What It Does** +When a normalized keyword replaces an old unnormalized version, the old one is automatically removed from Plex. -- **Scans all processed media** for items containing specified labels -- **Creates separate text files** for each export label (e.g., `action.txt`, `comedy.txt`) -- **Lists full file paths** of matching movies and TV show episodes -- **Updates files** after each processing run with current results -- **Preserves existing files** until new export data is ready +90+ test cases cover the normalization rules. -### 🔧 **Configuration** +## Export Functionality -Add these environment variables to enable export functionality: +Generate file path lists for media matching specific labels. Useful for syncing specific genres to other devices or creating targeted backups. ```yaml environment: - # Specify which labels to export (comma-separated, case-insensitive) - - EXPORT_LABELS=action,comedy,thriller,documentary - # Directory where export files will be created + - EXPORT_LABELS=action,comedy,thriller - EXPORT_LOCATION=/data/exports - # Export format: txt (default) creates separate files, json creates single comprehensive file - EXPORT_MODE=txt - # ... other labelarr config ... -``` - -**Complete Docker Compose Example with Export:** - -```yaml -services: - labelarr: - image: ghcr.io/nullable-eth/labelarr:latest - container_name: labelarr - restart: unless-stopped - volumes: - - ./labelarr-data:/data - - ./exports:/data/exports # Mount host directory for export files - environment: - - PLEX_TOKEN=your_plex_token_here - - TMDB_READ_ACCESS_TOKEN=your_tmdb_token - - PLEX_SERVER=plex - - PLEX_PORT=32400 - - MOVIE_PROCESS_ALL=true - - TV_PROCESS_ALL=true - # Export configuration - - EXPORT_LABELS=action,comedy,thriller,documentary,kids - - EXPORT_LOCATION=/data/exports - - EXPORT_MODE=txt -``` - -### 📁 **Output Example** - -With `EXPORT_LABELS=action,comedy,kids` and `EXPORT_LOCATION=/data/exports`, Labelarr will create library-specific subdirectories: - -#### **Text Mode (Default)** - -``` -/data/exports/ -├── summary.txt # Detailed statistics and file sizes -├── Movies/ # Movie library exports -│ ├── action.txt # Action movies only -│ ├── comedy.txt # Comedy movies only -│ └── kids.txt # Kids movies only -└── TV Shows/ # TV show library exports - ├── action.txt # Action TV shows only - ├── comedy.txt # Comedy TV shows only - └── kids.txt # Kids TV shows only +volumes: + - ./exports:/data/exports ``` -#### **JSON Mode** +### Text mode (default) -With `EXPORT_MODE=json`, Labelarr creates a single comprehensive JSON file: +Creates per-library subdirectories with one file per label: ``` /data/exports/ -└── export.json # Complete export data with statistics + summary.txt + Movies/ + action.txt + comedy.txt + TV Shows/ + action.txt + comedy.txt ``` -Each file contains full paths to matching media from that specific library: +Each file lists the full file paths of matching media. -**Movies/action.txt:** +### JSON mode -``` -/data/movies/John Wick (2014)/John Wick (2014) (Bluray-1080p).mkv -/data/movies/Mad Max Fury Road (2015)/Mad Max Fury Road (2015) (Bluray-2160p).mkv -/data/movies/The Dark Knight (2008)/The Dark Knight (2008) (Bluray-2160p).mkv -``` +Creates a single `export.json` with structured data including file sizes and statistics. -**TV Shows/action.txt:** +Label matching is case-insensitive. Items with multiple matching labels appear in each corresponding file. Exported paths reflect Plex's internal filesystem, so you may need to translate container paths to host paths. -``` -/data/tv/Breaking Bad (2008)/Season 01/Breaking Bad S01E01 (1080p).mkv -/data/tv/Breaking Bad (2008)/Season 01/Breaking Bad S01E02 (1080p).mkv -/data/tv/24 (2001)/Season 01/24 S01E01 (1080p).mkv -``` +## TMDb ID Detection -**export.json structure:** - -```json -{ - "generated_at": "2024-01-15 14:30:25", - "export_mode": "json", - "libraries": { - "Movies": { - "action": [ - { - "path": "/data/movies/John Wick (2014)/John Wick (2014) (Bluray-1080p).mkv", - "size": 4832716800 - }, - { - "path": "/data/movies/Mad Max Fury Road (2015)/Mad Max Fury Road (2015) (Bluray-2160p).mkv", - "size": 8945283072 - } - ], - "comedy": [ - { - "path": "/data/movies/The Hangover (2009)/The Hangover (2009) (Bluray-1080p).mkv", - "size": 3221225472 - } - ] - }, - "TV Shows": { - "action": [ - { - "path": "/data/tv/Breaking Bad (2008)/Season 01/Breaking Bad S01E01 (1080p).mkv", - "size": 2147483648 - } - ] - } - }, - "summary": { - "total_files": 1247, - "total_size": 2748779069440, - "total_size_formatted": "2.5 TB", - "library_stats": { - "Movies": { - "total_files": 156, - "total_size": 790495232000, - "total_size_formatted": "736.0 GB", - "labels": { - "action": { - "count": 89, - "size": 478150656000, - "size_formatted": "445.2 GB" - }, - "comedy": { - "count": 67, - "size": 312344576000, - "size_formatted": "290.8 GB" - } - } - } - }, - "label_totals": { - "action": { - "count": 632, - "size": 1797564416000, - "size_formatted": "1.6 TB" - }, - "comedy": { - "count": 615, - "size": 1052901376000, - "size_formatted": "980.3 GB" - } - } - } -} -``` - -**summary.txt:** +Labelarr looks for TMDb IDs in file and folder names using a flexible regex. All of these work: ``` -Labelarr Export Summary -Generated: 2024-01-15 14:30:25 - -📁 Export Files Generated: - Movies/action.txt - Movies/comedy.txt - TV Shows/action.txt - TV Shows/comedy.txt - -📊 Overall Statistics: - Total files: 1,247 - Total size: 2.5 TB (2,748,779,069,440 bytes) - -📚 Library Breakdown: - - Movies: - action.txt: 89 files, 445.2 GB (478,150,656,000 bytes) - comedy.txt: 67 files, 290.8 GB (312,344,576,000 bytes) - Library total: 156 files, 736.0 GB (790,495,232,000 bytes) - - TV Shows: - action.txt: 543 files, 1.2 TB (1,319,413,760,000 bytes) - comedy.txt: 548 files, 689.5 GB (740,556,800,000 bytes) - Library total: 1,091 files, 1.8 TB (2,059,970,560,000 bytes) - -🏷️ Label Totals (All Libraries): - action: 632 files, 1.6 TB (1,797,564,416,000 bytes) - comedy: 615 files, 980.3 GB (1,052,901,376,000 bytes) +/movies/The Matrix (1999) {tmdb-603}/file.mkv +/movies/Inception (2010) [tmdb:27205]/file.mkv +/movies/Avatar (2009) tmdb19995/file.mkv +/movies/Interstellar (2014) (tmdb=157336)/file.mkv +/movies/The Dark Knight (2008) TMDB_155/file.mkv ``` -### 🔄 **Use Cases** - -**Content Syncing:** - -- Export specific genres to sync to mobile devices or remote locations -- Create curated collections for different family members -- Sync action movies to gaming setup, kids content to tablets -- **Sync specific content to alternate Plex servers** for distributed media setups -- **Separate movie and TV exports** for different sync destinations -- **JSON format for programmatic processing** of export data with file sizes and metadata - -**Backup Management:** +Separators (`-`, `:`, `_`, `=`, space) and bracket styles (`{}`, `[]`, `()`) all work. Case-insensitive. -- Generate lists of premium content for priority backup -- Create separate backup sets by genre or rating -- Export documentary collections for educational archives -- **Create targeted backup lists** for specific movies/TV shows -- **Library-specific backup strategies** (movies vs TV shows) -- **JSON export for automated backup tools** that need file size information +Will not match: `mytmdb12345` (preceded by letters), `tmdb` (no digits), `tmdb12345abc` (followed by letters). -**Media Organization:** +### Radarr naming format -- Generate playlists for external media players -- Create file lists for batch operations (transcoding, moving, etc.) -- Export specific content types for different storage tiers -- **Organize exports by library type** for easier management -- **API integration with JSON format** for custom media management tools +To include TMDb IDs in Radarr-managed files, set the folder format to: -### 🚀 **Performance** - -- **Memory efficient**: Accumulates paths during processing, writes once at completion -- **Atomic updates**: Existing export files preserved until new data is ready -- **Minimal overhead**: Only ~2-5 MB RAM usage for large libraries (10K+ items) - -### 💡 **Tips** - -- **Label names are case-insensitive**: `Action`, `action`, and `ACTION` all match -- **Multiple labels per item**: Movies with both "action" and "comedy" labels appear in both export files -- **Empty files created**: Labels with no matches still get empty `.txt` files for consistency (text mode) -- **File paths included**: Both movie files and all TV show episode files are included -- **Library separation**: Files are organized by Plex library (e.g., `Movies/action.txt` vs `TV Shows/action.txt`) in text mode -- **Library names sanitized**: Special characters in library names are replaced with underscores for valid folder names -- **Summary statistics**: `summary.txt` provides detailed file counts, sizes, and breakdowns by library and label (text mode) -- **File sizes from Plex**: Uses Plex metadata for accurate file sizes without filesystem access -- **JSON export includes everything**: Single file with all data, file sizes, and comprehensive statistics -- **Choose format based on use case**: Use `txt` for simple file lists, `json` for programmatic processing - -### ⚠️ **Important Notes** - -**Container File Paths:** - -- Exported file paths reflect your **Plex container's internal file system** -- If using volume mounts (e.g., `-v /host/media:/data/media`), paths may need processing -- Example: Plex sees `/data/media/movies/...` but host filesystem has `/mnt/nas/movies/...` -- Consider path mapping/replacement when using exported files outside the container environment - -**Path Processing Example:** - -```bash -# If Plex container mounts: -v /mnt/nas/media:/data/media -# Export shows: /data/media/movies/Action Movie.mkv -# You may need: /mnt/nas/media/movies/Action Movie.mkv +``` +{Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}} ``` -
- -
-

🔧 Advanced Configuration

- -
-🔍 Finding Library IDs - -To find your library's ID, open your Plex web app, click on the desired library, and look for `source=` in the URL: - -- `https://app.plex.tv/desktop/#!/media/xxxx/com.plexapp.plugins.library?source=1` -- Here, the library ID is `1` - -**⚠️ Note**: Starting with this version, explicit library configuration is required. The application will **NOT** auto-select libraries by default. - -- `MOVIE_LIBRARY_ID=1` - Process only specific movie library -- `MOVIE_PROCESS_ALL=true` - Process all movie libraries (recommended) -- Neither set: Movies are **NOT** processed - -
- -
-🏷️ Labels vs Genres (UPDATE_FIELD) - -Control whether TMDb keywords are synced as Plex **labels** (default) or **genres**: - -- `UPDATE_FIELD=label` (default): Syncs keywords as Plex labels -- `UPDATE_FIELD=genre`: Syncs keywords as Plex genres - -The chosen field will be **locked** after update to prevent Plex from overwriting it. - -![Example of genres updated and locked by Labelarr](example/genre.png) - -
+For existing libraries, use Radarr's mass rename feature to apply the new format. -
-🗑️ Removing Keywords (REMOVE) +## Removing Keywords -Remove **only** TMDb keywords while preserving custom labels/genres: +`REMOVE=lock` or `REMOVE=unlock` runs a single pass that removes TMDb keywords from the configured field, then exits. -- `REMOVE=lock`: Removes TMDb keywords and **locks** the field -- `REMOVE=unlock`: Removes TMDb keywords and **unlocks** the field for Plex to update +- `lock`: removes keywords, keeps the field locked (Plex can't overwrite) +- `unlock`: removes keywords, unlocks the field (Plex can refresh it) -**Use lock when**: You manually manage labels/genres -**Use unlock when**: You want Plex to refresh metadata naturally +Only TMDb-sourced keywords are removed. Custom labels you added manually are preserved. ```bash -# Example: Remove TMDb keywords from labels and lock field docker run --rm \ -e PLEX_TOKEN=... -e TMDB_READ_ACCESS_TOKEN=... \ -e REMOVE=lock -e UPDATE_FIELD=label \ - -e MOVIE_PROCESS_ALL=true -e TV_PROCESS_ALL=true \ + -e MOVIE_PROCESS_ALL=true \ ghcr.io/nullable-eth/labelarr:latest ``` -
- -
-🔒 Field Locking & Plex Metadata - -**Locked fields** in Plex are protected from automatic updates: - -- ✅ Labelarr can still modify them -- ✅ Manual edits in Plex UI still work -- ❌ Plex cannot overwrite during metadata refresh -- 🔒 Lock icon appears in Plex UI - -**Unlocked fields** can be updated by Plex during metadata refreshes. - -**Labelarr's behavior:** - -- **Adding keywords**: Always locks the field -- **Remove with lock**: Keeps field locked after removing keywords -- **Remove with unlock**: Unlocks field for Plex to manage - -
- -
-🔄 Migration from Previous Version - -**⚠️ Breaking Changes**: This version requires explicit library configuration. - -**Old behavior**: Auto-selected first movie library -**New behavior**: Must specify which libraries to process - -**Migration steps:** - -```bash -# Before (auto-selected movies) --e LIBRARY_ID=1 - -# After (explicit selection) --e MOVIE_LIBRARY_ID=1 # Specific library -# OR --e MOVIE_PROCESS_ALL=true # All movie libraries --e TV_PROCESS_ALL=true # All TV libraries -``` - -**New Features:** - -- 📺 TV show support -- 🔇 Reduced verbose output -- 📊 Better progress tracking -- 🛡️ Enhanced error handling - -
- -
- -
-

🔒 Understanding Field Locking & Plex Metadata

- -Field locking is a crucial concept in Plex that determines whether Plex can automatically update metadata fields during library scans and metadata refreshes. Understanding how this works with Labelarr is essential for managing your media library effectively. - -
-🔐 What is Field Locking? - -When a field is **locked** in Plex: - -- ✅ The field value is **protected** from automatic changes -- ✅ Plex **cannot** overwrite the field during metadata refresh -- ✅ Manual edits in Plex UI are still possible -- ✅ External tools (like Labelarr) can still modify the field -- 🔒 A **lock icon** appears next to the field in Plex UI - -When a field is **unlocked** in Plex: - -- 🔄 Plex **can** update the field during metadata refresh -- 🔄 New metadata agents can overwrite existing values -- 🔄 "Refresh Metadata" will update the field with fresh data -- 🔓 **No lock icon** appears in Plex UI - -
- -
-🎯 Labelarr's Field Locking Behavior - -#### **During Normal Operation (Adding Keywords)** - -Labelarr **always locks** the field after adding TMDb keywords to prevent Plex from accidentally removing them during future metadata refreshes. - -#### **During Remove Operation** - -- `REMOVE=lock`: Removes TMDb keywords but **keeps the field locked** -- `REMOVE=unlock`: Removes TMDb keywords and **unlocks the field** - -
- -
-📋 Practical Examples - -#### **Scenario 1: Mixed Content Management** - -You have movies with: - -- 🏷️ TMDb keywords: `action`, `thriller`, `heist` -- 🏷️ Custom labels: `watched`, `favorites`, `4k-remaster` - -**Using `REMOVE=lock`:** - -- ✅ Removes only: `action`, `thriller`, `heist` -- ✅ Keeps: `watched`, `favorites`, `4k-remaster` -- 🔒 Field remains **locked** - Plex won't add new genres -- 💡 **Best for**: Users who manually manage labels alongside TMDb keywords - -**Using `REMOVE=unlock`:** - -- ✅ Removes only: `action`, `thriller`, `heist` -- ✅ Keeps: `watched`, `favorites`, `4k-remaster` -- 🔓 Field becomes **unlocked** - Plex can add new metadata -- 💡 **Best for**: Users who want Plex to manage metadata going forward - -#### **Scenario 2: Complete Reset** - -You want to completely reset your library's metadata: - -1. **Step 1**: `REMOVE=unlock` - Removes TMDb keywords and unlocks fields -2. **Step 2**: Use Plex's "Refresh All Metadata" to restore original metadata -3. **Result**: Clean slate with Plex's default metadata - -
- -
-🛡️ Best Practices - -#### **Use Locking When:** - -- ✅ You manually curate labels/genres -- ✅ You use labels for organization (playlists, collections, etc.) -- ✅ You want to prevent accidental metadata overwrites -- ✅ You share your library and need consistent metadata - -#### **Use Unlocking When:** - -- ✅ You want to return to Plex's default metadata behavior -- ✅ You're switching to a different metadata agent -- ✅ You want Plex to automatically update metadata in the future -- ✅ You're troubleshooting metadata issues - -
- -
-🔍 Visual Indicators - -In Plex Web UI, you'll see: - -- 🔒 **Lock icon** = Field is locked (protected from automatic updates) -- 🔓 **No lock icon** = Field is unlocked (can be updated by Plex) - -![Example of locked genre field in Plex](example/genre.png) - -*The lock icon indicates this genre field is protected from automatic changes* - -
- -
- -
-

🔍 Verbose Logging

- -Enable verbose logging to see detailed information about TMDb ID lookups and matching attempts. - -### What it shows - -When `VERBOSE_LOGGING=true`, you'll see: - -- 📋 All available Plex GUIDs for each item -- 🎬 Radarr lookup attempts (title, file path, IMDb ID) -- 📺 Sonarr lookup attempts (title, TVDb ID, IMDb ID, file paths) -- 📁 File path pattern matching attempts -- ✅ Successful matches with source information -- ❌ Failed lookup attempts with reasons - -### Example Output - -``` -🔍 Starting TMDb ID lookup for movie: The Matrix (1999) - 📋 Available Plex GUIDs: - - imdb://tt0133093 - - tmdb://603 - ✅ Found TMDb ID in Plex metadata: 603 - -🔍 Starting TMDb ID lookup for movie: Inception (2010) - 📋 Available Plex GUIDs: - - imdb://tt1375666 - 🎬 Checking Radarr for movie match... - → Searching by title: "Inception" year: 2010 - ✅ Found match in Radarr: Inception (TMDb: 27205) - -🔍 Starting TMDb ID lookup for TV show: Breaking Bad (2008) - 📋 Available Plex GUIDs: - - tvdb://81189 - - imdb://tt0903747 - 📺 Checking Sonarr for series match... - → Searching by title: "Breaking Bad" year: 2008 - ❌ No match found by title/year - → Searching by TVDb ID: 81189 - ✅ Found match by TVDb ID: Breaking Bad (TMDb: 1396) -``` - -### Configuration +## Field Locking -```yaml -environment: - - VERBOSE_LOGGING=true -``` +Labelarr locks the label/genre field after writing to prevent Plex from overwriting keywords during metadata refreshes. Locked fields show a lock icon in the Plex UI. -This is especially useful for: +You can still edit locked fields manually in Plex. External tools (including Labelarr) can also modify them. -- Troubleshooting why certain items aren't being matched -- Understanding which data source provided the TMDb ID -- Debugging Radarr/Sonarr integration issues +![Example of locked genre field](example/genre.png) -
+## Force Update Mode -
-

📝 Keyword Normalization

+Set `FORCE_UPDATE=true` to reprocess every item regardless of whether it was already processed. Useful after: -Labelarr automatically normalizes keywords from TMDb using intelligent pattern recognition and proper capitalization rules. +- Enabling keyword normalization on an existing library +- Switching between label and genre modes +- Wanting to refresh all keywords from TMDb -### How it works +This bypasses both the storage check and the "already has all keywords" check. -- **Smart Title Casing**: Proper capitalization with article/preposition handling -- **Acronym Recognition**: Automatically detects "fbi" → "FBI", "usa" → "USA" -- **Pattern-Based Rules**: Dynamic handling of common patterns without hardcoding every keyword -- **Critical Replacements**: Known abbreviations like "sci-fi" → "Sci-Fi", "romcom" → "Romantic Comedy" -- **Intelligent Patterns**: Recognizes relationships, locations, decades, and compound terms -- **Duplicate Removal**: Removes duplicates after normalization +## Verbose Logging -### Examples +`VERBOSE_LOGGING=true` shows the full TMDb ID lookup chain for each item: which Plex GUIDs are available, Radarr/Sonarr lookup attempts, file path matching, and the source of the final match. -**Before normalization:** +Useful for debugging why specific items aren't being matched. -``` -sci-fi, action, fbi, based on novel, time travel, woman in peril -``` - -**After normalization:** +## Persistent Storage -``` -Sci-Fi, Action, FBI, Based on Novel, Time Travel, Woman in Peril -``` +When `DATA_DIR` is set (e.g. `/data`), Labelarr saves processed items to a JSON file so it can skip them on restart. Without `DATA_DIR`, it runs in ephemeral mode and reprocesses everything each cycle. -### Pattern Recognition Examples - -- **Critical Replacements**: `sci-fi`, `scifi`, `sci fi` → `Sci-Fi` -- **Relationships**: `father daughter` → `Father Daughter Relationship` -- **Locations**: `san francisco, california` → `San Francisco, California` -- **Versus Patterns**: `man vs nature` → `Man vs Nature` -- **Based On**: `based on novel` → `Based on Novel` -- **Decades**: `1940s` → `1940s` (preserved) -- **Ethnicity**: `african american lead` → `African American Lead` -- **General Terms**: Any multi-word keyword gets proper title casing - -### Smart Duplicate Cleaning - -Labelarr automatically cleans up duplicate keywords when applying normalization: - -- **Removes old versions**: If you have "sci-fi" and we add "Sci-Fi", the old version is removed -- **Preserves manual keywords**: Custom tags you've added manually are always kept -- **Handles complex patterns**: Works with all normalization patterns (agencies, centuries, etc.) - -### Verbose Logging - -With `VERBOSE_LOGGING=true`, you'll see normalization and cleaning in action: - -``` -📝 Normalized: "sci-fi" → "Sci-Fi" -📝 Normalized: "fbi" → "FBI" -📝 Normalized: "based on novel" → "Based on Novel" -🧹 Cleaned 2 duplicate/unnormalized keywords -``` - -
- -
-

🔄 Force Update Mode

- -Use force update mode to reprocess all items in your library, regardless of whether they've been processed before. This is especially useful after implementing keyword normalization or when you want to refresh all metadata. - -### When to use Force Update - -- **After enabling keyword normalization** - Update existing keywords with proper formatting -- **Configuration changes** - When switching between label/genre fields -- **Keyword cleanup** - Refresh all TMDb keywords with latest data -- **Initial migration** - When moving from another labeling system - -### Configuration +Mount a volume to persist across container restarts: ```yaml +volumes: + - ./labelarr-data:/data environment: - - FORCE_UPDATE=true -``` - -### What it does - -When `FORCE_UPDATE=true`: - -- ✅ Processes all items regardless of previous processing status -- ✅ Reapplies keywords even if they already exist -- ✅ Updates storage with latest processing information -- ✅ Shows "FORCE UPDATE MODE" message in logs - -### Example Output - -``` -✅ Found 1250 movies in library -🔄 FORCE UPDATE MODE: All items will be reprocessed regardless of previous processing -⏳ Processing movies... -``` - -**⚠️ Note**: Force update will reprocess your entire library, which may take time for large collections. Consider running with `VERBOSE_LOGGING=true` to monitor progress. - -
- -
-

🔑 Getting API Keys

- -### Plex Token - -1. Open Plex Web App in browser -2. Press F12 → Network tab -3. Refresh the page -4. Find any request with `X-Plex-Token` in headers -5. Copy the token value - -### TMDb API Key - -1. Visit [TMDb API Settings](https://www.themoviedb.org/settings/api) -2. Create account and generate API key -3. Use the Read Access Token (not the API key) - -
- -
-

🔧 Troubleshooting

- -### Common Issues - -**401 Unauthorized from Plex** - -- Verify your Plex token is correct -- Check if your Plex server requires HTTPS - -**401 Unauthorized from TMDb** - -- Ensure you're using a valid API token. - -**No TMDb ID found** - -- Check if your movies have TMDb metadata -- Verify file naming includes `{tmdb-12345}` format -- Ensure TMDb agent is used in Plex - -**Connection refused** - -- Check PLEX_SERVER and PLEX_PORT values -- Try setting PLEX_REQUIRES_HTTPS=false for local servers - -### 🎬 Radarr Users: Ensuring TMDb ID in File Paths - -If you're using Radarr to manage your movie collection, follow these steps to ensure Labelarr can detect TMDb IDs from your file paths: - -#### **Configure Radarr Naming to Include TMDb ID** - -Radarr can automatically include TMDb IDs in your movie file and folder names. Update your naming scheme in Radarr settings: - -**Recommended Settings:** - -1. **Movie Folder Format**: - - ``` - {Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}} - ``` - - *Example*: `The Matrix (1999) {tmdb-603}` - -2. **Movie File Format**: - - ``` - {Movie CleanTitle} ({Release Year}) {tmdb-{TmdbId}} - {[Quality Full]}{[MediaInfo VideoDynamicRangeType]}{[Mediainfo AudioCodec}{ Mediainfo AudioChannels]}{[MediaInfo VideoCodec]}{-Release Group} - ``` - - *Example*: `The Matrix (1999) {tmdb-603} - [Bluray-1080p][x264][DTS 5.1]-GROUP` - -#### **Alternative Radarr Naming Options** - -If you prefer different bracket styles, these formats also work with Labelarr: - -- **Square brackets**: `{Movie CleanTitle} ({Release Year}) [tmdb-{TmdbId}]` -- **Parentheses**: `{Movie CleanTitle} ({Release Year}) (tmdb-{TmdbId})` -- **Different delimiters**: `{Movie CleanTitle} ({Release Year}) {tmdb:{TmdbId}}` or `{Movie CleanTitle} ({Release Year}) {tmdb;{TmdbId}}` - -#### **Common Radarr Configuration Pitfalls** - -❌ **Avoid these common mistakes:** - -1. **Missing TMDb ID in paths**: Default Radarr naming like `{Movie CleanTitle} ({Release Year})` doesn't include TMDb IDs -2. **Using only IMDb IDs**: `{imdb-{ImdbId}}` won't work - Labelarr specifically needs TMDb IDs -3. **Folder vs. file naming**: Ensure TMDb ID is in at least one location (folder name OR file name) - -#### **Verifying Your Configuration** - -After updating Radarr naming: - -1. **For new movies**: TMDb IDs will be included automatically -2. **For existing movies**: Use Radarr's "Rename Files" feature: - - Go to Movies → Select movies → Mass Editor - - Choose your root folder and click "Yes, move files" - - This will rename existing files to match your new naming scheme - -#### **Plex Agent Compatibility** - -- **New Plex Movie Agent**: Works with any naming scheme above -- **Legacy Plex Movie Agent**: May require specific TMDb ID placement for optimal matching -- **Best practice**: Include TMDb ID in folder names for maximum compatibility - -#### **Example Directory Structure** - + - DATA_DIR=/data ``` -/movies/ -├── The Matrix (1999) {tmdb-603}/ -│ └── The Matrix (1999) {tmdb-603} - [Bluray-1080p].mkv -├── Inception (2010) [tmdb-27205]/ -│ └── Inception (2010) [tmdb-27205] - [WEBDL-1080p].mkv -└── Avatar (2009) (tmdb:19995)/ - └── Avatar (2009) (tmdb:19995) - [Bluray-2160p].mkv -``` - -#### **Migration from Existing Libraries** - -If you have an existing movie library without TMDb IDs in file paths: - -1. **Update Radarr naming scheme** as shown above -2. **Use Radarr's mass rename feature** to update existing files -3. **Wait for Plex to detect the changes** (or manually scan library) -4. **Run Labelarr** - it will now detect TMDb IDs from the updated file paths - -**⚠️ Note**: Large libraries may take time to rename. Consider doing this in batches during low-usage periods. - -### 📺 Sonarr Users: Renaming Existing Folders to Include TMDb ID -If you're using Sonarr to manage your TV show collection and want to apply new folder naming that includes TMDb IDs, here's how to rename existing folders: +## Getting API Keys -#### **🔄 Apply the New Folder Names** +**Plex Token:** Open Plex Web, press F12, go to Network tab, refresh the page, and look for `X-Plex-Token` in any request header. -To actually rename existing folders: +**TMDb:** Create an account at [themoviedb.org](https://www.themoviedb.org/settings/api) and generate a Read Access Token. -1. **Go to the Series tab** +**Radarr/Sonarr:** Settings > General > Security > API Key. -2. **Click the Mass Editor** (three sliders icon) +## Troubleshooting -3. **Select the shows** you want to rename +**401 from Plex** -- Check your token. Try `PLEX_REQUIRES_HTTPS=false` for local servers. -4. **At the bottom, click "Edit"** +**401 from TMDb** -- Make sure you're using the Read Access Token, not the API key. -5. **In the popup:** - - Set the **Root Folder** to the same one it's already using (e.g., `/mnt/user/TV`) - - Click **"Save"** +**No TMDb ID found** -- Enable `VERBOSE_LOGGING=true` to see where the lookup fails. Either add TMDb IDs to your file paths, enable Radarr/Sonarr integration, or make sure Plex is using the TMDb agent. -6. **Sonarr will interpret this as a move** and apply the new folder naming format without physically moving the files—just renaming the folders. +**Container permission errors** -- If you see "mkdir /data: permission denied", either set `DATA_DIR` to a writable path with a mounted volume, or leave `DATA_DIR` unset to run in ephemeral mode. -#### **Example Result** +**Large library crashes** -- Set `BATCH_SIZE` and `BATCH_DELAY` to reduce API pressure. The defaults (100 items, 10s pause) work for most setups. -After applying the new naming format, your TV show folders will include TMDb IDs: - -``` -/tv/Batman [tmdb-2287]/Season 3/Batman - S03E17 - The Joke's on Catwoman Bluray-1080p [tmdb-2287].mkv -``` - -**💡 Pro Tip**: This method works for renaming folders without actually moving files, making it safe and efficient for large TV libraries. - -
- -
-

🛠️ Local Development

- -### Prerequisites - -- Go 1.23+ -- Git - -### Build and Run +## Local Development ```bash -# Clone the repository git clone https://github.com/nullable-eth/labelarr.git cd labelarr - -# Initialize Go modules go mod tidy +go build -o labelarr ./cmd/labelarr -# Set environment variables -export PLEX_SERVER=localhost -export PLEX_PORT=32400 -export PLEX_TOKEN=your_plex_token -export TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token -export MOVIE_PROCESS_ALL=true -export TV_PROCESS_ALL=true - -# Run the application -go run main.go +# Set required env vars and run +./labelarr ``` -### Build Binary - -```bash -# Build for current platform -go build -o labelarr main.go - -# Build for Linux (Docker) -CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr main.go -``` - -
- -
-

📊 Monitoring

- -### View Logs - -```bash -# Docker logs -docker logs labelarr - -# Follow logs -docker logs -f labelarr -``` - -### Log Output Includes - -- Processing progress with movie counts -- TMDb ID detection results -- Label synchronization status -- API error handling and retries -- Detailed processing summaries - -
- -
-

🤝 Contributing

- -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -
- -
-

📞 Support

- -- **GitHub**: [https://github.com/nullable-eth/labelarr](https://github.com/nullable-eth/labelarr) -- **Issues**: Report bugs and feature requests -- **Logs**: Check container logs for troubleshooting with `docker logs labelarr` - -
- -
-

📄 License

- -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -
- ---- +Build for Docker: `CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr ./cmd/labelarr` -**Tags**: plex, tmdb, automation, movies, tv shows, labels, genres, docker, go, selfhosted, media management +Run tests: `go test ./...` ---- +## License -⭐ **If you find this project helpful, please consider giving it a star!** +MIT. See [LICENSE](LICENSE). diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go index 3e07b21..ac37c8b 100644 --- a/cmd/labelarr/main.go +++ b/cmd/labelarr/main.go @@ -1,8 +1,11 @@ package main import ( + "context" "fmt" "os" + "os/signal" + "syscall" "time" "github.com/nullable-eth/labelarr/internal/config" @@ -11,97 +14,93 @@ import ( "github.com/nullable-eth/labelarr/internal/radarr" "github.com/nullable-eth/labelarr/internal/sonarr" "github.com/nullable-eth/labelarr/internal/tmdb" + "github.com/nullable-eth/labelarr/internal/utils" + "github.com/nullable-eth/labelarr/internal/version" + "github.com/nullable-eth/labelarr/internal/webhook" ) func main() { - // Load configuration + fmt.Printf("[INFO] Labelarr v%s\n", version.Version) + cfg := config.Load() - // Validate configuration if err := cfg.Validate(); err != nil { - fmt.Printf("❌ Configuration error: %v\n", err) + fmt.Printf("[ERROR] Configuration error: %v\n", err) os.Exit(1) } - // Initialize clients plexClient := plex.NewClient(cfg) tmdbClient := tmdb.NewClient(cfg) - // Test TMDb connection if err := tmdbClient.TestConnection(); err != nil { - fmt.Printf("❌ Failed to connect to TMDb: %v\n", err) + fmt.Printf("[ERROR] Failed to connect to TMDb: %v\n", err) os.Exit(1) } - fmt.Println("✅ Successfully connected to TMDb") + fmt.Println("[OK] Successfully connected to TMDb") - // Initialize Radarr client if enabled var radarrClient *radarr.Client if cfg.UseRadarr { radarrClient = radarr.NewClient(cfg.RadarrURL, cfg.RadarrAPIKey) if err := radarrClient.TestConnection(); err != nil { - fmt.Printf("❌ Failed to connect to Radarr: %v\n", err) + fmt.Printf("[ERROR] Failed to connect to Radarr: %v\n", err) os.Exit(1) } - fmt.Println("✅ Successfully connected to Radarr") + fmt.Println("[OK] Successfully connected to Radarr") } - // Initialize Sonarr client if enabled var sonarrClient *sonarr.Client if cfg.UseSonarr { sonarrClient = sonarr.NewClient(cfg.SonarrURL, cfg.SonarrAPIKey) if err := sonarrClient.TestConnection(); err != nil { - fmt.Printf("❌ Failed to connect to Sonarr: %v\n", err) + fmt.Printf("[ERROR] Failed to connect to Sonarr: %v\n", err) os.Exit(1) } - fmt.Println("✅ Successfully connected to Sonarr") + fmt.Println("[OK] Successfully connected to Sonarr") } - // Initialize single processor - processor, err := media.NewProcessor(cfg, plexClient, tmdbClient, radarrClient, sonarrClient) + processor, err := media.NewProcessor(cfg, media.Clients{ + Plex: plexClient, + TMDb: tmdbClient, + Radarr: radarrClient, + Sonarr: sonarrClient, + }) if err != nil { - fmt.Printf("❌ Failed to initialize processor: %v\n", err) + fmt.Printf("[ERROR] Failed to initialize processor: %v\n", err) os.Exit(1) } - fmt.Println("🏷️ Starting Labelarr with TMDb Integration...") - fmt.Printf("📡 Server: %s://%s:%s\n", cfg.Protocol, cfg.PlexServer, cfg.PlexPort) + fmt.Println("[INFO] Starting Labelarr with TMDb Integration...") + fmt.Printf("[NET] Server: %s://%s:%s\n", cfg.Protocol, cfg.PlexServer, cfg.PlexPort) - // Get and validate libraries movieLibraries, tvLibraries := getLibraries(cfg, plexClient) - // Handle REMOVE mode - run once and exit if cfg.IsRemoveMode() { handleRemoveMode(cfg, processor, movieLibraries, tvLibraries) os.Exit(0) } - // Handle normal processing mode handleNormalMode(cfg, processor, movieLibraries, tvLibraries) } -// getLibraries fetches, separates, and validates libraries from Plex func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, []plex.Library) { - // Get all libraries - fmt.Println("📚 Fetching all libraries...") + fmt.Println("[INFO] Fetching all libraries...") libraries, err := plexClient.GetAllLibraries() if err != nil { - fmt.Printf("❌ Error fetching libraries: %v\n", err) + fmt.Printf("[ERROR] Error fetching libraries: %v\n", err) os.Exit(1) } if len(libraries) == 0 { - fmt.Println("❌ No libraries found!") + fmt.Println("[ERROR] No libraries found!") os.Exit(1) } - fmt.Printf("✅ Found %d libraries:\n", len(libraries)) + fmt.Printf("[OK] Found %d libraries:\n", len(libraries)) for _, lib := range libraries { - fmt.Printf(" 📁 ID: %s - %s (%s)\n", lib.Key, lib.Title, lib.Type) + fmt.Printf(" ID: %s - %s (%s)\n", lib.Key, lib.Title, lib.Type) } - // Separate libraries by type - var movieLibraries []plex.Library - var tvLibraries []plex.Library + var movieLibraries, tvLibraries []plex.Library for _, lib := range libraries { switch lib.Type { case "movie": @@ -110,214 +109,242 @@ func getLibraries(cfg *config.Config, plexClient *plex.Client) ([]plex.Library, tvLibraries = append(tvLibraries, lib) } } + movieLibraries = filterExcluded(movieLibraries, utils.StringSet(cfg.MovieLibraryExclude), "movie") + tvLibraries = filterExcluded(tvLibraries, utils.StringSet(cfg.TVLibraryExclude), "TV") - // Validate libraries exist if len(movieLibraries) == 0 && !cfg.ProcessTVShows() { - fmt.Println("❌ No movie library found!") + fmt.Println("[ERROR] No movie library found!") os.Exit(1) } if cfg.ProcessTVShows() && len(tvLibraries) == 0 { - fmt.Println("❌ No TV show library found!") + fmt.Println("[ERROR] No TV show library found!") os.Exit(1) } return movieLibraries, tvLibraries } -// displayLibrarySelection shows which libraries will be processed +func filterExcluded(libs []plex.Library, exclude map[string]bool, kind string) []plex.Library { + if len(exclude) == 0 { + return libs + } + kept := libs[:0] + for _, lib := range libs { + if exclude[lib.Key] { + fmt.Printf("[INFO] Excluding %s library: %s (ID: %s)\n", kind, lib.Title, lib.Key) + continue + } + kept = append(kept, lib) + } + return kept +} + +// findLibraryName returns the library title for the given ID, or the fallback if not found. +func findLibraryName(libraries []plex.Library, id, fallback string) string { + for _, lib := range libraries { + if lib.Key == id { + return lib.Title + } + } + return fallback +} + +// forEachLibrary calls fn for the relevant libraries based on config (processAll vs specific ID). +func forEachLibrary(processAll bool, specificID string, libraries []plex.Library, fallbackName string, fn func(id, name string)) { + if processAll { + for _, lib := range libraries { + fn(lib.Key, lib.Title) + } + } else if specificID != "" { + fn(specificID, findLibraryName(libraries, specificID, fallbackName)) + } +} + func displayLibrarySelection(cfg *config.Config, movieLibraries, tvLibraries []plex.Library) { - // Movie library selection if cfg.ProcessMovies() { if cfg.MovieProcessAll { - fmt.Printf("🎯 Processing all %d movie libraries\n", len(movieLibraries)) + fmt.Printf("[INFO] Processing all %d movie libraries\n", len(movieLibraries)) } else if cfg.MovieLibraryID != "" { - found := false - for _, lib := range movieLibraries { - if lib.Key == cfg.MovieLibraryID { - fmt.Printf("\n🎯 Using specified movie library: %s (ID: %s)\n", lib.Title, lib.Key) - found = true - break - } - } - if !found { - fmt.Printf("❌ Movie library with ID %s not found!\n", cfg.MovieLibraryID) + name := findLibraryName(movieLibraries, cfg.MovieLibraryID, "") + if name == "" { + fmt.Printf("[ERROR] Movie library with ID %s not found!\n", cfg.MovieLibraryID) os.Exit(1) } + fmt.Printf("[INFO] Using specified movie library: %s (ID: %s)\n", name, cfg.MovieLibraryID) } } - // TV library selection if cfg.ProcessTVShows() { if cfg.TVProcessAll { - fmt.Printf("📺 Processing all %d TV show libraries\n", len(tvLibraries)) + fmt.Printf("[INFO] Processing all %d TV show libraries\n", len(tvLibraries)) } else if cfg.TVLibraryID != "" { - found := false - for _, lib := range tvLibraries { - if lib.Key == cfg.TVLibraryID { - fmt.Printf("\n📺 Using specified TV library: %s (ID: %s)\n", lib.Title, lib.Key) - found = true - break - } - } - if !found { - fmt.Printf("❌ TV library with ID %s not found!\n", cfg.TVLibraryID) + name := findLibraryName(tvLibraries, cfg.TVLibraryID, "") + if name == "" { + fmt.Printf("[ERROR] TV library with ID %s not found!\n", cfg.TVLibraryID) os.Exit(1) } + fmt.Printf("[INFO] Using specified TV library: %s (ID: %s)\n", name, cfg.TVLibraryID) } else { - fmt.Printf("\n📺 Using TV library: %s (ID: %s)\n", tvLibraries[0].Title, tvLibraries[0].Key) + fmt.Printf("[INFO] Using TV library: %s (ID: %s)\n", tvLibraries[0].Title, tvLibraries[0].Key) } } } -// handleRemoveMode processes keyword removal and exits func handleRemoveMode(cfg *config.Config, processor *media.Processor, movieLibraries, tvLibraries []plex.Library) { - // Display selected libraries displayLibrarySelection(cfg, movieLibraries, tvLibraries) - fmt.Printf("\n🗑️ Starting keyword removal from (field: %s, lock: %s)...\n", cfg.UpdateField, cfg.RemoveMode) + fmt.Printf("\n[REMOVE] Starting keyword removal (field: %s, lock: %s)...\n", cfg.UpdateField, cfg.RemoveMode) if cfg.ProcessMovies() { - // Process movie libraries - if cfg.MovieProcessAll { - for _, lib := range movieLibraries { - fmt.Printf("🎬 Processing library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.RemoveKeywordsFromItems(lib.Key, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error removing keywords from movies: %v\n", err) - } - } - } else if cfg.MovieLibraryID != "" { - if err := processor.RemoveKeywordsFromItems(cfg.MovieLibraryID, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error removing keywords from movies: %v\n", err) + forEachLibrary(cfg.MovieProcessAll, cfg.MovieLibraryID, movieLibraries, "Movies", func(id, name string) { + fmt.Printf("[MOVIE] Removing keywords from library: %s (ID: %s)\n", name, id) + if err := processor.RemoveKeywordsFromItems(id, media.MediaTypeMovie); err != nil { + fmt.Printf("[ERROR] Error removing keywords from movies: %v\n", err) } - } + }) } - // Process TV libraries if cfg.ProcessTVShows() { - if cfg.TVProcessAll { - for _, lib := range tvLibraries { - fmt.Printf("📺 Processing TV library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.RemoveKeywordsFromItems(lib.Key, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error removing keywords from TV shows: %v\n", err) - } + forEachLibrary(cfg.TVProcessAll, cfg.TVLibraryID, tvLibraries, "TV Shows", func(id, name string) { + fmt.Printf("[TV] Removing keywords from library: %s (ID: %s)\n", name, id) + if err := processor.RemoveKeywordsFromItems(id, media.MediaTypeTV); err != nil { + fmt.Printf("[ERROR] Error removing keywords from TV shows: %v\n", err) } - } else if cfg.TVLibraryID != "" { - if err := processor.RemoveKeywordsFromItems(cfg.TVLibraryID, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error removing keywords from TV shows: %v\n", err) - } - } + }) } - fmt.Println("\n✅ Keyword removal completed. Exiting.") + fmt.Println("\n[OK] Keyword removal completed. Exiting.") } -// handleNormalMode runs the periodic processing func handleNormalMode(cfg *config.Config, processor *media.Processor, movieLibraries, tvLibraries []plex.Library) { displayLibrarySelection(cfg, movieLibraries, tvLibraries) - fmt.Printf("🔄 Starting periodic processing interval: %v\n", cfg.ProcessTimer) - - processFunc := func() { - // Process movie libraries - if len(movieLibraries) > 0 { - if cfg.MovieProcessAll { - for _, lib := range movieLibraries { - fmt.Printf("🎬 Processing library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.ProcessAllItems(lib.Key, lib.Title, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error processing movies: %v\n", err) - } - } - } else if cfg.MovieLibraryID != "" { - // Find the library name for the specified ID - libraryName := "Movies" // Default fallback - for _, lib := range movieLibraries { - if lib.Key == cfg.MovieLibraryID { - libraryName = lib.Title - break - } - } - if err := processor.ProcessAllItems(cfg.MovieLibraryID, libraryName, media.MediaTypeMovie); err != nil { - fmt.Printf("❌ Error processing movies: %v\n", err) - } - } - } - // Process TV libraries - if cfg.ProcessTVShows() { - if cfg.TVProcessAll { - for _, lib := range tvLibraries { - fmt.Printf("📺 Processing TV library: %s (ID: %s)\n", lib.Title, lib.Key) - if err := processor.ProcessAllItems(lib.Key, lib.Title, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error processing TV shows: %v\n", err) - } - } - } else if cfg.TVLibraryID != "" { - // Find the library name for the specified ID - libraryName := "TV Shows" // Default fallback - for _, lib := range tvLibraries { - if lib.Key == cfg.TVLibraryID { - libraryName = lib.Title - break - } - } - if err := processor.ProcessAllItems(cfg.TVLibraryID, libraryName, media.MediaTypeTV); err != nil { - fmt.Printf("❌ Error processing TV shows: %v\n", err) - } - } + scanner := &scanRunner{cfg: cfg, processor: processor, movieLibs: movieLibraries, tvLibs: tvLibraries} + + var webhookServer *webhook.Server + if cfg.WebhookEnabled { + webhookServer = webhook.NewServer(cfg, processor, movieLibraries, tvLibraries, scanner) + if err := webhookServer.Start(); err != nil { + fmt.Printf("[ERROR] %v\n", err) + os.Exit(1) } + fmt.Printf("[OK] Webhook server listening on port %d\n", cfg.WebhookPort) + } - // Write all accumulated export files after processing all libraries - if cfg.HasExportEnabled() { - fmt.Printf("\n📤 Writing export files to %s...\n", cfg.ExportLocation) - if exporter := processor.GetExporter(); exporter != nil { - totalSummary, err := exporter.GetExportSummary() - if err != nil { - fmt.Printf("❌ Error getting export summary: %v\n", err) - } else { - totalAccumulated := 0 - for label, count := range totalSummary { - if count > 0 { - fmt.Printf(" 📁 %s: %d total file paths\n", label, count) - } - totalAccumulated += count - } - - if totalAccumulated > 0 { - fmt.Printf("📝 Writing %d total file paths across all libraries...\n", totalAccumulated) - if err := exporter.FlushAll(); err != nil { - fmt.Printf("❌ Failed to write export files: %v\n", err) - } else { - if cfg.ExportMode == "json" { - fmt.Printf("✅ Successfully wrote export data to export.json\n") - } else { - fmt.Printf("✅ Successfully wrote export files to library subdirectories\n") - fmt.Printf("📊 Generated summary.txt with detailed statistics and file sizes\n") - } - } - } else { - fmt.Printf("📭 No matching items found for export labels\n") - // Still create empty files for each label in each library - if err := exporter.FlushAll(); err != nil { - fmt.Printf("❌ Failed to create export files: %v\n", err) - } else { - if cfg.ExportMode == "json" { - fmt.Printf("✅ Created empty export.json file\n") - } else { - fmt.Printf("✅ Created empty export files in library subdirectories\n") - fmt.Printf("📊 Generated summary.txt with export statistics\n") - } - } - } - } - } + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + fmt.Printf("\n[INFO] Received %s, shutting down...\n", sig) + if webhookServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = webhookServer.Stop(ctx) } + os.Exit(0) + }() + + if cfg.WebhookOnly { + fmt.Println("[INFO] WEBHOOK_ONLY=true: skipping startup full scan and periodic timer; webhook server is the only trigger") + select {} } - // Process immediately on start - processFunc() + fmt.Printf("[INFO] Starting periodic processing interval: %v\n", cfg.ProcessTimer) + + scanner.RunAll() - // Set up timer for periodic processing ticker := time.NewTicker(cfg.ProcessTimer) defer ticker.Stop() for range ticker.C { - fmt.Printf("\n⏰ Timer triggered - processing at %s\n", time.Now().Format("15:04:05")) - processFunc() + fmt.Printf("\n[TIMER] Timer triggered - processing at %s\n", time.Now().Format("15:04:05")) + scanner.RunAll() + } +} + +// scanRunner implements webhook.Scanner. Used by both the periodic timer and +// the /scan HTTP endpoint. +type scanRunner struct { + cfg *config.Config + processor *media.Processor + movieLibs []plex.Library + tvLibs []plex.Library +} + +func (r *scanRunner) RunAll() { + r.processor.ClearCaches() + + if len(r.movieLibs) > 0 { + forEachLibrary(r.cfg.MovieProcessAll, r.cfg.MovieLibraryID, r.movieLibs, "Movies", func(id, name string) { + fmt.Printf("[MOVIE] Processing library: %s (ID: %s)\n", name, id) + if err := r.processor.ProcessAllItems(id, name, media.MediaTypeMovie); err != nil { + fmt.Printf("[ERROR] Error processing movies: %v\n", err) + } + }) + } + + if r.cfg.ProcessTVShows() { + forEachLibrary(r.cfg.TVProcessAll, r.cfg.TVLibraryID, r.tvLibs, "TV Shows", func(id, name string) { + fmt.Printf("[TV] Processing TV library: %s (ID: %s)\n", name, id) + if err := r.processor.ProcessAllItems(id, name, media.MediaTypeTV); err != nil { + fmt.Printf("[ERROR] Error processing TV shows: %v\n", err) + } + }) + } + + if r.cfg.HasExportEnabled() { + writeExportFiles(r.cfg, r.processor) + } +} + +func (r *scanRunner) RunLibrary(libraryID, libraryName string, mediaType media.MediaType) error { + r.processor.ClearCaches() + tag := "[MOVIE]" + if mediaType == media.MediaTypeTV { + tag = "[TV]" + } + fmt.Printf("%s Processing library: %s (ID: %s)\n", tag, libraryName, libraryID) + if err := r.processor.ProcessAllItems(libraryID, libraryName, mediaType); err != nil { + return err + } + if r.cfg.HasExportEnabled() { + writeExportFiles(r.cfg, r.processor) + } + return nil +} + +func writeExportFiles(cfg *config.Config, processor *media.Processor) { + fmt.Printf("\n[EXPORT] Writing export files to %s...\n", cfg.ExportLocation) + exporter := processor.GetExporter() + if exporter == nil { + return + } + + totalSummary, err := exporter.GetExportSummary() + if err != nil { + fmt.Printf("[ERROR] Error getting export summary: %v\n", err) + return + } + + totalAccumulated := 0 + for label, count := range totalSummary { + if count > 0 { + fmt.Printf(" %s: %d total file paths\n", label, count) + } + totalAccumulated += count + } + + if totalAccumulated > 0 { + fmt.Printf("[INFO] Writing %d total file paths across all libraries...\n", totalAccumulated) + } else { + fmt.Printf("[INFO] No matching items found for export labels\n") + } + + if err := exporter.FlushAll(); err != nil { + fmt.Printf("[ERROR] Failed to write export files: %v\n", err) + return + } + + if cfg.ExportMode == "json" { + fmt.Printf("[OK] Successfully wrote export data to export.json\n") + } else { + fmt.Printf("[OK] Successfully wrote export files to library subdirectories\n") } } diff --git a/docker-compose.yml b/docker-compose.yml index 3c8f3c7..0670818 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: labelarr: - image: ghcr.io/nullable-eth/labelarr:latest + image: ttlequals0/labelarr:latest container_name: labelarr restart: unless-stopped volumes: @@ -36,4 +36,20 @@ services: # Sonarr integration (optional) - USE_SONARR=false # Set to true to enable - SONARR_URL=http://localhost:8989 # Your Sonarr URL - - SONARR_API_KEY=your_sonarr_api_key # Your Sonarr API key \ No newline at end of file + - SONARR_API_KEY=your_sonarr_api_key # Your Sonarr API key + + # Batch processing (optional, for large libraries) + # - BATCH_SIZE=100 + # - BATCH_DELAY=10s + # - ITEM_DELAY=500ms + + # Keyword prefix (optional, useful with UPDATE_FIELD=genre) + # - KEYWORD_PREFIX=- + + # Webhook support (optional, for real-time processing) + # - WEBHOOK_ENABLED=true + # - WEBHOOK_PORT=9090 + # - WEBHOOK_DEBOUNCE=30s + # Uncomment if using webhooks: + # ports: + # - "9090:9090" \ No newline at end of file diff --git a/go.mod b/go.mod index a149953..1ecf929 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/nullable-eth/labelarr -go 1.21 +go 1.26 diff --git a/internal/config/config.go b/internal/config/config.go index d57368c..7724e7e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,18 +10,22 @@ import ( // Config holds all application configuration type Config struct { - Protocol string - PlexServer string - PlexPort string - PlexToken string - MovieLibraryID string - MovieProcessAll bool - TVLibraryID string - TVProcessAll bool - UpdateField string - RemoveMode string - TMDbReadAccessToken string - ProcessTimer time.Duration + Protocol string + PlexInsecureSkipVerify bool + PlexServer string + PlexPort string + PlexToken string + MovieLibraryID string + MovieProcessAll bool + MovieLibraryExclude []string + TVLibraryID string + TVProcessAll bool + TVLibraryExclude []string + WebhookOnly bool + UpdateField string + RemoveMode string + TMDbReadAccessToken string + ProcessTimer time.Duration // Radarr configuration RadarrURL string @@ -42,6 +46,19 @@ type Config struct { // Force update configuration ForceUpdate bool + // Webhook configuration + WebhookEnabled bool + WebhookPort int + WebhookDebounce time.Duration + + // Keyword prefix configuration + KeywordPrefix string + + // Batch processing configuration + BatchSize int + BatchDelay time.Duration + ItemDelay time.Duration + // Export configuration ExportLabels []string ExportLocation string @@ -51,17 +68,21 @@ type Config struct { // Load loads configuration from environment variables func Load() *Config { config := &Config{ - PlexServer: os.Getenv("PLEX_SERVER"), - PlexPort: os.Getenv("PLEX_PORT"), - PlexToken: os.Getenv("PLEX_TOKEN"), - MovieLibraryID: os.Getenv("MOVIE_LIBRARY_ID"), - MovieProcessAll: getBoolEnvWithDefault("MOVIE_PROCESS_ALL", false), - TVLibraryID: os.Getenv("TV_LIBRARY_ID"), - TVProcessAll: getBoolEnvWithDefault("TV_PROCESS_ALL", false), - UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"), - RemoveMode: os.Getenv("REMOVE"), - TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), - ProcessTimer: getProcessTimerFromEnv(), + PlexInsecureSkipVerify: getBoolEnvWithDefault("PLEX_INSECURE_SKIP_VERIFY", false), + PlexServer: os.Getenv("PLEX_SERVER"), + PlexPort: os.Getenv("PLEX_PORT"), + PlexToken: os.Getenv("PLEX_TOKEN"), + MovieLibraryID: os.Getenv("MOVIE_LIBRARY_ID"), + MovieProcessAll: getBoolEnvWithDefault("MOVIE_PROCESS_ALL", false), + MovieLibraryExclude: parseCSV(os.Getenv("MOVIE_LIBRARY_EXCLUDE")), + TVLibraryID: os.Getenv("TV_LIBRARY_ID"), + TVProcessAll: getBoolEnvWithDefault("TV_PROCESS_ALL", false), + TVLibraryExclude: parseCSV(os.Getenv("TV_LIBRARY_EXCLUDE")), + WebhookOnly: getBoolEnvWithDefault("WEBHOOK_ONLY", false), + UpdateField: getEnvWithDefault("UPDATE_FIELD", "label"), + RemoveMode: os.Getenv("REMOVE"), + TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), + ProcessTimer: getDurationEnvWithDefault("PROCESS_TIMER", "1h"), // Radarr configuration RadarrURL: os.Getenv("RADARR_URL"), @@ -82,8 +103,21 @@ func Load() *Config { // Force update configuration ForceUpdate: getBoolEnvWithDefault("FORCE_UPDATE", false), + // Webhook configuration + WebhookEnabled: getBoolEnvWithDefault("WEBHOOK_ENABLED", false), + WebhookPort: getIntEnvWithDefault("WEBHOOK_PORT", 9090), + WebhookDebounce: getDurationEnvWithDefault("WEBHOOK_DEBOUNCE", "30s"), + + // Keyword prefix configuration + KeywordPrefix: os.Getenv("KEYWORD_PREFIX"), + + // Batch processing configuration + BatchSize: getIntEnvWithDefault("BATCH_SIZE", 100), + BatchDelay: getDurationEnvWithDefault("BATCH_DELAY", "10s"), + ItemDelay: getDurationEnvWithDefault("ITEM_DELAY", "500ms"), + // Export configuration - ExportLabels: parseExportLabels(os.Getenv("EXPORT_LABELS")), + ExportLabels: parseCSV(os.Getenv("EXPORT_LABELS")), ExportLocation: os.Getenv("EXPORT_LOCATION"), ExportMode: getEnvWithDefault("EXPORT_MODE", "txt"), } @@ -136,6 +170,16 @@ func (c *Config) Validate() error { if c.ExportMode != "txt" && c.ExportMode != "json" { return fmt.Errorf("EXPORT_MODE must be 'txt' or 'json'") } + if c.WebhookOnly && !c.WebhookEnabled { + return fmt.Errorf("WEBHOOK_ONLY=true requires WEBHOOK_ENABLED=true") + } + + if c.WebhookEnabled && (c.WebhookPort < 1 || c.WebhookPort > 65535) { + return fmt.Errorf("WEBHOOK_PORT must be between 1 and 65535") + } + if c.BatchSize < 1 { + return fmt.Errorf("BATCH_SIZE must be at least 1") + } // Validate Radarr configuration if enabled if c.UseRadarr { @@ -167,41 +211,53 @@ func getEnvWithDefault(envVar, defaultValue string) string { return defaultValue } -func getProcessTimerFromEnv() time.Duration { - timerStr := getEnvWithDefault("PROCESS_TIMER", "1h") - timer, err := time.ParseDuration(timerStr) +func getBoolEnvWithDefault(envVar string, defaultValue bool) bool { + value := os.Getenv(envVar) + if value == "" { + return defaultValue + } + result, err := strconv.ParseBool(value) if err != nil { - return 5 * time.Minute + return defaultValue } - return timer + return result } -func getBoolEnvWithDefault(envVar string, defaultValue bool) bool { +func getIntEnvWithDefault(envVar string, defaultValue int) int { value := os.Getenv(envVar) if value == "" { return defaultValue } - result, err := strconv.ParseBool(value) + result, err := strconv.Atoi(value) if err != nil { return defaultValue } return result } -func parseExportLabels(labels string) []string { - if labels == "" { +func getDurationEnvWithDefault(envVar string, defaultValue string) time.Duration { + value := getEnvWithDefault(envVar, defaultValue) + duration, err := time.ParseDuration(value) + if err != nil { + fallback, _ := time.ParseDuration(defaultValue) + return fallback + } + return duration +} + +func parseCSV(s string) []string { + if s == "" { return nil } - // Split and trim whitespace from each label - rawLabels := strings.Split(labels, ",") - var cleanLabels []string - for _, label := range rawLabels { - trimmed := strings.TrimSpace(label) + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + trimmed := strings.TrimSpace(p) if trimmed != "" { - cleanLabels = append(cleanLabels, trimmed) + out = append(out, trimmed) } } - return cleanLabels + return out } // HasExportEnabled returns true if export functionality is enabled diff --git a/internal/export/export.go b/internal/export/export.go index abc75c6..3a326fc 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -100,7 +100,10 @@ func (e *Exporter) SetCurrentLibrary(libraryName string) error { // Only create library-specific subdirectory in txt mode if e.exportMode == "txt" { - libraryPath := filepath.Join(e.exportLocation, sanitizedName) + libraryPath, err := e.safeJoin(sanitizedName) + if err != nil { + return fmt.Errorf("invalid library path: %w", err) + } if err := os.MkdirAll(libraryPath, 0755); err != nil { return fmt.Errorf("failed to create library directory %s: %w", libraryPath, err) } @@ -196,7 +199,10 @@ func (e *Exporter) FlushAll() error { func (e *Exporter) flushTxt() error { // Process each library for libraryName, libraryData := range e.accumulated { - libraryPath := filepath.Join(e.exportLocation, libraryName) + libraryPath, err := e.safeJoin(libraryName) + if err != nil { + return fmt.Errorf("invalid library path: %w", err) + } // Ensure library directory exists if err := os.MkdirAll(libraryPath, 0755); err != nil { @@ -253,7 +259,10 @@ func (e *Exporter) flushJSON() error { jsonData := e.buildJSONExportData() // Write JSON file - jsonPath := filepath.Join(e.exportLocation, "export.json") + jsonPath, err := e.safeJoin("export.json") + if err != nil { + return fmt.Errorf("invalid JSON export path: %w", err) + } file, err := os.Create(jsonPath) if err != nil { return fmt.Errorf("failed to create JSON export file: %w", err) @@ -274,7 +283,10 @@ func (e *Exporter) flushJSON() error { // writeSummary writes a summary.txt file with detailed statistics func (e *Exporter) writeSummary() error { - summaryPath := filepath.Join(e.exportLocation, "summary.txt") + summaryPath, err := e.safeJoin("summary.txt") + if err != nil { + return fmt.Errorf("invalid summary path: %w", err) + } file, err := os.Create(summaryPath) if err != nil { @@ -319,7 +331,7 @@ func (e *Exporter) writeSummary() error { } // Write export file list - fmt.Fprintf(file, "📁 Export Files Generated:\n") + fmt.Fprintf(file, "[STORAGE] Export Files Generated:\n") for libraryName := range libraryStats { for _, label := range e.exportLabels { if stats, exists := libraryStats[libraryName][label]; exists && stats.Count > 0 { @@ -330,13 +342,13 @@ func (e *Exporter) writeSummary() error { fmt.Fprintf(file, "\n") // Write totals - fmt.Fprintf(file, "📊 Overall Statistics:\n") + fmt.Fprintf(file, "[STATS] Overall Statistics:\n") fmt.Fprintf(file, " Total files: %d\n", totalFiles) fmt.Fprintf(file, " Total size: %s (%d bytes)\n", formatFileSize(totalSize), totalSize) fmt.Fprintf(file, "\n") // Write per-library breakdown - fmt.Fprintf(file, "📚 Library Breakdown:\n") + fmt.Fprintf(file, "[INFO] Library Breakdown:\n") for libraryName, labelStats := range libraryStats { fmt.Fprintf(file, "\n %s:\n", libraryName) @@ -363,7 +375,7 @@ func (e *Exporter) writeSummary() error { } // Write per-label totals across all libraries - fmt.Fprintf(file, "\n🏷️ Label Totals (All Libraries):\n") + fmt.Fprintf(file, "\n[LABEL] Label Totals (All Libraries):\n") labelTotals := make(map[string]struct { Count int Size int64 @@ -414,14 +426,20 @@ func (e *Exporter) ClearExportFiles() error { // Remove all library subdirectories and their contents for libraryName := range e.accumulated { - libraryPath := filepath.Join(e.exportLocation, libraryName) + libraryPath, err := e.safeJoin(libraryName) + if err != nil { + return fmt.Errorf("invalid library path: %w", err) + } if err := os.RemoveAll(libraryPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove library directory %s: %w", libraryPath, err) } } // Remove summary file - summaryPath := filepath.Join(e.exportLocation, "summary.txt") + summaryPath, err := e.safeJoin("summary.txt") + if err != nil { + return fmt.Errorf("invalid summary path: %w", err) + } if err := os.Remove(summaryPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove summary file: %w", err) } @@ -490,15 +508,38 @@ func (e *Exporter) GetCurrentLibrary() string { return e.currentLibrary } -// sanitizeFilename removes invalid characters from filenames +// sanitizeFilename removes invalid characters from filenames and rejects +// dots-only names that could traverse outside the export directory. func sanitizeFilename(filename string) string { - // Replace common invalid characters with underscores invalid := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} result := filename for _, char := range invalid { result = strings.ReplaceAll(result, char, "_") } - return strings.TrimSpace(result) + result = strings.TrimSpace(result) + if result == "" || result == "." || result == ".." { + return "_" + } + return result +} + +// safeJoin joins path elements onto the export location and guarantees the +// resolved absolute path stays within the export root. Returns an error if +// the result would escape (e.g. via "..", absolute paths, or similar). +func (e *Exporter) safeJoin(elems ...string) (string, error) { + joined := filepath.Join(append([]string{e.exportLocation}, elems...)...) + absBase, err := filepath.Abs(e.exportLocation) + if err != nil { + return "", fmt.Errorf("resolve export location: %w", err) + } + absJoined, err := filepath.Abs(joined) + if err != nil { + return "", fmt.Errorf("resolve joined path: %w", err) + } + if absJoined != absBase && !strings.HasPrefix(absJoined, absBase+string(filepath.Separator)) { + return "", fmt.Errorf("path %q escapes export location %q", joined, e.exportLocation) + } + return joined, nil } // buildJSONExportData builds a JSONExportData struct from the accumulated data diff --git a/internal/media/processor.go b/internal/media/processor.go index 5d35aa6..3e69ec7 100644 --- a/internal/media/processor.go +++ b/internal/media/processor.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strings" + "sync" "time" "github.com/nullable-eth/labelarr/internal/config" @@ -20,11 +21,18 @@ import ( type MediaType string const ( - MediaTypeMovie MediaType = "movie" - MediaTypeTV MediaType = "tv" + MediaTypeMovie MediaType = "movie" + MediaTypeTV MediaType = "tv" + MediaTypeUnknown MediaType = "" ) -// ProcessedItem is now imported from storage package +// Clients groups external API clients for the processor. +type Clients struct { + Plex *plex.Client + TMDb *tmdb.Client + Radarr *radarr.Client + Sonarr *sonarr.Client +} // MediaItem interface for common media operations type MediaItem interface { @@ -46,10 +54,18 @@ type Processor struct { sonarrClient *sonarr.Client storage *storage.Storage exporter *export.Exporter + keywordCache map[string][]string + cacheMu sync.RWMutex + processingMu sync.Mutex + processing map[string]bool } // NewProcessor creates a new generic media processor -func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb.Client, radarrClient *radarr.Client, sonarrClient *sonarr.Client) (*Processor, error) { +func NewProcessor(cfg *config.Config, clients Clients) (*Processor, error) { + plexClient := clients.Plex + tmdbClient := clients.TMDb + radarrClient := clients.Radarr + sonarrClient := clients.Sonarr // Initialize persistent storage only if DATA_DIR is set var stor *storage.Storage if cfg.DataDir != "" { @@ -67,6 +83,8 @@ func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb. radarrClient: radarrClient, sonarrClient: sonarrClient, storage: stor, + keywordCache: make(map[string][]string), + processing: make(map[string]bool), } // Initialize exporter if export is enabled @@ -77,17 +95,17 @@ func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb. } processor.exporter = exporter - fmt.Printf("📤 Export enabled: Writing file paths for labels %v to %s\n", cfg.ExportLabels, cfg.ExportLocation) + fmt.Printf("[EXPORT] Export enabled: Writing file paths for labels %v to %s\n", cfg.ExportLabels, cfg.ExportLocation) } // Log storage initialization if stor != nil { count := stor.Count() if count > 0 { - fmt.Printf("📁 Loaded %d previously processed items from storage\n", count) + fmt.Printf("[STORAGE] Loaded %d previously processed items from storage\n", count) } } else { - fmt.Printf("🔄 Running in ephemeral mode - no persistent storage (set DATA_DIR to enable)\n") + fmt.Printf("[SYNC] Running in ephemeral mode - no persistent storage (set DATA_DIR to enable)\n") } return processor, nil @@ -98,26 +116,246 @@ func (p *Processor) GetExporter() *export.Exporter { return p.exporter } +type batch struct { + items []MediaItem + num int // 0-indexed batch number + total int // total number of batches + startIdx int // 1-indexed start position in overall list + endIdx int // end position in overall list +} + +// makeBatches splits items into batches based on config.BatchSize. +func (p *Processor) makeBatches(items []MediaItem) []batch { + batchSize := p.config.BatchSize + total := (len(items) + batchSize - 1) / batchSize + batches := make([]batch, 0, total) + for i := 0; i < total; i++ { + start := i * batchSize + end := start + batchSize + if end > len(items) { + end = len(items) + } + batches = append(batches, batch{ + items: items[start:end], + num: i, + total: total, + startIdx: start + 1, + endIdx: end, + }) + } + return batches +} + +// logBatchStart prints a batch start message if there are multiple batches. +func (b batch) logStart(label string, totalItems int) { + if b.total > 1 { + fmt.Printf("%s batch %d/%d (items %d-%d of %d)\n", + label, b.num+1, b.total, b.startIdx, b.endIdx, totalItems) + } +} + +// pauseAfter sleeps between batches (not after the last one). +func (p *Processor) pauseAfterBatch(b batch, label string) { + if b.total > 1 && b.num < b.total-1 { + fmt.Printf("%s batch %d complete. Pausing %v before next batch...\n", + label, b.num+1, p.config.BatchDelay) + time.Sleep(p.config.BatchDelay) + } +} + +// ClearCaches resets the TMDb keyword cache and Radarr/Sonarr library caches. +// Call at the start of each processing cycle so data is refreshed periodically. +func (p *Processor) ClearCaches() { + p.cacheMu.Lock() + p.keywordCache = make(map[string][]string) + p.cacheMu.Unlock() + + if p.radarrClient != nil { + p.radarrClient.ClearCache() + } + if p.sonarrClient != nil { + p.sonarrClient.ClearCache() + } +} + +func (p *Processor) applyKeywordPrefix(keywords []string) []string { + if p.config.KeywordPrefix == "" { + return keywords + } + prefixed := make([]string, len(keywords)) + for i, kw := range keywords { + prefixed[i] = p.config.KeywordPrefix + kw + } + return prefixed +} + // ProcessAllItems processes all items in the specified library +// ProcessSingleItem processes a single item by rating key. Used by webhooks to +// tag only the newly added item instead of scanning the entire library. +// ProcessSingleItem processes a single item by rating key. Used by webhooks to +// tag only the newly added item instead of scanning the entire library. +func (p *Processor) ProcessSingleItem(ratingKey, libraryID string, mediaType MediaType) error { + const ( + pollInterval = 5 * time.Second + maxWait = 2 * time.Hour + ) + deadline := time.Now().Add(maxWait) + logged := false + for { + p.processingMu.Lock() + if !p.processing[libraryID] { + p.processing[libraryID] = true + p.processingMu.Unlock() + break + } + p.processingMu.Unlock() + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s waiting for library %s to free up for item %s", maxWait, libraryID, ratingKey) + } + if !logged { + fmt.Printf("[INFO] Library %s busy (scan in progress); webhook item %s will wait until it frees\n", libraryID, ratingKey) + logged = true + } + time.Sleep(pollInterval) + } + defer func() { + p.processingMu.Lock() + delete(p.processing, libraryID) + p.processingMu.Unlock() + }() + + var item MediaItem + + switch mediaType { + case MediaTypeMovie: + movie, err := p.plexClient.GetMovieDetails(ratingKey) + if err != nil { + return fmt.Errorf("failed to fetch movie %s: %w", ratingKey, err) + } + item = movie + case MediaTypeTV: + show, err := p.plexClient.GetTVShowDetails(ratingKey) + if err != nil { + return fmt.Errorf("failed to fetch TV show %s: %w", ratingKey, err) + } + item = show + default: + return fmt.Errorf("unsupported media type: %s", mediaType) + } + + fmt.Printf("[WEBHOOK] Processing single item: %s (%d)\n", item.GetTitle(), item.GetYear()) + + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { + fmt.Printf("[SKIP] No TMDb ID found for: %s\n", item.GetTitle()) + return nil + } + + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + return fmt.Errorf("failed to fetch keywords for TMDb ID %s: %w", tmdbID, err) + } + + keywords = p.applyKeywordPrefix(keywords) + + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + return fmt.Errorf("failed to fetch item details: %w", err) + } + + currentValues := p.extractCurrentValues(details) + + currentValuesMap := make(map[string]bool) + for _, val := range currentValues { + currentValuesMap[strings.ToLower(val)] = true + } + + allExist := true + for _, kw := range keywords { + if !currentValuesMap[strings.ToLower(kw)] { + allExist = false + break + } + } + + if allExist && !p.config.ForceUpdate { + fmt.Printf("[OK] %s already has all %d keywords\n", item.GetTitle(), len(keywords)) + return nil + } + + source := p.getTMDbIDSource(item, mediaType, tmdbID) + fmt.Printf("[KEY] TMDb ID: %s (source: %s)\n", tmdbID, source) + fmt.Printf("[SYNC] Applying %d keywords to %s field for %s\n", len(keywords), p.config.UpdateField, item.GetTitle()) + + if err := p.syncFieldWithKeywords(item.GetRatingKey(), libraryID, currentValues, keywords, mediaType); err != nil { + return fmt.Errorf("failed to sync keywords for %s: %w", item.GetTitle(), err) + } + + fmt.Printf("[OK] Successfully applied %d keywords to %s\n", len(keywords), item.GetTitle()) + + // Export if enabled + if p.exporter != nil { + mergedLabels := append(currentValues, keywords...) + fileInfos, err := p.extractFileInfos(details, mediaType) + if err == nil && len(fileInfos) > 0 { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), mergedLabels, fileInfos); err != nil { + fmt.Printf("[WARN] Export failed for %s: %v\n", item.GetTitle(), err) + } + } + } + + if p.storage != nil { + processedItem := &storage.ProcessedItem{ + RatingKey: item.GetRatingKey(), + Title: item.GetTitle(), + TMDbID: tmdbID, + LastProcessed: time.Now(), + KeywordsSynced: true, + UpdateField: p.config.UpdateField, + } + if err := p.storage.Set(processedItem); err != nil { + fmt.Printf("[WARN] Failed to save processed item to storage: %v\n", err) + } + } + + return nil +} + func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaType MediaType) error { + // Unlike ProcessSingleItem, this path skips when the library is busy: + // the timer will re-fire on the next cycle, so waiting here would only + // stack redundant full scans behind each other. + p.processingMu.Lock() + if p.processing[libraryID] { + p.processingMu.Unlock() + fmt.Printf("[INFO] Library %s is already being processed, skipping\n", libraryName) + return nil + } + p.processing[libraryID] = true + p.processingMu.Unlock() + defer func() { + p.processingMu.Lock() + delete(p.processing, libraryID) + p.processingMu.Unlock() + }() + var displayName, emoji string switch mediaType { case MediaTypeMovie: displayName = "movies" - emoji = "🎬" + emoji = "[MOVIE]" case MediaTypeTV: displayName = "tv shows" - emoji = "📺" + emoji = "[TV]" default: return fmt.Errorf("unsupported media type: %s", mediaType) } - fmt.Printf("📋 Fetching all %s from library...\n", displayName) + fmt.Printf("[INFO] Fetching all %s from library...\n", displayName) - // Set current library in exporter if export is enabled if p.exporter != nil { if err := p.exporter.SetCurrentLibrary(libraryName); err != nil { - fmt.Printf("⚠️ Warning: Failed to set current library for export: %v\n", err) + fmt.Printf("[WARN] Warning: Failed to set current library for export: %v\n", err) } } @@ -127,21 +365,21 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT } if len(items) == 0 { - fmt.Printf("❌ No %s found in library!\n", displayName) + fmt.Printf("[ERROR] No %s found in library!\n", displayName) return nil } totalCount := len(items) - fmt.Printf("✅ Found %d %s in library\n", totalCount, displayName) + fmt.Printf("[OK] Found %d %s in library\n", totalCount, displayName) if p.config.ForceUpdate { - fmt.Printf("🔄 FORCE UPDATE MODE: All items will be reprocessed regardless of previous processing\n") + fmt.Printf("[SYNC] FORCE UPDATE MODE: All items will be reprocessed regardless of previous processing\n") } if p.config.VerboseLogging { - fmt.Printf("🔎 Starting detailed processing with verbose logging enabled...\n") + fmt.Printf("[DEBUG] Starting detailed processing with verbose logging enabled...\n") } else { - fmt.Printf("⏳ Processing %s... (enable VERBOSE_LOGGING=true for detailed lookup information)\n", displayName) + fmt.Printf("[WAIT] Processing %s... (enable VERBOSE_LOGGING=true for detailed lookup information)\n", displayName) } newItems := 0 @@ -153,36 +391,58 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT processedCount := 0 lastProgressReport := 0 - for _, item := range items { - processedCount++ + for _, b := range p.makeBatches(items) { + b.logStart(emoji+" Processing", len(items)) - // Show progress for large libraries - if totalCount > 100 { - progress := (processedCount * 100) / totalCount - if progress >= lastProgressReport+10 { - fmt.Printf("📊 Progress: %d%% (%d/%d %s processed)\n", progress, processedCount, totalCount, displayName) - lastProgressReport = progress + for _, item := range b.items { + processedCount++ + + if totalCount > 100 { + progress := (processedCount * 100) / totalCount + if progress >= lastProgressReport+10 { + fmt.Printf("[STATS] Progress: %d%% (%d/%d %s processed)\n", progress, processedCount, totalCount, displayName) + lastProgressReport = progress + } } - } - // Check if already processed (only if storage is enabled) - var exists bool - if p.storage != nil { - processed, storageExists := p.storage.Get(item.GetRatingKey()) - if storageExists && processed.KeywordsSynced && processed.UpdateField == p.config.UpdateField && !p.config.ForceUpdate { - // Still try to export if export is enabled, even if already processed + var exists bool + if p.storage != nil { + processed, storageExists := p.storage.Get(item.GetRatingKey()) + if storageExists && processed.KeywordsSynced && processed.UpdateField == p.config.UpdateField && !p.config.ForceUpdate { + if p.exporter != nil { + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err == nil { + currentLabels := p.extractCurrentValues(details) + + fileInfos, err := p.extractFileInfos(details, mediaType) + if err == nil && len(fileInfos) > 0 { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err == nil { + if p.config.VerboseLogging { + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s (already processed)\n", len(fileInfos), item.GetTitle()) + } + } + } + } + } + + skippedItems++ + skippedAlreadyExist++ + continue + } + exists = storageExists + } + + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { if p.exporter != nil { details, err := p.getItemDetails(item.GetRatingKey(), mediaType) if err == nil { - // Extract current labels for export currentLabels := p.extractCurrentValues(details) - // Extract file paths and sizes fileInfos, err := p.extractFileInfos(details, mediaType) if err == nil && len(fileInfos) > 0 { - // Accumulate the item for export if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err == nil { if p.config.VerboseLogging { - fmt.Printf(" 📤 Accumulated %d file paths for %s (already processed)\n", len(fileInfos), item.GetTitle()) + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s (no TMDb ID)\n", len(fileInfos), item.GetTitle()) } } } @@ -190,252 +450,203 @@ func (p *Processor) ProcessAllItems(libraryID string, libraryName string, mediaT } skippedItems++ - skippedAlreadyExist++ + if p.config.VerboseLogging && skippedItems <= 10 { + fmt.Printf(" [SKIP] Skipped %s: %s (%d) - No TMDb ID found\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) + } continue } - exists = storageExists - } - - // Silently check if we need to process this item - tmdbID := p.extractTMDbID(item, mediaType) - if tmdbID == "" { - // Still try to export if export is enabled, even without TMDb ID - if p.exporter != nil { - details, err := p.getItemDetails(item.GetRatingKey(), mediaType) - if err == nil { - // Extract current labels for export - currentLabels := p.extractCurrentValues(details) - // Extract file paths and sizes - fileInfos, err := p.extractFileInfos(details, mediaType) - if err == nil && len(fileInfos) > 0 { - // Accumulate the item for export - if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err == nil { - if p.config.VerboseLogging { - fmt.Printf(" 📤 Accumulated %d file paths for %s (no TMDb ID)\n", len(fileInfos), item.GetTitle()) - } - } - } + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + if p.config.VerboseLogging { + fmt.Printf(" [ERROR] Error fetching keywords for TMDb ID %s: %v\n", tmdbID, err) } + skippedItems++ + continue } - skippedItems++ - if p.config.VerboseLogging && skippedItems <= 10 { - fmt.Printf(" ⏭️ Skipped %s: %s (%d) - No TMDb ID found\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - } - continue - } - - // Silently fetch keywords and details to check if processing is needed - keywords, err := p.getKeywords(tmdbID, mediaType) - if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ❌ Error fetching keywords for TMDb ID %s: %v\n", tmdbID, err) + fmt.Printf(" [FETCH] Fetched %d keywords from TMDb: %v\n", len(keywords), keywords) } - skippedItems++ - continue - } - if p.config.VerboseLogging { - fmt.Printf(" 📥 Fetched %d keywords from TMDb: %v\n", len(keywords), keywords) - } + keywords = p.applyKeywordPrefix(keywords) - details, err := p.getItemDetails(item.GetRatingKey(), mediaType) - if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ❌ Error fetching item details: %v\n", err) + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + if p.config.VerboseLogging { + fmt.Printf(" [ERROR] Error fetching item details: %v\n", err) + } + skippedItems++ + continue } - skippedItems++ - continue - } - currentValues := p.extractCurrentValues(details) - if p.config.VerboseLogging { - fmt.Printf(" 📋 Current %ss in Plex: %v\n", p.config.UpdateField, currentValues) - } - - currentValuesMap := make(map[string]bool) - for _, val := range currentValues { - currentValuesMap[strings.ToLower(val)] = true - } + currentValues := p.extractCurrentValues(details) + if p.config.VerboseLogging { + fmt.Printf(" [INFO] Current %ss in Plex: %v\n", p.config.UpdateField, currentValues) + } - allKeywordsExist := true - var missingKeywords []string - for _, keyword := range keywords { - if !currentValuesMap[strings.ToLower(keyword)] { - allKeywordsExist = false - missingKeywords = append(missingKeywords, keyword) + currentValuesMap := make(map[string]bool) + for _, val := range currentValues { + currentValuesMap[strings.ToLower(val)] = true } - } - if allKeywordsExist && !p.config.ForceUpdate { - // Silently skip - no verbose output - if p.config.VerboseLogging { - fmt.Printf(" ✨ Already has all keywords, skipping\n") + allKeywordsExist := true + var missingKeywords []string + for _, keyword := range keywords { + if !currentValuesMap[strings.ToLower(keyword)] { + allKeywordsExist = false + missingKeywords = append(missingKeywords, keyword) + } } - // Still export if export is enabled, even if no keyword updates are needed - if p.exporter != nil { - // Extract current labels for export - currentLabels := p.extractCurrentValues(details) + if allKeywordsExist && !p.config.ForceUpdate { + // Silently skip - no verbose output + if p.config.VerboseLogging { + fmt.Printf(" [OK] Already has all keywords, skipping\n") + } - // Extract file paths and sizes - fileInfos, err := p.extractFileInfos(details, mediaType) - if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Could not extract file paths for export: %v\n", err) - } - } else if len(fileInfos) > 0 { - // Accumulate the item for export - if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err != nil { + // Still export if export is enabled, even if no keyword updates are needed + if p.exporter != nil { + currentLabels := p.extractCurrentValues(details) + + fileInfos, err := p.extractFileInfos(details, mediaType) + if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Export accumulation failed for %s: %v\n", item.GetTitle(), err) + fmt.Printf(" [WARN] Warning: Could not extract file paths for export: %v\n", err) + } + } else if len(fileInfos) > 0 { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err != nil { + if p.config.VerboseLogging { + fmt.Printf(" [WARN] Warning: Export accumulation failed for %s: %v\n", item.GetTitle(), err) + } + } else if p.config.VerboseLogging { + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s (already had keywords)\n", len(fileInfos), item.GetTitle()) } - } else if p.config.VerboseLogging { - fmt.Printf(" 📤 Accumulated %d file paths for %s (already had keywords)\n", len(fileInfos), item.GetTitle()) } } + + skippedItems++ + skippedAlreadyExist++ + continue } - skippedItems++ - skippedAlreadyExist++ - continue - } + if p.config.ForceUpdate && allKeywordsExist { + if p.config.VerboseLogging { + fmt.Printf(" [SYNC] Force update enabled - reprocessing item with existing keywords\n") + } + } - if p.config.ForceUpdate && allKeywordsExist { if p.config.VerboseLogging { - fmt.Printf(" 🔄 Force update enabled - reprocessing item with existing keywords\n") + fmt.Printf(" [NEW] Missing keywords to add: %v\n", missingKeywords) } - } - if p.config.VerboseLogging { - fmt.Printf(" 🆕 Missing keywords to add: %v\n", missingKeywords) - } + if !exists { + fmt.Printf("\n%s Processing new %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - // Only show verbose output for completely new items (never processed before) - if !exists { - fmt.Printf("\n%s Processing new %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - - // Show source of TMDb ID - source := p.getTMDbIDSource(item, mediaType, tmdbID) - fmt.Printf("🔑 TMDb ID: %s (source: %s)\n", tmdbID, source) - fmt.Printf("🏷️ Found %d TMDb keywords\n", len(keywords)) - } - - // Show when we're about to apply labels/genres - if p.config.VerboseLogging || !exists { - fmt.Printf("🔄 Applying %d keywords to %s field...\n", len(keywords), p.config.UpdateField) - if p.config.VerboseLogging { - fmt.Printf(" Current %ss: %v\n", p.config.UpdateField, currentValues) - fmt.Printf(" New keywords to add: %v\n", keywords) + // Show source of TMDb ID + source := p.getTMDbIDSource(item, mediaType, tmdbID) + fmt.Printf("[KEY] TMDb ID: %s (source: %s)\n", tmdbID, source) + fmt.Printf("[LABEL] Found %d TMDb keywords\n", len(keywords)) } - } - err = p.syncFieldWithKeywords(item.GetRatingKey(), libraryID, currentValues, keywords, mediaType) - if err != nil { - // Show error even for existing items since it's important - if exists { - fmt.Printf("❌ Error syncing %s for %s: %v\n", p.config.UpdateField, item.GetTitle(), err) + if p.config.VerboseLogging || !exists { + fmt.Printf("[SYNC] Applying %d keywords to %s field...\n", len(keywords), p.config.UpdateField) + if p.config.VerboseLogging { + fmt.Printf(" Current %ss: %v\n", p.config.UpdateField, currentValues) + fmt.Printf(" New keywords to add: %v\n", keywords) + } } - skippedItems++ - continue - } - - // Show success message when labels/genres are applied - if p.config.VerboseLogging || !exists { - fmt.Printf("✅ Successfully applied %d keywords to Plex %s field\n", len(keywords), p.config.UpdateField) - } - // Export file paths if export is enabled - if p.exporter != nil { - // Get updated item details to get current labels - updatedDetails, err := p.getItemDetails(item.GetRatingKey(), mediaType) + err = p.syncFieldWithKeywords(item.GetRatingKey(), libraryID, currentValues, keywords, mediaType) if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Could not get updated details for export: %v\n", err) + // Show error even for existing items since it's important + if exists { + fmt.Printf("[ERROR] Error syncing %s for %s: %v\n", p.config.UpdateField, item.GetTitle(), err) } - } else { - // Extract current labels for export - currentLabels := p.extractCurrentValues(updatedDetails) + skippedItems++ + continue + } + + if p.config.VerboseLogging || !exists { + fmt.Printf("[OK] Successfully applied %d keywords to Plex %s field\n", len(keywords), p.config.UpdateField) + } - // Extract file paths and sizes - fileInfos, err := p.extractFileInfos(updatedDetails, mediaType) + if p.exporter != nil { + mergedLabels := append(currentValues, keywords...) + fileInfos, err := p.extractFileInfos(details, mediaType) if err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Could not extract file paths for export: %v\n", err) + fmt.Printf(" [WARN] Could not extract file paths for export: %v\n", err) } } else if len(fileInfos) > 0 { - // Accumulate the item for export - if err := p.exporter.ExportItemWithSizes(item.GetTitle(), currentLabels, fileInfos); err != nil { + if err := p.exporter.ExportItemWithSizes(item.GetTitle(), mergedLabels, fileInfos); err != nil { if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Warning: Export accumulation failed for %s: %v\n", item.GetTitle(), err) + fmt.Printf(" [WARN] Export accumulation failed for %s: %v\n", item.GetTitle(), err) } } else if p.config.VerboseLogging { - fmt.Printf(" 📤 Accumulated %d file paths for %s\n", len(fileInfos), item.GetTitle()) + fmt.Printf(" [EXPORT] Accumulated %d file paths for %s\n", len(fileInfos), item.GetTitle()) } } } - } - // Save processed item (only if storage is enabled) - if p.storage != nil { - processedItem := &storage.ProcessedItem{ - RatingKey: item.GetRatingKey(), - Title: item.GetTitle(), - TMDbID: tmdbID, - LastProcessed: time.Now(), - KeywordsSynced: true, - UpdateField: p.config.UpdateField, + if p.storage != nil { + processedItem := &storage.ProcessedItem{ + RatingKey: item.GetRatingKey(), + Title: item.GetTitle(), + TMDbID: tmdbID, + LastProcessed: time.Now(), + KeywordsSynced: true, + UpdateField: p.config.UpdateField, + } + + if err := p.storage.Set(processedItem); err != nil { + fmt.Printf("[WARN] Warning: Failed to save processed item to storage: %v\n", err) + } } - if err := p.storage.Set(processedItem); err != nil { - fmt.Printf("⚠️ Warning: Failed to save processed item to storage: %v\n", err) + if exists { + updatedItems++ + } else { + newItems++ + fmt.Printf("[OK] Successfully processed new %s: %s\n", strings.TrimSuffix(displayName, "s"), item.GetTitle()) } - } - if exists { - updatedItems++ - } else { - newItems++ - fmt.Printf("✅ Successfully processed new %s: %s\n", strings.TrimSuffix(displayName, "s"), item.GetTitle()) + time.Sleep(p.config.ItemDelay) } - time.Sleep(500 * time.Millisecond) + p.pauseAfterBatch(b, emoji+" Processing") } - // Show verbose summary if items were skipped if p.config.VerboseLogging && skippedItems > 10 { fmt.Printf(" ... and %d more items skipped\n", skippedItems-10) } - fmt.Printf("\n📊 Processing Summary:\n") - fmt.Printf(" 📈 Total %s in library: %d\n", displayName, totalCount) - fmt.Printf(" 🆕 New %s processed: %d\n", displayName, newItems) - fmt.Printf(" 🔄 Updated %s: %d\n", displayName, updatedItems) - fmt.Printf(" ⏭️ Skipped %s: %d\n", displayName, skippedItems) + fmt.Printf("\n[STATS] Processing Summary:\n") + fmt.Printf(" [TOTAL] Total %s in library: %d\n", displayName, totalCount) + fmt.Printf(" [NEW] New %s processed: %d\n", displayName, newItems) + fmt.Printf(" [SYNC] Updated %s: %d\n", displayName, updatedItems) + fmt.Printf(" [SKIP] Skipped %s: %d\n", displayName, skippedItems) if skippedAlreadyExist > 0 { - fmt.Printf(" ✨ Already have all keywords: %d\n", skippedAlreadyExist) + fmt.Printf(" [OK] Already have all keywords: %d\n", skippedAlreadyExist) } - // Show export summary if export is enabled if p.exporter != nil { librarySummary, err := p.exporter.GetLibraryExportSummary() if err != nil { - fmt.Printf(" ⚠️ Export summary error: %v\n", err) + fmt.Printf(" [WARN] Export summary error: %v\n", err) } else { - fmt.Printf("\n📤 Export Summary for %s:\n", libraryName) + fmt.Printf("\n[EXPORT] Export Summary for %s:\n", libraryName) totalAccumulated := 0 - // Show current library summary currentLibrary := p.exporter.GetCurrentLibrary() if librarySummary[currentLibrary] != nil { for label, count := range librarySummary[currentLibrary] { - fmt.Printf(" 📁 %s: %d file paths accumulated\n", label, count) + fmt.Printf(" [STORAGE] %s: %d file paths accumulated\n", label, count) totalAccumulated += count } } - fmt.Printf("📊 Total accumulated in this library: %d file paths\n", totalAccumulated) + fmt.Printf("[STATS] Total accumulated in this library: %d file paths\n", totalAccumulated) } } @@ -448,15 +659,15 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp switch mediaType { case MediaTypeMovie: displayName = "movies" - emoji = "🎬" + emoji = "[MOVIE]" case MediaTypeTV: displayName = "tv shows" - emoji = "📺" + emoji = "[TV]" default: return fmt.Errorf("unsupported media type: %s", mediaType) } - fmt.Printf("\n📋 Fetching all %s for keyword removal...\n", displayName) + fmt.Printf("\n[INFO] Fetching all %s for keyword removal...\n", displayName) items, err := p.fetchItems(libraryID, mediaType) if err != nil { @@ -464,85 +675,102 @@ func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaTyp } if len(items) == 0 { - fmt.Printf("❌ No %s found in library!\n", displayName) + fmt.Printf("[ERROR] No %s found in library!\n", displayName) return nil } - fmt.Printf("✅ Found %d %s in library\n", len(items), displayName) + fmt.Printf("[OK] Found %d %s in library\n", len(items), displayName) removedCount := 0 skippedCount := 0 totalKeywordsRemoved := 0 - for _, item := range items { - tmdbID := p.extractTMDbID(item, mediaType) - if tmdbID == "" { - skippedCount++ - continue - } + processedCount := 0 - details, err := p.getItemDetails(item.GetRatingKey(), mediaType) - if err != nil { - fmt.Printf("❌ Error fetching %s details for %s: %v\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), err) - skippedCount++ - continue - } + for _, b := range p.makeBatches(items) { + b.logStart(emoji+" Removal", len(items)) - currentValues := p.extractCurrentValues(details) + for _, item := range b.items { + processedCount++ - if len(currentValues) == 0 { - skippedCount++ - continue - } + if len(items) > 100 && processedCount%50 == 0 { + fmt.Printf("Removal Progress: %d/%d (%.1f%%)\n", processedCount, len(items), float64(processedCount)/float64(len(items))*100) + } - keywords, err := p.getKeywords(tmdbID, mediaType) - if err != nil { - keywords = []string{} - } + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { + skippedCount++ + continue + } - keywordMap := make(map[string]bool) - for _, keyword := range keywords { - keywordMap[strings.ToLower(keyword)] = true - } + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + fmt.Printf("[ERROR] Error fetching %s details for %s: %v\n", strings.TrimSuffix(displayName, "s"), item.GetTitle(), err) + skippedCount++ + continue + } - var valuesToRemove []string - foundTMDbKeywords := false - for _, value := range currentValues { - if keywordMap[strings.ToLower(value)] { - foundTMDbKeywords = true - valuesToRemove = append(valuesToRemove, value) + currentValues := p.extractCurrentValues(details) + + if len(currentValues) == 0 { + skippedCount++ + continue } - } - if !foundTMDbKeywords { - skippedCount++ - continue - } + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + keywords = []string{} + } - fmt.Printf("\n%s Processing %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) - fmt.Printf("🔑 TMDb ID: %s\n", tmdbID) - fmt.Printf("🗑️ Removing %d TMDb keywords from %s field\n", len(valuesToRemove), p.config.UpdateField) + keywords = p.applyKeywordPrefix(keywords) - lockField := p.config.RemoveMode == "lock" - err = p.removeItemFieldKeywords(item.GetRatingKey(), libraryID, valuesToRemove, lockField, mediaType) - if err != nil { - fmt.Printf("❌ Error removing keywords from %s: %v\n", item.GetTitle(), err) - skippedCount++ - continue - } + keywordMap := make(map[string]bool) + for _, keyword := range keywords { + keywordMap[strings.ToLower(keyword)] = true + } + + var valuesToRemove []string + foundTMDbKeywords := false + for _, value := range currentValues { + if keywordMap[strings.ToLower(value)] { + foundTMDbKeywords = true + valuesToRemove = append(valuesToRemove, value) + } + } + + if !foundTMDbKeywords { + skippedCount++ + continue + } + + fmt.Printf("\n%s Processing %s: %s (%d)\n", emoji, strings.TrimSuffix(displayName, "s"), item.GetTitle(), item.GetYear()) + fmt.Printf("[KEY] TMDb ID: %s\n", tmdbID) + fmt.Printf("[REMOVE] Removing %d TMDb keywords from %s field\n", len(valuesToRemove), p.config.UpdateField) + + lockField := p.config.RemoveMode == "lock" + err = p.removeItemFieldKeywords(item.GetRatingKey(), libraryID, valuesToRemove, lockField, mediaType) + if err != nil { + fmt.Printf("[ERROR] Error removing keywords from %s: %v\n", item.GetTitle(), err) + skippedCount++ + continue + } + + totalKeywordsRemoved += len(valuesToRemove) + removedCount++ + fmt.Printf("[OK] Successfully removed keywords from %s\n", item.GetTitle()) - totalKeywordsRemoved += len(valuesToRemove) - removedCount++ - fmt.Printf("✅ Successfully removed keywords from %s\n", item.GetTitle()) + time.Sleep(p.config.ItemDelay) + } - time.Sleep(500 * time.Millisecond) + p.pauseAfterBatch(b, emoji+" Removal") } - fmt.Printf("\n📊 Removal Summary:\n") - fmt.Printf(" 📈 Total %s checked: %d\n", displayName, len(items)) - fmt.Printf(" 🗑️ %s with keywords removed: %d\n", strings.Title(displayName), removedCount) - fmt.Printf(" ⏭️ Skipped %s: %d\n", displayName, skippedCount) - fmt.Printf(" 🏷️ Total keywords removed: %d\n", totalKeywordsRemoved) + fmt.Printf("\n[STATS] Removal Summary:\n") + fmt.Printf(" [TOTAL] Total %s checked: %d\n", displayName, len(items)) + displayTitle := strings.ToUpper(displayName[:1]) + displayName[1:] + fmt.Printf(" [REMOVE] %s with keywords removed: %d\n", displayTitle, removedCount) + fmt.Printf(" [SKIP] Skipped %s: %d\n", displayName, skippedCount) + fmt.Printf(" [LABEL] Total keywords removed: %d\n", totalKeywordsRemoved) return nil } @@ -601,14 +829,33 @@ func (p *Processor) getItemDetails(ratingKey string, mediaType MediaType) (Media // getKeywords gets keywords from TMDb based on media type func (p *Processor) getKeywords(tmdbID string, mediaType MediaType) ([]string, error) { + cacheKey := string(mediaType) + ":" + tmdbID + + p.cacheMu.RLock() + if cached, ok := p.keywordCache[cacheKey]; ok { + p.cacheMu.RUnlock() + return cached, nil + } + p.cacheMu.RUnlock() + + var keywords []string + var err error switch mediaType { case MediaTypeMovie: - return p.tmdbClient.GetMovieKeywords(tmdbID) + keywords, err = p.tmdbClient.GetMovieKeywords(tmdbID) case MediaTypeTV: - return p.tmdbClient.GetTVShowKeywords(tmdbID) + keywords, err = p.tmdbClient.GetTVShowKeywords(tmdbID) default: return nil, fmt.Errorf("unsupported media type: %s", mediaType) } + if err != nil { + return nil, err + } + + p.cacheMu.Lock() + p.keywordCache[cacheKey] = keywords + p.cacheMu.Unlock() + return keywords, nil } // syncFieldWithKeywords synchronizes the configured field with TMDb keywords @@ -619,7 +866,7 @@ func (p *Processor) syncFieldWithKeywords(itemID, libraryID string, currentValue if p.config.VerboseLogging && len(cleanedValues) != len(currentValues) { removedCount := len(currentValues) - len(cleanedValues) + len(keywords) - fmt.Printf(" 🧹 Cleaned %d duplicate/unnormalized keywords\n", removedCount) + fmt.Printf(" [CLEAN] Cleaned %d duplicate/unnormalized keywords\n", removedCount) } return p.updateItemField(itemID, libraryID, cleanedValues, mediaType) @@ -693,266 +940,182 @@ func (p *Processor) extractTMDbID(item MediaItem, mediaType MediaType) string { // extractMovieTMDbID extracts TMDb ID from movie metadata or file paths func (p *Processor) extractMovieTMDbID(item MediaItem) string { - if p.config.VerboseLogging { - fmt.Printf("\n🔍 Starting TMDb ID lookup for movie: %s (%d)\n", item.GetTitle(), item.GetYear()) - fmt.Printf(" 📋 Available Plex GUIDs:\n") - for _, guid := range item.GetGuid() { - fmt.Printf(" - %s\n", guid.ID) - } + verbose := p.config.VerboseLogging + if verbose { + fmt.Printf("\n[LOOKUP] Movie: %s (%d)\n", item.GetTitle(), item.GetYear()) } - // First, try to get TMDb ID from Plex metadata + // 1. Plex metadata for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "tmdb://") { parts := strings.Split(guid.ID, "//") if len(parts) > 1 { tmdbID := strings.Split(parts[1], "?")[0] - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found TMDb ID in Plex metadata: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] Plex metadata: %s\n", tmdbID) } return tmdbID } } } - // If Radarr is enabled, try to match via Radarr + // 2. Radarr lookup (title/year, then IMDb ID) if p.config.UseRadarr && p.radarrClient != nil { - if p.config.VerboseLogging { - fmt.Printf(" 🎬 Checking Radarr for movie match...\n") - } - - // Try to match by title and year first - if p.config.VerboseLogging { - fmt.Printf(" → Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) - } movie, err := p.radarrClient.FindMovieMatch(item.GetTitle(), item.GetYear()) if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match in Radarr: %s (TMDb: %s)\n", movie.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Radarr match: %s (TMDb: %s)\n", movie.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by title/year\n") + } else if verbose { + fmt.Printf(" [SKIP] No Radarr match by title/year\n") } - // Try to match by file path - if p.config.VerboseLogging { - fmt.Printf(" → Searching by file path...\n") - } - for _, mediaItem := range item.GetMedia() { - for _, part := range mediaItem.Part { - if p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) - } - movie, err := p.radarrClient.GetMovieByPath(part.File) - if err == nil && movie != nil { - tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match by file path: %s (TMDb: %s)\n", movie.Title, tmdbID) - } - return tmdbID - } - } - } - if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by file path\n") - } - - // Try to match by IMDb ID if available for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "imdb://") { imdbID := strings.TrimPrefix(guid.ID, "imdb://") - if p.config.VerboseLogging { - fmt.Printf(" → Searching by IMDb ID: %s\n", imdbID) - } movie, err := p.radarrClient.GetMovieByIMDbID(imdbID) if err == nil && movie != nil { tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match by IMDb ID: %s (TMDb: %s)\n", movie.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Radarr match by IMDb %s: %s (TMDb: %s)\n", imdbID, movie.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by IMDb ID\n") + } else if verbose { + fmt.Printf(" [SKIP] No Radarr match by IMDb ID %s\n", imdbID) } } } } - // If not found in Radarr or Radarr not enabled, try to extract from file paths - if p.config.VerboseLogging { - fmt.Printf(" 📁 Checking file paths for TMDb ID pattern...\n") - } + // 3. File paths: check Radarr path match AND TMDb ID regex in one pass + logged := 0 for _, mediaItem := range item.GetMedia() { for _, part := range mediaItem.Part { - if p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) + if verbose && logged < 3 { + fmt.Printf(" [INFO] Checking path: %s\n", part.File) + logged++ + } + if p.config.UseRadarr && p.radarrClient != nil { + movie, err := p.radarrClient.GetMovieByPath(part.File) + if err == nil && movie != nil { + tmdbID := p.radarrClient.GetTMDbIDFromMovie(movie) + if verbose { + fmt.Printf(" [OK] Radarr path match: %s (TMDb: %s)\n", movie.Title, tmdbID) + } + return tmdbID + } } if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found TMDb ID in file path: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] TMDb ID in file path: %s\n", tmdbID) } return tmdbID } } } - if p.config.VerboseLogging { - fmt.Printf(" ❌ No TMDb ID found for movie: %s\n", item.GetTitle()) + if verbose { + fmt.Printf(" [SKIP] No TMDb ID found for: %s\n", item.GetTitle()) } - return "" } // extractTVShowTMDbID extracts TMDb ID from TV show metadata or episode file paths func (p *Processor) extractTVShowTMDbID(item MediaItem) string { - if p.config.VerboseLogging { - fmt.Printf("\n🔍 Starting TMDb ID lookup for TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) - fmt.Printf(" 📋 Available Plex GUIDs:\n") - for _, guid := range item.GetGuid() { - fmt.Printf(" - %s\n", guid.ID) - } + verbose := p.config.VerboseLogging + if verbose { + fmt.Printf("\n[LOOKUP] TV show: %s (%d)\n", item.GetTitle(), item.GetYear()) } - // First check if we have TMDb GUID in the TV show metadata + // 1. Plex metadata for _, guid := range item.GetGuid() { if strings.HasPrefix(guid.ID, "tmdb://") { tmdbID := strings.TrimPrefix(guid.ID, "tmdb://") - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found TMDb ID in Plex metadata: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] Plex metadata: %s\n", tmdbID) } return tmdbID } } - // If Sonarr is enabled, try to match via Sonarr + // 2. Sonarr lookup (title/year, TVDb ID, IMDb ID) if p.config.UseSonarr && p.sonarrClient != nil { - if p.config.VerboseLogging { - fmt.Printf(" 📺 Checking Sonarr for series match...\n") - } - - // Try to match by title and year first - if p.config.VerboseLogging { - fmt.Printf(" → Searching by title: \"%s\" year: %d\n", item.GetTitle(), item.GetYear()) - } series, err := p.sonarrClient.FindSeriesMatch(item.GetTitle(), item.GetYear()) if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match in Sonarr: %s (TMDb: %s)\n", series.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Sonarr match: %s (TMDb: %s)\n", series.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by title/year\n") + } else if verbose { + fmt.Printf(" [SKIP] No Sonarr match by title/year\n") } - // Try to match by TVDb ID if available for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "tvdb://") { tvdbIDStr := strings.TrimPrefix(guid.ID, "tvdb://") - // Parse TVDb ID to int var tvdbID int if _, err := fmt.Sscanf(tvdbIDStr, "%d", &tvdbID); err == nil { - if p.config.VerboseLogging { - fmt.Printf(" → Searching by TVDb ID: %d\n", tvdbID) - } series, err := p.sonarrClient.GetSeriesByTVDbID(tvdbID) if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match by TVDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Sonarr match by TVDb %d: %s (TMDb: %s)\n", tvdbID, series.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by TVDb ID\n") + } else if verbose { + fmt.Printf(" [SKIP] No Sonarr match by TVDb ID %d\n", tvdbID) } } } - } - - // Try to match by IMDb ID if available - for _, guid := range item.GetGuid() { if strings.Contains(guid.ID, "imdb://") { imdbID := strings.TrimPrefix(guid.ID, "imdb://") - if p.config.VerboseLogging { - fmt.Printf(" → Searching by IMDb ID: %s\n", imdbID) - } series, err := p.sonarrClient.GetSeriesByIMDbID(imdbID) if err == nil && series != nil { tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match by IMDb ID: %s (TMDb: %s)\n", series.Title, tmdbID) + if verbose { + fmt.Printf(" [OK] Sonarr match by IMDb %s: %s (TMDb: %s)\n", imdbID, series.Title, tmdbID) } return tmdbID - } else if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by IMDb ID\n") - } - } - } - - // Try to match by file path from episodes - if p.config.VerboseLogging { - fmt.Printf(" → Searching by episode file paths...\n") - } - episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) - if err == nil { - episodeCount := 0 - for _, episode := range episodes { - for _, mediaItem := range episode.Media { - for _, part := range mediaItem.Part { - episodeCount++ - if episodeCount <= 5 && p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) - } - series, err := p.sonarrClient.GetSeriesByPath(part.File) - if err == nil && series != nil { - tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found match by file path: %s (TMDb: %s)\n", series.Title, tmdbID) - } - return tmdbID - } - } + } else if verbose { + fmt.Printf(" [SKIP] No Sonarr match by IMDb ID %s\n", imdbID) } } - if episodeCount > 5 && p.config.VerboseLogging { - fmt.Printf(" ... and %d more episodes\n", episodeCount-5) - } - if p.config.VerboseLogging { - fmt.Printf(" ❌ No match found by file path\n") - } - } else if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Could not fetch episodes: %v\n", err) } } - // If no TMDb GUID found and Sonarr not enabled, get episodes and check their file paths - if p.config.VerboseLogging { - fmt.Printf(" 📁 Checking episode file paths for TMDb ID pattern...\n") - } + // 3. Episode file paths: check Sonarr path match AND TMDb ID regex in one pass episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) if err != nil { - if p.config.VerboseLogging { - fmt.Printf(" ⚠️ Error fetching episodes: %v\n", err) - } else { - fmt.Printf("⚠️ Error fetching episodes for %s: %v\n", item.GetTitle(), err) + if verbose { + fmt.Printf(" [WARN] Could not fetch episodes: %v\n", err) } return "" } - // Check file paths in episodes for TMDb ID - stop at first match - episodeCount := 0 + logged := 0 for _, episode := range episodes { for _, mediaItem := range episode.Media { for _, part := range mediaItem.Part { - episodeCount++ - if episodeCount <= 5 && p.config.VerboseLogging { - fmt.Printf(" - Checking: %s\n", part.File) + if verbose && logged < 3 { + fmt.Printf(" [INFO] Checking path: %s\n", part.File) + logged++ + } + if p.config.UseSonarr && p.sonarrClient != nil { + series, err := p.sonarrClient.GetSeriesByPath(part.File) + if err == nil && series != nil { + tmdbID := p.sonarrClient.GetTMDbIDFromSeries(series) + if verbose { + fmt.Printf(" [OK] Sonarr path match: %s (TMDb: %s)\n", series.Title, tmdbID) + } + return tmdbID + } } if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { - if p.config.VerboseLogging { - fmt.Printf(" ✅ Found TMDb ID in file path: %s\n", tmdbID) + if verbose { + fmt.Printf(" [OK] TMDb ID in file path: %s\n", tmdbID) } return tmdbID } @@ -960,14 +1123,21 @@ func (p *Processor) extractTVShowTMDbID(item MediaItem) string { } } - if episodeCount > 5 && p.config.VerboseLogging { - fmt.Printf(" ... and %d more episodes\n", episodeCount-5) + if verbose && logged > 0 { + totalPaths := 0 + for _, ep := range episodes { + for _, m := range ep.Media { + totalPaths += len(m.Part) + } + } + if totalPaths > 3 { + fmt.Printf(" [INFO] ... and %d more paths checked\n", totalPaths-3) + } } - if p.config.VerboseLogging { - fmt.Printf(" ❌ No TMDb ID found for TV show: %s\n", item.GetTitle()) + if verbose { + fmt.Printf(" [SKIP] No TMDb ID found for: %s\n", item.GetTitle()) } - return "" } diff --git a/internal/plex/client.go b/internal/plex/client.go index 0e3d5e5..06af679 100644 --- a/internal/plex/client.go +++ b/internal/plex/client.go @@ -7,12 +7,31 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" "github.com/nullable-eth/labelarr/internal/config" ) +// urlSecretRedactor matches credential query params so tokens don't leak into +// error messages produced by net/http (which embed the full request URL). +var urlSecretRedactor = regexp.MustCompile(`([?&](?:X-Plex-Token|apikey|api_key)=)[^&\s"]+`) + +func redactURLSecrets(s string) string { + return urlSecretRedactor.ReplaceAllString(s, "${1}REDACTED") +} + +// safeDo wraps httpClient.Do so transport errors have their request URL +// stripped of secret query params before bubbling up. +func (c *Client) safeDo(req *http.Request) (*http.Response, error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%s", redactURLSecrets(err.Error())) + } + return resp, nil +} + // Client represents a Plex API client type Client struct { config *config.Config @@ -21,8 +40,11 @@ type Client struct { // NewClient creates a new Plex client func NewClient(cfg *config.Config) *Client { + if cfg.PlexInsecureSkipVerify { + fmt.Printf("[WARN] TLS certificate verification is disabled for Plex (PLEX_INSECURE_SKIP_VERIFY=true)\n") + } tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.PlexInsecureSkipVerify}, } return &Client{ @@ -42,7 +64,7 @@ func (c *Client) GetAllLibraries() ([]Library, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch libraries: %w", err) } @@ -77,7 +99,7 @@ func (c *Client) GetMoviesFromLibrary(libraryID string) ([]Movie, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch movies: %w", err) } @@ -111,7 +133,7 @@ func (c *Client) GetMovieDetails(ratingKey string) (*Movie, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch movie details: %w", err) } @@ -141,7 +163,7 @@ func (c *Client) GetMovieDetails(ratingKey string) (*Movie, error) { // UpdateMediaField updates a media item's field (labels or genres) with new keywords func (c *Client) UpdateMediaField(mediaID, libraryID string, keywords []string, updateField string, mediaType string) error { if c.config.VerboseLogging { - fmt.Printf(" 🌐 Making Plex API call to update %s field with %d keywords\n", updateField, len(keywords)) + fmt.Printf(" [API] Making Plex API call to update %s field with %d keywords\n", updateField, len(keywords)) } return c.updateMediaField(mediaID, libraryID, keywords, updateField, c.getMediaTypeForLibraryType(mediaType)) } @@ -162,7 +184,7 @@ func (c *Client) GetTVShowsFromLibrary(libraryID string) ([]TVShow, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch TV shows: %w", err) } @@ -196,7 +218,7 @@ func (c *Client) GetTVShowDetails(ratingKey string) (*TVShow, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch TV show details: %w", err) } @@ -234,7 +256,7 @@ func (c *Client) GetTVShowEpisodes(ratingKey string) ([]Episode, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch TV show episodes: %w", err) } @@ -268,7 +290,7 @@ func (c *Client) GetAllTVShowEpisodes(ratingKey string) ([]Episode, error) { req.Header.Set("X-Plex-Token", c.config.PlexToken) req.Header.Set("Accept", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return nil, fmt.Errorf("failed to fetch all TV show episodes: %w", err) } @@ -301,7 +323,7 @@ func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, // Parse the URL to add query parameters properly parsedURL, err := url.Parse(baseURL) if err != nil { - return fmt.Errorf("failed to parse URL: %w", err) + return fmt.Errorf("failed to parse URL: %s", redactURLSecrets(err.Error())) } // Create query parameters @@ -329,7 +351,7 @@ func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, return fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return fmt.Errorf("failed to update media field: %w", err) } @@ -342,7 +364,7 @@ func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, if c.config.VerboseLogging { duration := time.Since(startTime) - fmt.Printf(" ⏱️ Plex API call completed in %v\n", duration) + fmt.Printf(" [TIMING] Plex API call completed in %v\n", duration) } return nil @@ -356,7 +378,7 @@ func (c *Client) removeMediaFieldKeywords(mediaID, libraryID string, valuesToRem // Parse the URL to add query parameters properly parsedURL, err := url.Parse(baseURL) if err != nil { - return fmt.Errorf("failed to parse URL: %w", err) + return fmt.Errorf("failed to parse URL: %s", redactURLSecrets(err.Error())) } // Create query parameters @@ -388,7 +410,7 @@ func (c *Client) removeMediaFieldKeywords(mediaID, libraryID string, valuesToRem return fmt.Errorf("failed to create request: %w", err) } - resp, err := c.httpClient.Do(req) + resp, err := c.safeDo(req) if err != nil { return fmt.Errorf("failed to remove media field keywords: %w", err) } diff --git a/internal/radarr/client.go b/internal/radarr/client.go index e4a07ab..692c09d 100644 --- a/internal/radarr/client.go +++ b/internal/radarr/client.go @@ -4,24 +4,29 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" + "regexp" "strconv" "strings" + "sync" "time" ) -// Client represents a Radarr API client +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]`) + +func cleanTitle(s string) string { + return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "") +} + type Client struct { baseURL string apiKey string httpClient *http.Client + movies []Movie + moviesMu sync.Mutex } -// NewClient creates a new Radarr API client func NewClient(baseURL, apiKey string) *Client { - // Ensure baseURL doesn't have trailing slash baseURL = strings.TrimRight(baseURL, "/") - return &Client{ baseURL: baseURL, apiKey: apiKey, @@ -31,211 +36,239 @@ func NewClient(baseURL, apiKey string) *Client { } } -// makeRequest performs an API request to Radarr -func (c *Client) makeRequest(method, endpoint string, params url.Values) (*http.Response, error) { +func (c *Client) makeRequest(method, endpoint string, params map[string]string) (*http.Response, error) { fullURL := fmt.Sprintf("%s%s", c.baseURL, endpoint) - - if params != nil && len(params) > 0 { - fullURL = fmt.Sprintf("%s?%s", fullURL, params.Encode()) + + if len(params) > 0 { + parts := make([]string, 0, len(params)) + for k, v := range params { + parts = append(parts, k+"="+v) + } + fullURL += "?" + strings.Join(parts, "&") } - + req, err := http.NewRequest(method, fullURL, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } - + req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("error making request: %w", err) } - + if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("radarr API returned status %d", resp.StatusCode) } - + return resp, nil } -// GetAllMovies retrieves all movies from Radarr +// GetAllMovies fetches the full movie list from Radarr, caching the result +// for the lifetime of the client. Call ClearCache to refresh. func (c *Client) GetAllMovies() ([]Movie, error) { + c.moviesMu.Lock() + defer c.moviesMu.Unlock() + + if c.movies != nil { + return c.movies, nil + } + resp, err := c.makeRequest("GET", "/api/v3/movie", nil) if err != nil { return nil, err } defer resp.Body.Close() - + var movies []Movie if err := json.NewDecoder(resp.Body).Decode(&movies); err != nil { return nil, fmt.Errorf("error decoding movies: %w", err) } - + + c.movies = movies return movies, nil } -// GetMovieByTMDbID retrieves a movie by its TMDb ID -func (c *Client) GetMovieByTMDbID(tmdbID int) (*Movie, error) { - movies, err := c.GetAllMovies() - if err != nil { - return nil, err - } - - for _, movie := range movies { - if movie.TMDbID == tmdbID { - return &movie, nil - } - } - - return nil, fmt.Errorf("movie with TMDb ID %d not found", tmdbID) +// ClearCache forces the next GetAllMovies call to re-fetch from Radarr. +func (c *Client) ClearCache() { + c.moviesMu.Lock() + c.movies = nil + c.moviesMu.Unlock() } -// SearchMovieByTitle searches for movies by title +// SearchMovieByTitle returns all movies whose title, original title, clean title, +// or alternate titles match the query. Matching is bidirectional (either contains +// the other) and also checks a cleaned/normalized form for punctuation-insensitive matching. func (c *Client) SearchMovieByTitle(title string) ([]Movie, error) { - // First try to get all movies and filter locally - // This is more reliable than using Radarr's search endpoint allMovies, err := c.GetAllMovies() if err != nil { return nil, err } - - var matches []Movie + titleLower := strings.ToLower(title) - + titleClean := cleanTitle(title) + + var matches []Movie for _, movie := range allMovies { - if strings.Contains(strings.ToLower(movie.Title), titleLower) || - strings.Contains(strings.ToLower(movie.OriginalTitle), titleLower) { + if titleMatches(titleLower, titleClean, movie) { matches = append(matches, movie) - continue - } - - // Check alternate titles - for _, altTitle := range movie.AlternateTitles { - if strings.Contains(strings.ToLower(altTitle.Title), titleLower) { - matches = append(matches, movie) - break - } } } - + return matches, nil } -// FindMovieMatch attempts to find the best match for a movie by title and year +func titleMatches(titleLower, titleClean string, movie Movie) bool { + movieTitleLower := strings.ToLower(movie.Title) + movieOrigLower := strings.ToLower(movie.OriginalTitle) + + // Bidirectional substring match on title and original title + if containsEither(movieTitleLower, titleLower) || containsEither(movieOrigLower, titleLower) { + return true + } + + // CleanTitle match (Radarr provides this stripped of punctuation) + if movie.CleanTitle != "" && (movie.CleanTitle == titleClean || strings.Contains(movie.CleanTitle, titleClean) || strings.Contains(titleClean, movie.CleanTitle)) { + return true + } + + // Cleaned form match against title (for cases where CleanTitle field is empty) + if cleanTitle(movie.Title) == titleClean { + return true + } + + // Alternate titles + for _, alt := range movie.AlternateTitles { + altLower := strings.ToLower(alt.Title) + if containsEither(altLower, titleLower) { + return true + } + if alt.CleanTitle != "" && (alt.CleanTitle == titleClean || strings.Contains(alt.CleanTitle, titleClean) || strings.Contains(titleClean, alt.CleanTitle)) { + return true + } + } + + return false +} + +func containsEither(a, b string) bool { + return strings.Contains(a, b) || strings.Contains(b, a) +} + +// FindMovieMatch finds the best match for a movie by title and year. func (c *Client) FindMovieMatch(title string, year int) (*Movie, error) { movies, err := c.SearchMovieByTitle(title) if err != nil { return nil, err } - - // First try exact title and year match + titleLower := strings.ToLower(title) - for _, movie := range movies { - if strings.ToLower(movie.Title) == titleLower && movie.Year == year { - return &movie, nil + titleClean := cleanTitle(title) + + // Exact title + year + for i := range movies { + if strings.ToLower(movies[i].Title) == titleLower && movies[i].Year == year { + return &movies[i], nil } } - - // Then try year match with similar title - for _, movie := range movies { - if movie.Year == year { - return &movie, nil + + // CleanTitle + year + for i := range movies { + if movies[i].Year == year && (movies[i].CleanTitle == titleClean || cleanTitle(movies[i].Title) == titleClean) { + return &movies[i], nil } } - - // If still no match, try within 1 year range - for _, movie := range movies { - if movie.Year >= year-1 && movie.Year <= year+1 { - return &movie, nil + + // Year match with any title hit + for i := range movies { + if movies[i].Year == year { + return &movies[i], nil } } - - // Return first match if any found - if len(movies) > 0 { - return &movies[0], nil - } - - return nil, fmt.Errorf("no movie match found for: %s (%d)", title, year) -} -// GetSystemStatus retrieves Radarr system status (useful for testing connection) -func (c *Client) GetSystemStatus() (*SystemStatus, error) { - resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) - if err != nil { - return nil, err + // Within 1 year + for i := range movies { + if movies[i].Year >= year-1 && movies[i].Year <= year+1 { + return &movies[i], nil + } } - defer resp.Body.Close() - - var status SystemStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, fmt.Errorf("error decoding system status: %w", err) + + if len(movies) > 0 { + return &movies[0], nil } - - return &status, nil -} -// TestConnection tests the connection to Radarr -func (c *Client) TestConnection() error { - _, err := c.GetSystemStatus() - return err + return nil, fmt.Errorf("no movie match found for: %s (%d)", title, year) } -// GetMovieByIMDbID retrieves a movie by its IMDb ID func (c *Client) GetMovieByIMDbID(imdbID string) (*Movie, error) { - // Normalize IMDb ID format if !strings.HasPrefix(imdbID, "tt") { imdbID = "tt" + imdbID } - + movies, err := c.GetAllMovies() if err != nil { return nil, err } - - for _, movie := range movies { - if movie.IMDbID == imdbID { - return &movie, nil + + for i := range movies { + if movies[i].IMDbID == imdbID { + return &movies[i], nil } } - + return nil, fmt.Errorf("movie with IMDb ID %s not found", imdbID) } -// GetMovieByPath attempts to find a movie by its file path func (c *Client) GetMovieByPath(filePath string) (*Movie, error) { movies, err := c.GetAllMovies() if err != nil { return nil, err } - - // Normalize the file path for comparison + filePathLower := strings.ToLower(filePath) - - for _, movie := range movies { - // Check if the file path is within the movie's folder - if movie.Path != "" && strings.Contains(filePathLower, strings.ToLower(movie.Path)) { - return &movie, nil + + for i := range movies { + if movies[i].Path != "" && strings.Contains(filePathLower, strings.ToLower(movies[i].Path)) { + return &movies[i], nil } - - // Also check against the movie file path if available - if movie.HasFile && movie.MovieFile.Path != "" { - if strings.EqualFold(movie.MovieFile.Path, filePath) || - strings.Contains(filePathLower, strings.ToLower(movie.MovieFile.Path)) { - return &movie, nil + if movies[i].HasFile && movies[i].MovieFile.Path != "" { + if strings.EqualFold(movies[i].MovieFile.Path, filePath) || + strings.Contains(filePathLower, strings.ToLower(movies[i].MovieFile.Path)) { + return &movies[i], nil } } } - + return nil, fmt.Errorf("movie not found for path: %s", filePath) } -// GetTMDbIDFromMovie extracts the TMDb ID from a Radarr movie func (c *Client) GetTMDbIDFromMovie(movie *Movie) string { if movie.TMDbID > 0 { return strconv.Itoa(movie.TMDbID) } return "" -} \ No newline at end of file +} + +func (c *Client) GetSystemStatus() (*SystemStatus, error) { + resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var status SystemStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("error decoding system status: %w", err) + } + + return &status, nil +} + +func (c *Client) TestConnection() error { + _, err := c.GetSystemStatus() + return err +} diff --git a/internal/sonarr/client.go b/internal/sonarr/client.go index 89c5cc2..8cee7cd 100644 --- a/internal/sonarr/client.go +++ b/internal/sonarr/client.go @@ -5,23 +5,33 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strconv" "strings" + "sync" "time" ) -// Client represents a Sonarr API client +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]`) + +func cleanTitle(s string) string { + return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "") +} + +func containsEither(a, b string) bool { + return strings.Contains(a, b) || strings.Contains(b, a) +} + type Client struct { baseURL string apiKey string httpClient *http.Client + series []Series + seriesMu sync.Mutex } -// NewClient creates a new Sonarr API client func NewClient(baseURL, apiKey string) *Client { - // Ensure baseURL doesn't have trailing slash baseURL = strings.TrimRight(baseURL, "/") - return &Client{ baseURL: baseURL, apiKey: apiKey, @@ -31,216 +41,225 @@ func NewClient(baseURL, apiKey string) *Client { } } -// makeRequest performs an API request to Sonarr func (c *Client) makeRequest(method, endpoint string, params url.Values) (*http.Response, error) { fullURL := fmt.Sprintf("%s%s", c.baseURL, endpoint) - + if params != nil && len(params) > 0 { fullURL = fmt.Sprintf("%s?%s", fullURL, params.Encode()) } - + req, err := http.NewRequest(method, fullURL, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } - + req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") - req.Header.Set("Content-Type", "application/json") - + resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("error making request: %w", err) } - + if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("sonarr API returned status %d", resp.StatusCode) } - + return resp, nil } -// GetAllSeries retrieves all TV series from Sonarr +// GetAllSeries fetches the full series list from Sonarr, caching the result +// for the lifetime of the client. Call ClearCache to refresh. func (c *Client) GetAllSeries() ([]Series, error) { + c.seriesMu.Lock() + defer c.seriesMu.Unlock() + + if c.series != nil { + return c.series, nil + } + resp, err := c.makeRequest("GET", "/api/v3/series", nil) if err != nil { return nil, err } defer resp.Body.Close() - + var series []Series if err := json.NewDecoder(resp.Body).Decode(&series); err != nil { return nil, fmt.Errorf("error decoding series: %w", err) } - + + c.series = series return series, nil } -// GetSeriesByTMDbID retrieves a series by its TMDb ID -func (c *Client) GetSeriesByTMDbID(tmdbID int) (*Series, error) { - series, err := c.GetAllSeries() +// ClearCache forces the next GetAllSeries call to re-fetch from Sonarr. +func (c *Client) ClearCache() { + c.seriesMu.Lock() + c.series = nil + c.seriesMu.Unlock() +} + +// SearchSeriesByTitle returns all series whose title, sort title, clean title, +// or alternate titles match the query. Matching is bidirectional and also checks +// a cleaned/normalized form for punctuation-insensitive matching. +func (c *Client) SearchSeriesByTitle(title string) ([]Series, error) { + allSeries, err := c.GetAllSeries() if err != nil { return nil, err } - - for _, s := range series { - if s.TMDBID == tmdbID { - return &s, nil + + titleLower := strings.ToLower(title) + titleClean := cleanTitle(title) + + var matches []Series + for _, s := range allSeries { + if seriesTitleMatches(titleLower, titleClean, s) { + matches = append(matches, s) } } - - return nil, fmt.Errorf("series with TMDb ID %d not found", tmdbID) + + return matches, nil } -// GetSeriesByTVDbID retrieves a series by its TVDb ID -func (c *Client) GetSeriesByTVDbID(tvdbID int) (*Series, error) { - series, err := c.GetAllSeries() - if err != nil { - return nil, err +func seriesTitleMatches(titleLower, titleClean string, s Series) bool { + sTitleLower := strings.ToLower(s.Title) + sSortLower := strings.ToLower(s.SortTitle) + + if containsEither(sTitleLower, titleLower) || containsEither(sSortLower, titleLower) { + return true } - - for _, s := range series { - if s.TVDbID == tvdbID { - return &s, nil - } + + if s.CleanTitle != "" && (s.CleanTitle == titleClean || strings.Contains(s.CleanTitle, titleClean) || strings.Contains(titleClean, s.CleanTitle)) { + return true } - - return nil, fmt.Errorf("series with TVDb ID %d not found", tvdbID) -} -// SearchSeriesByTitle searches for series by title -func (c *Client) SearchSeriesByTitle(title string) ([]Series, error) { - // First try to get all series and filter locally - // This is more reliable than using Sonarr's search endpoint - allSeries, err := c.GetAllSeries() - if err != nil { - return nil, err + if cleanTitle(s.Title) == titleClean { + return true } - - var matches []Series - titleLower := strings.ToLower(title) - - for _, series := range allSeries { - if strings.Contains(strings.ToLower(series.Title), titleLower) || - strings.Contains(strings.ToLower(series.SortTitle), titleLower) { - matches = append(matches, series) - continue - } - - // Check alternate titles - for _, altTitle := range series.AlternateTitles { - if strings.Contains(strings.ToLower(altTitle.Title), titleLower) { - matches = append(matches, series) - break - } + + for _, alt := range s.AlternateTitles { + altLower := strings.ToLower(alt.Title) + if containsEither(altLower, titleLower) { + return true } } - - return matches, nil + + return false } -// FindSeriesMatch attempts to find the best match for a series by title and year +// FindSeriesMatch finds the best match for a series by title and year. func (c *Client) FindSeriesMatch(title string, year int) (*Series, error) { series, err := c.SearchSeriesByTitle(title) if err != nil { return nil, err } - - // First try exact title and year match + titleLower := strings.ToLower(title) - for _, s := range series { - if strings.ToLower(s.Title) == titleLower && s.Year == year { - return &s, nil + titleClean := cleanTitle(title) + + // Exact title + year + for i := range series { + if strings.ToLower(series[i].Title) == titleLower && series[i].Year == year { + return &series[i], nil } } - - // Then try year match with similar title - for _, s := range series { - if s.Year == year { - return &s, nil + + // CleanTitle + year + for i := range series { + if series[i].Year == year && (series[i].CleanTitle == titleClean || cleanTitle(series[i].Title) == titleClean) { + return &series[i], nil } } - - // If still no match, try within 1 year range - for _, s := range series { - if s.Year >= year-1 && s.Year <= year+1 { - return &s, nil + + // Year match with any title hit + for i := range series { + if series[i].Year == year { + return &series[i], nil } } - - // Return first match if any found + + // Within 1 year + for i := range series { + if series[i].Year >= year-1 && series[i].Year <= year+1 { + return &series[i], nil + } + } + if len(series) > 0 { return &series[0], nil } - + return nil, fmt.Errorf("no series match found for: %s (%d)", title, year) } -// GetSystemStatus retrieves Sonarr system status (useful for testing connection) -func (c *Client) GetSystemStatus() (*SystemStatus, error) { - resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) +func (c *Client) GetSeriesByTMDbID(tmdbID int) (*Series, error) { + series, err := c.GetAllSeries() if err != nil { return nil, err } - defer resp.Body.Close() - - var status SystemStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return nil, fmt.Errorf("error decoding system status: %w", err) + + for i := range series { + if series[i].TMDBID == tmdbID { + return &series[i], nil + } } - - return &status, nil + + return nil, fmt.Errorf("series with TMDb ID %d not found", tmdbID) } -// TestConnection tests the connection to Sonarr -func (c *Client) TestConnection() error { - _, err := c.GetSystemStatus() - return err +func (c *Client) GetSeriesByTVDbID(tvdbID int) (*Series, error) { + series, err := c.GetAllSeries() + if err != nil { + return nil, err + } + + for i := range series { + if series[i].TVDbID == tvdbID { + return &series[i], nil + } + } + + return nil, fmt.Errorf("series with TVDb ID %d not found", tvdbID) } -// GetSeriesByIMDbID retrieves a series by its IMDb ID func (c *Client) GetSeriesByIMDbID(imdbID string) (*Series, error) { - // Normalize IMDb ID format if !strings.HasPrefix(imdbID, "tt") { imdbID = "tt" + imdbID } - + series, err := c.GetAllSeries() if err != nil { return nil, err } - - for _, s := range series { - if s.IMDBID == imdbID { - return &s, nil + + for i := range series { + if series[i].IMDBID == imdbID { + return &series[i], nil } } - + return nil, fmt.Errorf("series with IMDb ID %s not found", imdbID) } -// GetSeriesByPath attempts to find a series by its file path func (c *Client) GetSeriesByPath(filePath string) (*Series, error) { series, err := c.GetAllSeries() if err != nil { return nil, err } - - // Normalize the file path for comparison + filePathLower := strings.ToLower(filePath) - - for _, s := range series { - // Check if the file path is within the series' folder - if s.Path != "" && strings.Contains(filePathLower, strings.ToLower(s.Path)) { - return &s, nil + + for i := range series { + if series[i].Path != "" && strings.Contains(filePathLower, strings.ToLower(series[i].Path)) { + return &series[i], nil } } - + return nil, fmt.Errorf("series not found for path: %s", filePath) } -// GetTMDbIDFromSeries extracts the TMDb ID from a Sonarr series func (c *Client) GetTMDbIDFromSeries(series *Series) string { if series.TMDBID > 0 { return strconv.Itoa(series.TMDBID) @@ -248,21 +267,40 @@ func (c *Client) GetTMDbIDFromSeries(series *Series) string { return "" } -// GetEpisodesBySeries gets all episodes for a series func (c *Client) GetEpisodesBySeries(seriesID int) ([]Episode, error) { params := url.Values{} params.Set("seriesId", strconv.Itoa(seriesID)) - + resp, err := c.makeRequest("GET", "/api/v3/episode", params) if err != nil { return nil, err } defer resp.Body.Close() - + var episodes []Episode if err := json.NewDecoder(resp.Body).Decode(&episodes); err != nil { return nil, fmt.Errorf("error decoding episodes: %w", err) } - + return episodes, nil -} \ No newline at end of file +} + +func (c *Client) GetSystemStatus() (*SystemStatus, error) { + resp, err := c.makeRequest("GET", "/api/v3/system/status", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var status SystemStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("error decoding system status: %w", err) + } + + return &status, nil +} + +func (c *Client) TestConnection() error { + _, err := c.GetSystemStatus() + return err +} diff --git a/internal/tmdb/client.go b/internal/tmdb/client.go index ba4ec49..a5e5801 100644 --- a/internal/tmdb/client.go +++ b/internal/tmdb/client.go @@ -78,7 +78,7 @@ func (c *Client) GetMovieKeywords(tmdbID string) ([]string, error) { if c.config.VerboseLogging { for i, original := range keywords { if i < len(normalizedKeywords) && original != normalizedKeywords[i] { - fmt.Printf(" 📝 Normalized: \"%s\" → \"%s\"\n", original, normalizedKeywords[i]) + fmt.Printf(" [NOTE] Normalized: \"%s\" -> \"%s\"\n", original, normalizedKeywords[i]) } } } @@ -139,7 +139,7 @@ func (c *Client) GetTVShowKeywords(tmdbID string) ([]string, error) { if c.config.VerboseLogging { for i, original := range keywords { if i < len(normalizedKeywords) && original != normalizedKeywords[i] { - fmt.Printf(" 📝 Normalized: \"%s\" → \"%s\"\n", original, normalizedKeywords[i]) + fmt.Printf(" [NOTE] Normalized: \"%s\" -> \"%s\"\n", original, normalizedKeywords[i]) } } } diff --git a/internal/utils/set.go b/internal/utils/set.go new file mode 100644 index 0000000..894f866 --- /dev/null +++ b/internal/utils/set.go @@ -0,0 +1,9 @@ +package utils + +func StringSet(items []string) map[string]bool { + m := make(map[string]bool, len(items)) + for _, s := range items { + m[s] = true + } + return m +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a57ddc6 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,3 @@ +package version + +const Version = "1.3.2" diff --git a/internal/webhook/server.go b/internal/webhook/server.go new file mode 100644 index 0000000..494cb0f --- /dev/null +++ b/internal/webhook/server.go @@ -0,0 +1,379 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/nullable-eth/labelarr/internal/config" + "github.com/nullable-eth/labelarr/internal/media" + "github.com/nullable-eth/labelarr/internal/plex" +) + +const eventLibraryNew = "library.new" + +// PlexWebhookPayload matches the Plex webhook JSON structure. +// Plex sends this as the "payload" field in a multipart/form-data POST. +// Requires Plex Pass on the server. +type PlexWebhookPayload struct { + Event string `json:"event"` + User bool `json:"user"` + Owner bool `json:"owner"` + Account struct { + ID int `json:"id"` + Title string `json:"title"` + } `json:"Account"` + Server struct { + Title string `json:"title"` + UUID string `json:"uuid"` + } `json:"Server"` + Player struct { + Local bool `json:"local"` + Title string `json:"title"` + UUID string `json:"uuid"` + } `json:"Player"` + Metadata struct { + LibrarySectionType string `json:"librarySectionType"` + LibrarySectionID int `json:"librarySectionID"` + LibrarySectionTitle string `json:"librarySectionTitle"` + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + Type string `json:"type"` + Title string `json:"title"` + Year int `json:"year"` + AddedAt int64 `json:"addedAt"` + } `json:"Metadata"` +} + +type libraryInfo struct { + name string + mediaType media.MediaType +} + +// pendingWork tracks accumulated rating keys during a debounce window. +type pendingWork struct { + libraryName string + mediaType media.MediaType + ratingKeys []string + timer *time.Timer + gen uint64 +} + +// Scanner kicks off full or per-library scan cycles. Implemented by main. +type Scanner interface { + RunAll() + RunLibrary(libraryID, libraryName string, mediaType media.MediaType) error +} + +type Server struct { + config *config.Config + processor *media.Processor + scanner Scanner + httpServer *http.Server + libraryMap map[string]libraryInfo + pending map[string]*pendingWork + pendingMu sync.Mutex + scanMu sync.Mutex + scanning bool +} + +func NewServer(cfg *config.Config, proc *media.Processor, movieLibs, tvLibs []plex.Library, scanner Scanner) *Server { + libMap := make(map[string]libraryInfo, len(movieLibs)+len(tvLibs)) + for _, lib := range movieLibs { + libMap[lib.Key] = libraryInfo{name: lib.Title, mediaType: media.MediaTypeMovie} + } + for _, lib := range tvLibs { + libMap[lib.Key] = libraryInfo{name: lib.Title, mediaType: media.MediaTypeTV} + } + + return &Server{ + config: cfg, + processor: proc, + scanner: scanner, + libraryMap: libMap, + pending: make(map[string]*pendingWork), + } +} + +func (s *Server) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/webhook", s.handleWebhook) + mux.HandleFunc("/scan", s.handleScan) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) + + addr := fmt.Sprintf(":%d", s.config.WebhookPort) + + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to bind webhook port %d: %w", s.config.WebhookPort, err) + } + + s.httpServer = &http.Server{ + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + go func() { + if err := s.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed { + fmt.Printf("Webhook server error: %v\n", err) + } + }() + + return nil +} + +func (s *Server) Stop(ctx context.Context) error { + if s.httpServer != nil { + return s.httpServer.Shutdown(ctx) + } + return nil +} + +func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseMultipartForm(1 << 20); err != nil { + fmt.Printf("[WEBHOOK] 400 ParseMultipartForm: content-type=%q content-length=%d err=%v\n", + r.Header.Get("Content-Type"), r.ContentLength, err) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + payloadStr := r.FormValue("payload") + if payloadStr == "" { + formKeys := make([]string, 0, len(r.Form)) + for k := range r.Form { + formKeys = append(formKeys, k) + } + fileKeys := []string{} + if r.MultipartForm != nil { + for k := range r.MultipartForm.File { + fileKeys = append(fileKeys, k) + } + } + fmt.Printf("[WEBHOOK] 400 MissingPayload: content-type=%q form_keys=%v file_keys=%v\n", + r.Header.Get("Content-Type"), formKeys, fileKeys) + http.Error(w, "Missing payload", http.StatusBadRequest) + return + } + + var payload PlexWebhookPayload + if err := json.Unmarshal([]byte(payloadStr), &payload); err != nil { + snippet := payloadStr + if len(snippet) > 300 { + snippet = snippet[:300] + } + fmt.Printf("[WEBHOOK] 400 InvalidPayload: err=%v payload_snippet=%q\n", err, snippet) + http.Error(w, "Invalid payload", http.StatusBadRequest) + return + } + + if s.config.VerboseLogging { + fmt.Printf("Webhook received: event=%s library=%s section_type=%s media_type=%s title=%s\n", + payload.Event, + payload.Metadata.LibrarySectionTitle, + payload.Metadata.LibrarySectionType, + payload.Metadata.Type, + payload.Metadata.Title) + } + + if payload.Event != eventLibraryNew { + w.WriteHeader(http.StatusOK) + return + } + + libraryID := strconv.Itoa(payload.Metadata.LibrarySectionID) + mediaType := s.resolveMediaType(libraryID, payload.Metadata.LibrarySectionType) + libraryName := payload.Metadata.LibrarySectionTitle + if libraryName == "" { + if info, ok := s.libraryMap[libraryID]; ok { + libraryName = info.name + } + } + + if mediaType != media.MediaTypeUnknown { + s.addPendingItem(libraryID, libraryName, mediaType, payload.Metadata.RatingKey) + } else if s.config.VerboseLogging { + fmt.Printf("Webhook: ignoring event for unknown library %s (ID: %s)\n", libraryName, libraryID) + } + + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if s.scanner == nil { + http.Error(w, "Scanner unavailable", http.StatusServiceUnavailable) + return + } + + libParam := r.URL.Query().Get("library") + + var ( + targetID string + targetName string + targetMT media.MediaType + ) + if libParam != "" { + id, info, ok := s.findLibrary(libParam) + if !ok { + http.Error(w, "Library not found", http.StatusNotFound) + return + } + targetID, targetName, targetMT = id, info.name, info.mediaType + } + + s.scanMu.Lock() + if s.scanning { + s.scanMu.Unlock() + http.Error(w, "Scan already in progress", http.StatusConflict) + return + } + s.scanning = true + s.scanMu.Unlock() + + scope := "all libraries" + if libParam != "" { + scope = fmt.Sprintf("library %s (ID: %s)", targetName, targetID) + } + fmt.Printf("[INFO] Manual scan triggered via /scan from %s: %s\n", r.RemoteAddr, scope) + + go func() { + defer func() { + s.scanMu.Lock() + s.scanning = false + s.scanMu.Unlock() + fmt.Println("[INFO] Manual scan complete") + }() + if libParam == "" { + s.scanner.RunAll() + return + } + if err := s.scanner.RunLibrary(targetID, targetName, targetMT); err != nil { + fmt.Printf("[ERROR] Manual scan of %s failed: %v\n", targetName, err) + } + }() + + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "scan started: %s\n", scope) +} + +func (s *Server) findLibrary(param string) (string, libraryInfo, bool) { + if info, ok := s.libraryMap[param]; ok { + return param, info, true + } + lower := strings.ToLower(param) + for id, info := range s.libraryMap { + if strings.ToLower(info.name) == lower { + return id, info, true + } + } + return "", libraryInfo{}, false +} + +func (s *Server) resolveMediaType(libraryID, sectionType string) media.MediaType { + switch sectionType { + case "movie": + return media.MediaTypeMovie + case "show": + return media.MediaTypeTV + } + if info, ok := s.libraryMap[libraryID]; ok { + return info.mediaType + } + return media.MediaTypeUnknown +} + +// addPendingItem accumulates rating keys during the debounce window. Each new +// event for the same library resets the timer and adds the rating key to the list. +// When the timer fires, all accumulated keys are processed. +func (s *Server) addPendingItem(libraryID, libraryName string, mediaType media.MediaType, ratingKey string) { + debounce := s.config.WebhookDebounce + + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + + pw, exists := s.pending[libraryID] + if exists { + pw.timer.Stop() + pw.gen++ + if ratingKey != "" { + pw.ratingKeys = appendUnique(pw.ratingKeys, ratingKey) + } + if s.config.VerboseLogging { + fmt.Printf("Webhook: reset debounce for library %s (%d items queued)\n", libraryName, len(pw.ratingKeys)) + } + } else { + var keys []string + if ratingKey != "" { + keys = []string{ratingKey} + } + pw = &pendingWork{ + libraryName: libraryName, + mediaType: mediaType, + ratingKeys: keys, + } + s.pending[libraryID] = pw + fmt.Printf("Webhook: scheduled processing for library %s in %v\n", libraryName, debounce) + } + + gen := pw.gen + pw.timer = time.AfterFunc(debounce, func() { + s.pendingMu.Lock() + current, ok := s.pending[libraryID] + if !ok || current.gen != gen { + s.pendingMu.Unlock() + return + } + keys := current.ratingKeys + delete(s.pending, libraryID) + s.pendingMu.Unlock() + + s.processItems(libraryID, libraryName, mediaType, keys) + }) +} + +func appendUnique(slice []string, val string) []string { + for _, v := range slice { + if v == val { + return slice + } + } + return append(slice, val) +} + +func (s *Server) processItems(libraryID, libraryName string, mediaType media.MediaType, ratingKeys []string) { + if len(ratingKeys) == 0 { + fmt.Printf("Webhook: processing full library %s (no rating keys in events)\n", libraryName) + if err := s.processor.ProcessAllItems(libraryID, libraryName, mediaType); err != nil { + fmt.Printf("Webhook: error processing library %s: %v\n", libraryName, err) + } else { + fmt.Printf("Webhook: finished processing library %s\n", libraryName) + } + return + } + + fmt.Printf("Webhook: processing %d items in library %s\n", len(ratingKeys), libraryName) + for _, key := range ratingKeys { + if err := s.processor.ProcessSingleItem(key, libraryID, mediaType); err != nil { + fmt.Printf("Webhook: error processing item %s: %v\n", key, err) + } + } + fmt.Printf("Webhook: finished processing %d items in library %s\n", len(ratingKeys), libraryName) +}