diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b8454bd --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,121 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for version calculation + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate version and tags + id: meta + run: | + # Convert repository name to lowercase + IMAGE_NAME_LOWER=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + + # Get current date for version generation + DATE=$(date +'%Y%m%d') + + # Generate version based on context + if [[ $GITHUB_REF == refs/tags/v* ]]; then + # If this is a tag push, use the tag as version + VERSION=${GITHUB_REF#refs/tags/v} + TAGS="${IMAGE_NAME_LOWER}:${VERSION},${IMAGE_NAME_LOWER}:latest" + elif [[ $GITHUB_REF == refs/heads/main ]]; then + # If this is main branch, generate auto-incrementing version + # Get count of commits to main for auto-incrementing + COMMIT_COUNT=$(git rev-list --count HEAD) + SHORT_SHA=${GITHUB_SHA::8} + + # Generate semantic version: 1.0.COMMIT_COUNT-DATE + VERSION="1.0.${COMMIT_COUNT}" + TAGS="${IMAGE_NAME_LOWER}:${VERSION},${IMAGE_NAME_LOWER}:latest,${IMAGE_NAME_LOWER}:${DATE}-${SHORT_SHA}" + else + # For pull requests or other branches + SHORT_SHA=${GITHUB_SHA::8} + VERSION="pr-${SHORT_SHA}" + TAGS="${IMAGE_NAME_LOWER}:${VERSION}" + fi + + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "TAGS=${TAGS}" >> $GITHUB_OUTPUT + echo "IMAGE_NAME_LOWER=${IMAGE_NAME_LOWER}" >> $GITHUB_OUTPUT + + # Print for debugging + echo "Generated version: ${VERSION}" + echo "Generated tags: ${TAGS}" + + - name: Extract metadata + id: docker_meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.meta.outputs.IMAGE_NAME_LOWER }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=${{ steps.meta.outputs.VERSION }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Generate summary + if: github.event_name != 'pull_request' + run: | + echo "## 🐳 Docker Image Published" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.meta.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Registry:** ${{ env.REGISTRY }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tags:**" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "${{ steps.docker_meta.outputs.tags }}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Pull command:**" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + echo "docker pull ${{ steps.meta.outputs.IMAGE_NAME_LOWER }}:${{ steps.meta.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6a3e2cc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,155 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + release_type: + description: 'Release type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + push: + branches: + - main + paths-ignore: + - 'README*.md' + - 'LICENSE' + - '.gitignore' + - 'example/**' + +jobs: + check-changes: + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.changes.outputs.should_release }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for significant changes + id: changes + run: | + # Get the last release tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Last tag: $LAST_TAG" + + # Check if there are changes in source code since last release + if git diff --quiet $LAST_TAG HEAD -- '*.go' 'go.mod' 'go.sum' 'Dockerfile' '.github/workflows/' 'internal/' 'cmd/'; then + echo "No significant changes detected" + echo "should_release=false" >> $GITHUB_OUTPUT + else + echo "Significant changes detected" + echo "should_release=true" >> $GITHUB_OUTPUT + fi + + - name: Calculate next version + id: version + if: steps.changes.outputs.should_release == 'true' || github.event_name == 'workflow_dispatch' + run: | + # Get the last release tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Last tag: $LAST_TAG" + + # Remove 'v' prefix and split version + VERSION_NUMBER=${LAST_TAG#v} + IFS='.' read -r -a VERSION_PARTS <<< "$VERSION_NUMBER" + + MAJOR=${VERSION_PARTS[0]:-0} + MINOR=${VERSION_PARTS[1]:-0} + PATCH=${VERSION_PARTS[2]:-0} + + # Determine release type + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + RELEASE_TYPE="${{ github.event.inputs.release_type }}" + else + # Auto-determine based on commit messages since last release + COMMITS=$(git log $LAST_TAG..HEAD --oneline) + if echo "$COMMITS" | grep -qE "(BREAKING CHANGE|!:)"; then + RELEASE_TYPE="major" + elif echo "$COMMITS" | grep -qE "(feat:|feature:)"; then + RELEASE_TYPE="minor" + else + RELEASE_TYPE="patch" + fi + fi + + echo "Release type: $RELEASE_TYPE" + + # Increment version based on release type + case $RELEASE_TYPE in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}" + echo "New version: $NEW_VERSION" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + + create-release: + needs: check-changes + if: needs.check-changes.outputs.should_release == 'true' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + # Get the last release tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [[ -n "$LAST_TAG" ]]; then + echo "## What's Changed" > changelog.md + echo "" >> changelog.md + + # Get commits since last release + git log $LAST_TAG..HEAD --pretty=format:"- %s (%h)" --reverse >> changelog.md + else + echo "## Initial Release" > changelog.md + echo "" >> changelog.md + echo "πŸŽ‰ First release of Labelarr!" >> changelog.md + echo "" >> changelog.md + echo "### Features" >> changelog.md + echo "- 🎬 Movie library processing with TMDb integration" >> changelog.md + echo "- πŸ“Ί TV show library processing with TMDb integration" >> changelog.md + echo "- 🏷️ Smart label/genre management" >> changelog.md + echo "- 🐳 Docker container with multi-architecture support" >> changelog.md + fi + + echo "" >> changelog.md + echo "### Docker Image" >> changelog.md + echo '```bash' >> changelog.md + echo "docker pull ghcr.io/${{ github.repository_owner }}/$(echo '${{ github.repository }}' | cut -d'/' -f2):${{ needs.check-changes.outputs.version }}" >> changelog.md + echo '```' >> changelog.md + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.check-changes.outputs.version }} + name: Release ${{ needs.check-changes.outputs.version }} + body_path: changelog.md + draft: false + prerelease: false + generate_release_notes: true \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 90f61ba..80c0abc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY . . RUN go mod download # Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr main.go +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr ./cmd/labelarr # Runtime stage FROM alpine:latest diff --git a/README.md b/README.md index 06ab0f1..ac88b5b 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,170 @@ -# Labelarr 🎬🏷️ +# Labelarr πŸŽ¬πŸ“ΊπŸ·οΈ -[![Docker Image](https://img.shields.io/docker/v/nullableeth/labelarr?style=flat-square)](https://hub.docker.com/r/nullableeth/labelarr) -[![Docker Pulls](https://img.shields.io/docker/pulls/nullableeth/labelarr?style=flat-square)](https://hub.docker.com/r/nullableeth/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/docker-publish.yml?branch=main&style=flat-square)](https://github.com/nullable-eth/labelarr/actions) -Automatically sync TMDb movie keywords as Plex labels - A lightweight Go application that bridges your Plex movie library with The Movie Database, adding relevant keywords as searchable labels. +**Automatically sync TMDb keywords as Plex labels or genres for both movies and TV shows** +A lightweight Go application that bridges your Plex media libraries with The Movie Database, adding relevant keywords as searchable labels or genres. -## πŸš€ Features +## What it does + +Labelarr continuously monitors your Plex movie and TV show libraries and automatically: + +- πŸ” Detects TMDb IDs from Plex metadata or file paths (e.g. `{tmdb-12345}`, `[tmdb:12345]`, `(tmdb;12345)`, etc.) +- πŸ“₯ Fetches movie and TV show keywords from TMDb API +- 🏷️ Adds keywords as Plex labels or genres (preserves existing values) +- πŸ“Š Tracks processed media to avoid duplicates +- ⏰ Runs on a configurable timer (default: 5 minutes) + +## βœ… Features + +- βœ… **Non-destructive**: Never removes existing labels or genres +- βœ… **Smart detection**: Multiple TMDb ID sources (metadata and file paths) +- βœ… **Progress tracking**: Remembers processed movies to avoid re-processing +- βœ… **Lightweight**: ~10MB Alpine-based container +- βœ… **Secure**: Runs as non-root user +- βœ… **Auto-retry**: Handles API rate limits gracefully +- βœ… **Protocol flexibility**: Supports both HTTP and HTTPS Plex connections +- βœ… **Periodic Processing**: Automatically processes movies and TV shows on a configurable timer +- βœ… **Movie Support**: Full movie library processing with TMDb integration +- βœ… **TV Show Support**: Complete TV show library processing with TMDb integration +- βœ… **Smart Label/Genre Management**: Adds TMDb keywords as Plex labels or genres without removing existing values +- βœ… **Flexible TMDb ID Detection**: Extracts TMDb IDs from Plex metadata or file paths +- βœ… **Docker Ready**: Containerized for easy deployment +- βœ… **Environment Configuration**: Fully configurable via environment variables + +### Examples + +- Allows you to have TMDB keywords as labels in Plex: + + ![1](example/labels.png) -- πŸ”„ **Periodic Processing**: Automatically processes movies on a configurable timer -- 🏷️ **Smart Label Management**: Adds TMDb keywords as Plex labels without removing existing labels -- πŸ” **Flexible TMDb ID Detection**: Extracts TMDb IDs from Plex metadata or file paths -- πŸ“Š **Progress Tracking**: Maintains a dictionary of processed movies to avoid duplicates -- 🐳 **Docker Ready**: Containerized for easy deployment -- βš™οΈ **Environment Configuration**: Fully configurable via environment variables -- πŸ”’ **Protocol Flexibility**: Supports both HTTP and HTTPS Plex connections -- Allows you to have TMBDB keywords as labels in Plex: -![1](example/labels.png) - Create custom dynamic filters for multiple labels that will update automatically when new movies are labeled: -![2](example/dynamic_filter.png) + + ![2](example/dynamic_filter.png) + - Filter on the fly by a label: -![3](example/filter.png) + ![3](example/filter.png) + +## πŸš€ Quick Start + +```bash +docker run -d --name labelarr \ + -e PLEX_SERVER=localhost \ + -e PLEX_PORT=32400 \ + -e PLEX_REQUIRES_HTTPS=true \ + -e PLEX_TOKEN=your_plex_token_here \ + -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ + -e PROCESS_TIMER=1h \ + -e MOVIE_PROCESS_ALL=true \ + -e TV_PROCESS_ALL=true \ + ghcr.io/nullable-eth/labelarr:latest +``` ## πŸ“‹ Environment Variables -| Variable | Description | Default | Required | -|----------|-------------|---------|----------| -| `PLEX_SERVER` | Plex server IP/hostname | - | **Yes** | -| `PLEX_PORT` | Plex server port | - | **Yes** | -| `PLEX_REQUIRES_HTTPS` | Use HTTPS for Plex connection | `true` | No | -| `PLEX_TOKEN` | Plex authentication token | - | **Yes** | -| `TMDB_READ_ACCESS_TOKEN` | TMDb API Bearer token | - | **Yes** | -| `PROCESS_TIMER` | Processing interval (e.g., `5m`, `1h`) | `5m` | No | -| `LIBRARY_ID` | Plex library ID (auto-detected if not set) | - | No | -| `PROCESS_ALL_MOVIE_LIBRARIES` | Process all movie libraries (set to `true` to enable) | `false` | No | -| `UPDATE_FIELD` | Field to update: `labels` (default) or `genre` | `labels` | No | -| `REMOVE` | Remove keywords mode: `lock` or `unlock` (runs once and exits) | - | No | +| Variable | Description | Default | Required | How to Get | +|----------|-------------|---------|----------|------------| +| `PLEX_SERVER` | Plex server IP/hostname | - | **Yes** | Your Plex server address | +| `PLEX_PORT` | Plex server port | - | **Yes** | Usually `32400` | +| `PLEX_REQUIRES_HTTPS` | Use HTTPS for Plex connection | `true` | No | `true`/`false` | +| `PLEX_TOKEN` | Plex authentication token | - | **Yes** | Plex Web App β†’ F12 β†’ Network tab β†’ Look for `X-Plex-Token` in headers | +| `TMDB_READ_ACCESS_TOKEN` | TMDb API Bearer token | - | **Yes** | [TMDb API Settings](https://www.themoviedb.org/settings/api) | +| `PROCESS_TIMER` | Processing interval (e.g., `5m`, `1h`) | `1h` | No | `5m`, `10m`, `1h`, etc. | +| `MOVIE_LIBRARY_ID` | Specific movie library ID to process | - | No | See Finding Library IDs below | +| `TV_LIBRARY_ID` | Specific TV library ID to process | - | No | See Finding Library IDs below | +| `MOVIE_PROCESS_ALL` | Process all movie libraries (set to `true` to enable) | `false` | No | `true`/`false` | +| `TV_PROCESS_ALL` | Process all TV libraries (set to `true` to enable) | `false` | No | `true`/`false` | +| `UPDATE_FIELD` | Field to update: `label` (default) or `genre` | `label` | No | `label` or `genre` | +| `REMOVE` | Remove keywords mode: `lock` or `unlock` (runs once and exits) | - | No | `lock`, `unlock`, or leave empty | + +## πŸ” 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` + +Alternatively, you can use the library processing options: + +- Set `MOVIE_PROCESS_ALL=true` to process all movie libraries +- Set `TV_PROCESS_ALL=true` to process all TV libraries + +## πŸ“š Library Selection Logic + +**⚠️ IMPORTANT CHANGE**: Starting with this version, explicit library configuration is required. The application will **NOT** auto-select libraries by default. + +### Movie Libraries + +- **`MOVIE_LIBRARY_ID=1`**: Process only the specific movie library with ID 1 +- **`MOVIE_PROCESS_ALL=true`**: Process all movie libraries found in Plex +- **Neither set**: Movies are **NOT** processed (no default selection) + +### TV Libraries + +- **`TV_LIBRARY_ID=2`**: Process only the specific TV library with ID 2 +- **`TV_PROCESS_ALL=true`**: Process all TV libraries found in Plex +- **Neither set**: TV shows are **NOT** processed + +### Why This Changed + +Previously, the application would auto-select the first movie library if no movie library ID was specified. With the addition of TV show support, users might want to process only TV shows without movies, or vice versa. The auto-selection behavior would force movie processing even when users only wanted TV shows processed. + +## πŸ”„ Changes from Previous Version + +This version includes significant enhancements while maintaining backward compatibility: + +### ✨ New Features + +- **πŸ“Ί TV Show Support**: Complete TV show library processing with TMDb keyword integration +- **🎯 Explicit Library Selection**: Must specify which libraries to process (no more auto-selection) +- **πŸ”‡ Reduced Verbosity**: Much quieter processing output - only shows new items and errors +- **πŸ“Š Better Progress Tracking**: Enhanced summary reporting for both movies and TV shows + +### πŸ”„ Behavioral Changes + +- **No Default Library Selection**: Application requires explicit configuration of which libraries to process +- **Backward Compatible**: All existing environment variables work the same way +- **Silent Processing**: Items already processed or with existing keywords are handled silently +- **Enhanced Error Handling**: Better error reporting for API issues and processing failures + +### πŸ“‹ Migration Guide + +If you were relying on auto-selection of the first movie library, you now need to explicitly configure which libraries to process: + +- `MOVIE_LIBRARY_ID=` for a specific movie library, or +- `MOVIE_PROCESS_ALL=true` to process all movie libraries +- `TV_LIBRARY_ID=` for a specific TV library, or +- `TV_PROCESS_ALL=true` to process all TV libraries + +**Example using the "process all" approach:** + +```bash +# Old (auto-selected first movie library) +docker run -d --name labelarr \ + -e PLEX_SERVER=localhost \ + -e PLEX_TOKEN=... \ + docker.io/nullableeth/labelarr:latest + +# New (explicit library selection required) +docker run -d --name labelarr \ + -e PLEX_SERVER=localhost \ + -e PLEX_TOKEN=... \ + -e MOVIE_PROCESS_ALL=true \ + -e TV_PROCESS_ALL=true \ + ghcr.io/nullable-eth/labelarr:latest +``` + +Without explicit library configuration, the application will fetch all libraries but process none, essentially doing nothing. ## πŸ†• UPDATE_FIELD: Sync as Labels or Genres You can control whether TMDb keywords are synced as Plex **labels** (default) or **genres** by setting the `UPDATE_FIELD` environment variable: -- `UPDATE_FIELD=labels` (default): Syncs keywords as Plex labels (original behavior) +- `UPDATE_FIELD=label` (default): Syncs keywords as Plex labels (original behavior) - `UPDATE_FIELD=genre`: Syncs keywords as Plex genres The chosen field will be **locked** after update to prevent Plex from overwriting it. @@ -55,8 +177,10 @@ docker run -d --name labelarr \ -e PLEX_PORT=32400 \ -e PLEX_TOKEN=your_plex_token_here \ -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ + -e MOVIE_PROCESS_ALL=true \ + -e TV_PROCESS_ALL=true \ -e UPDATE_FIELD=genre \ - nullableeth/labelarr:latest + ghcr.io/nullable-eth/labelarr:latest ``` #### Example: Genres Updated and Locked in Plex @@ -98,9 +222,11 @@ docker run --rm \ -e PLEX_PORT=32400 \ -e PLEX_TOKEN=your_plex_token_here \ -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e UPDATE_FIELD=labels \ + -e UPDATE_FIELD=label \ + -e MOVIE_PROCESS_ALL=true \ + -e TV_PROCESS_ALL=true \ -e REMOVE=lock \ - nullableeth/labelarr:latest + ghcr.io/nullable-eth/labelarr:latest ``` #### Remove TMDb keywords from genres and unlock for metadata refresh @@ -112,12 +238,104 @@ docker run --rm \ -e PLEX_TOKEN=your_plex_token_here \ -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ -e UPDATE_FIELD=genre \ + -e MOVIE_PROCESS_ALL=true \ + -e TV_PROCESS_ALL=true \ -e REMOVE=unlock \ - nullableeth/labelarr:latest + ghcr.io/nullable-eth/labelarr:latest ``` **Note:** The `--rm` flag automatically removes the container after completion since this is a one-time operation. +## πŸ”’ 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* + ## πŸ”‘ Getting API Keys ### Plex Token @@ -145,10 +363,44 @@ docker run -d --name labelarr \ -e PLEX_REQUIRES_HTTPS=true \ -e PLEX_TOKEN=your_plex_token_here \ -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e PROCESS_TIMER=5m \ - nullableeth/labelarr:latest + -e MOVIE_PROCESS_ALL=true \ + -e TV_PROCESS_ALL=true \ + -e PROCESS_TIMER=1h \ + ghcr.io/nullable-eth/labelarr:latest ``` +### Images Available + +The application is automatically published to GitHub Container Registry (GHCR) with multiple tags: + +- `ghcr.io/nullable-eth/labelarr:latest` - Latest stable release +- `ghcr.io/nullable-eth/labelarr:v1.0.x` - Specific version releases +- `ghcr.io/nullable-eth/labelarr:1.0.x` - Auto-incrementing versions from main branch + +### πŸ€– Automated Publishing + +This project uses GitHub Actions to automatically build and publish Docker images: + +#### **Automatic Releases** + +- πŸ”„ **Auto-versioning**: Semantic versioning based on commit messages + - `feat:` or `feature:` β†’ Minor version bump (v1.1.0) + - `BREAKING CHANGE` or `!:` β†’ Major version bump (v2.0.0) + - Other commits β†’ Patch version bump (v1.0.1) +- πŸ“¦ **Multi-architecture**: Builds for `linux/amd64` and `linux/arm64` +- 🏷️ **Smart tagging**: Creates multiple tags including `latest`, version-specific, and date-based tags +- πŸ“‹ **Release notes**: Automatically generates changelog from commits + +#### **Image Information** + +All published images include: + +- βœ… Security scanning via GitHub's built-in tools +- βœ… Multi-platform support (AMD64 + ARM64) +- βœ… Minimal Alpine Linux base (~10MB) +- βœ… Non-root user execution +- βœ… Build caching for faster builds + ### Docker Compose 1. Download the `docker-compose.yml` file from this repository @@ -159,7 +411,7 @@ version: '3.8' services: labelarr: - image: nullableeth/labelarr:latest + image: ghcr.io/nullable-eth/labelarr:latest container_name: labelarr restart: unless-stopped environment: @@ -168,7 +420,9 @@ services: - PLEX_REQUIRES_HTTPS=true - PLEX_TOKEN=your_plex_token_here - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token - - PROCESS_TIMER=5m + - MOVIE_PROCESS_ALL=true + - TV_PROCESS_ALL=true + - PROCESS_TIMER=1h ``` 3. Run: `docker-compose up -d` @@ -192,7 +446,7 @@ services: retries: 3 start_period: 1m00s labelarr: - image: nullableeth/labelarr:latest + image: ghcr.io/nullable-eth/labelarr:latest container_name: labelarr depends_on: plex: @@ -224,6 +478,8 @@ 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 @@ -241,14 +497,13 @@ CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o labelarr main.go ## πŸ“– How It Works -1. **Library Discovery**: Automatically finds your movie library -2. **Movie Processing**: Iterates through all movies in the library -3. **TMDb ID Extraction**: Gets TMDb IDs from: +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 -4. **Keyword Fetching**: Retrieves keywords from TMDb API -5. **Label Synchronization**: Adds new keywords as labels (preserves existing labels) -6. **Progress Tracking**: Remembers processed movies to avoid re-processing +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 ## πŸ” TMDb ID Detection @@ -306,26 +561,6 @@ docker logs -f labelarr - API error handling and retries - Detailed processing summaries -## βš™οΈ Configuration Examples - -### For HTTP-only Plex servers - -```bash --e PLEX_REQUIRES_HTTPS=false -``` - -### For frequent processing - -```bash --e PROCESS_TIMER=2m -``` - -### For specific library - -```bash --e LIBRARY_ID=1 -``` - ## πŸ”§ Troubleshooting ### Common Issues @@ -350,6 +585,87 @@ docker logs -f labelarr - 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** + +``` +/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. + ## 🀝 Contributing 1. Fork the repository @@ -358,21 +674,19 @@ docker logs -f labelarr 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request -## πŸ“„ License +## πŸ“ž Support -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +- **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` -## πŸ™ Acknowledgments +## πŸ“„ License -- [Plex](https://www.plex.tv/) for the amazing media server -- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the comprehensive movie data -- [Go](https://golang.org/) for the excellent programming language +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -## πŸ“ž Support +--- -- **Issues**: [GitHub Issues](https://github.com/nullable-eth/labelarr/issues) -- **Docker Hub**: [nullableeth/labelarr](https://hub.docker.com/r/nullableeth/labelarr) -- **Documentation**: This README and inline code comments +**Tags**: plex, tmdb, automation, movies, tv shows, labels, genres, docker, go, selfhosted, media management --- diff --git a/README_DOCKER.md b/README_DOCKER.md deleted file mode 100644 index 127a589..0000000 --- a/README_DOCKER.md +++ /dev/null @@ -1,234 +0,0 @@ -# Labelarr 🎬🏷️ - -**Automatically sync TMDb movie keywords as Plex labels** -A lightweight Go application that bridges your Plex movie library with The Movie Database, adding relevant keywords as searchable labels. - -## What it does - -Labelarr continuously monitors your Plex movie library and automatically: - -- πŸ” Detects TMDb IDs from Plex metadata or file paths (e.g. `{tmdb-12345}`, `[tmdb:12345]`, `(tmdb;12345)`, etc.) -- πŸ“₯ Fetches movie keywords from TMDb API -- 🏷️ Adds keywords as Plex labels (preserves existing labels) -- πŸ“Š Tracks processed movies to avoid duplicates -- ⏰ Runs on a configurable timer (default: 5 minutes) - -## Quick Start - -```bash -docker run -d --name labelarr \ - -e PLEX_SERVER=localhost \ - -e PLEX_PORT=32400 \ - -e PLEX_REQUIRES_HTTPS=true \ - -e PLEX_TOKEN=your_plex_token_here \ - -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - nullableeth/labelarr:latest -``` - -## Required Environment Variables - -| Variable | Description | How to Get | -|---------------------------|------------------------------------|----------------------------------------------------------------------------| -| `PLEX_TOKEN` | Plex authentication token | Plex Web App β†’ F12 β†’ Network tab β†’ Look for `X-Plex-Token` in headers | -| `TMDB_READ_ACCESS_TOKEN` | TMDb Read Access API Token | [TMDb API Settings](https://www.themoviedb.org/settings/api) | -| `PLEX_SERVER` | Your Plex server IP/hostname | | -| `PLEX_PORT` | Your Plex server port | | -| `PLEX_REQUIRES_HTTPS` | Use HTTPS for Plex connection | `true`/`false` | -| `PROCESS_TIMER` | How often to scan (e.g., `5m`) | `5m`, `10m`, `1h`, etc. | -| `LIBRARY_ID` | Plex library ID (auto-detected if not set) | See Library Selection Logic below | -| `PROCESS_ALL_MOVIE_LIBRARIES` | Process all movie libraries (set to `true` to enable) | `false` | -| `UPDATE_FIELD` | Field to update: `labels` (default) or `genre` | `labels` | No | -| `REMOVE` | Remove keywords mode: `lock` or `unlock` (runs once and exits) | - | No | - -## Docker Compose Example - -```yaml -version: '3.8' -services: - labelarr: - image: nullableeth/labelarr:latest - container_name: labelarr - restart: unless-stopped - environment: - - PLEX_SERVER=localhost - - PLEX_PORT=32400 - - PLEX_REQUIRES_HTTPS=true - - PLEX_TOKEN=your_plex_token_here - - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token - - PROCESS_TIMER=5m -``` - -## 🐳 Docker Compose: Ensuring Labelarr Waits for Plex - -To prevent Labelarr from logging 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. - -Example: - -```yaml -services: - plex: - image: plexinc/pms-docker:latest - container_name: plex - # ... other config ... - 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: nullableeth/labelarr:latest - container_name: labelarr - depends_on: - plex: - condition: service_healthy - # ... other config ... -``` - -This setup ensures Labelarr only starts after Plex is healthy, avoiding initial connection errors. - -## πŸ†• UPDATE_FIELD: Sync as Labels or Genres - -You can control whether TMDb keywords are synced as Plex **labels** (default) or **genres** by setting the `UPDATE_FIELD` environment variable: - -- `UPDATE_FIELD=labels` (default): Syncs keywords as Plex labels (original behavior) -- `UPDATE_FIELD=genre`: Syncs keywords as Plex genres - -The chosen field will be **locked** after update to prevent Plex from overwriting it. - -### Example Usage - -```bash -docker run -d --name labelarr \ - -e PLEX_SERVER=localhost \ - -e PLEX_PORT=32400 \ - -e PLEX_TOKEN=your_plex_token_here \ - -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e UPDATE_FIELD=genre \ - nullableeth/labelarr:latest -``` - -## πŸ—‘οΈ REMOVE: Clean Up TMDb Keywords - -The `REMOVE` environment variable allows you to remove **only** TMDb keywords from the selected field while preserving all other values (like custom labels for sharing). When `REMOVE` is set, the tool runs once and exits. - -### Remove Options - -- `REMOVE=lock`: Removes TMDb keywords and **locks** the field to prevent Plex from updating it -- `REMOVE=unlock`: Removes TMDb keywords and **unlocks** the field so metadata refresh can set new values - -### When to Use Each Option - -**Use `REMOVE=lock`:** - -- When you want to permanently remove TMDb keywords but keep custom labels/genres -- For users who use labels for sharing or other purposes and don't want Plex to overwrite them -- When you want manual control over the field content - -**Use `REMOVE=unlock`:** - -- When you want to clean up and let Plex refresh metadata naturally -- To reset the field to Plex's default metadata values -- When switching from TMDb keywords back to standard Plex metadata - -### Example Usage - -#### Remove TMDb keywords from labels and lock the field - -```bash -docker run --rm \ - -e PLEX_SERVER=localhost \ - -e PLEX_PORT=32400 \ - -e PLEX_TOKEN=your_plex_token_here \ - -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e UPDATE_FIELD=labels \ - -e REMOVE=lock \ - nullableeth/labelarr:latest -``` - -#### Remove TMDb keywords from genres and unlock for metadata refresh - -```bash -docker run --rm \ - -e PLEX_SERVER=localhost \ - -e PLEX_PORT=32400 \ - -e PLEX_TOKEN=your_plex_token_here \ - -e TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token \ - -e UPDATE_FIELD=genre \ - -e REMOVE=unlock \ - nullableeth/labelarr:latest -``` - -**Note:** The `--rm` flag automatically removes the container after completion since this is a one-time operation. - -## TMDb ID Detection - -Works with multiple sources: - -- **Plex Metadata**: Standard TMDb agent data -- **File Names**: `/Movies/Movie (2023) {tmdb-12345}/movie.mkv`, `/Movies/Movie [tmdb:12345]/movie.mkv` -- **Directory Names**: `/Movies/Movie {tmdb-12345}/`, `/Movies/Movie [tmdb:12345]/` - -Supports various separators and brackets: `{tmdb-12345}`, `[tmdb:12345]`, `(tmdb;12345)`, etc. - -## Features - -βœ… **Non-destructive**: Never removes existing labels -βœ… **Smart detection**: Multiple TMDb ID sources -βœ… **Progress tracking**: Remembers processed movies -βœ… **Lightweight**: ~10MB Alpine-based container -βœ… **Secure**: Runs as non-root user -βœ… **Auto-retry**: Handles API rate limits gracefully -βœ… **Protocol flexibility**: Supports both HTTP and HTTPS Plex connections - -## Getting API Keys - -### Plex Token - -1. Open Plex Web App in browser -2. Press F12 β†’ Network tab -3. Refresh page -4. Find any request with `X-Plex-Token` header -5. Copy the token value - -### TMDb Read Access Token - -1. Visit [TMDb API Settings](https://www.themoviedb.org/settings/api) -2. Create account if needed -3. Generate API key -4. Use the **(Read Access Token)** (not the v3 API key) - -## 🏷️ Library Selection Logic - -- **Default Behavior:** - - If you do **not** specify a `LIBRARY_ID`, the application will automatically select the **first movie library** it finds on your Plex server. -- **Specifying a Library:** - - You can specify a particular movie library by setting the `LIBRARY_ID` environment variable. - - To find your library's ID, open your Plex web app, click on the desired movie library, and look for `source=` in the URL. For example: - - `https://app.plex.tv/desktop/#!/media/xxxx/com.plexapp.plugins.library?source=1` - - Here, the library ID is `1`. -- **Processing All Movie Libraries:** - - If you set `PROCESS_ALL_MOVIE_LIBRARIES=true`, the application will process **all** movie libraries found on your Plex server, regardless of the `LIBRARY_ID` setting. - -## Logs & Monitoring - -View logs: `docker logs labelarr` - -The application provides detailed logging including: - -- Movie processing progress -- TMDb ID detection results -- Label sync status -- API errors and retries - -## 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 - ---- - -**Tags**: plex, tmdb, automation, movies, labels, docker, go, selfhosted - ---- diff --git a/cmd/labelarr/main.go b/cmd/labelarr/main.go new file mode 100644 index 0000000..e0f9ad7 --- /dev/null +++ b/cmd/labelarr/main.go @@ -0,0 +1,226 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/nullable-eth/labelarr/internal/config" + "github.com/nullable-eth/labelarr/internal/media" + "github.com/nullable-eth/labelarr/internal/plex" + "github.com/nullable-eth/labelarr/internal/tmdb" +) + +func main() { + // Load configuration + cfg := config.Load() + + // Validate configuration + if err := cfg.Validate(); err != nil { + fmt.Printf("❌ Configuration error: %v\n", err) + os.Exit(1) + } + + // Initialize clients + plexClient := plex.NewClient(cfg) + tmdbClient := tmdb.NewClient(cfg) + + // Initialize single processor + processor := media.NewProcessor(cfg, plexClient, tmdbClient) + + fmt.Println("🏷️ Starting Labelarr with TMDb Integration...") + fmt.Printf("πŸ“‘ 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...") + libraries, err := plexClient.GetAllLibraries() + if err != nil { + fmt.Printf("❌ Error fetching libraries: %v\n", err) + os.Exit(1) + } + + if len(libraries) == 0 { + fmt.Println("❌ No libraries found!") + os.Exit(1) + } + + fmt.Printf("βœ… Found %d libraries:\n", len(libraries)) + for _, lib := range libraries { + 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 + for _, lib := range libraries { + switch lib.Type { + case "movie": + movieLibraries = append(movieLibraries, lib) + case "show": + tvLibraries = append(tvLibraries, lib) + } + } + + // Validate libraries exist + if len(movieLibraries) == 0 && !cfg.ProcessTVShows() { + fmt.Println("❌ No movie library found!") + os.Exit(1) + } + + if cfg.ProcessTVShows() && len(tvLibraries) == 0 { + fmt.Println("❌ No TV show library found!") + os.Exit(1) + } + + return movieLibraries, tvLibraries +} + +// displayLibrarySelection shows which libraries will be processed +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)) + } 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) + os.Exit(1) + } + } + } + // TV library selection + if cfg.ProcessTVShows() { + if cfg.TVProcessAll { + fmt.Printf("πŸ“Ί 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) + os.Exit(1) + } + } else { + fmt.Printf("\nπŸ“Ί 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) + + 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) + } + } + } + // 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) + } + } + } 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.") +} + +// 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, media.MediaTypeMovie); err != nil { + fmt.Printf("❌ Error processing movies: %v\n", err) + } + } + } else if cfg.MovieLibraryID != "" { + if err := processor.ProcessAllItems(cfg.MovieLibraryID, 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, media.MediaTypeTV); err != nil { + fmt.Printf("❌ Error processing TV shows: %v\n", err) + } + } + } else if cfg.TVLibraryID != "" { + if err := processor.ProcessAllItems(cfg.TVLibraryID, media.MediaTypeTV); err != nil { + fmt.Printf("❌ Error processing TV shows: %v\n", err) + } + } + } + } + + // Process immediately on start + processFunc() + + // 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() + } +} diff --git a/example/docker-compose.yml b/example/docker-compose.yml index 9acd755..3b074bb 100644 --- a/example/docker-compose.yml +++ b/example/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: labelarr: - image: nullableeth/labelarr:latest + image: ghcr.io/nullable-eth/labelarr:latest container_name: labelarr restart: unless-stopped environment: @@ -10,7 +10,8 @@ services: - PLEX_PORT=32400 - PLEX_REQUIRES_HTTPS=true - PLEX_TOKEN=your_plex_token_here - - TMDB_API_KEY=your_tmdb_api_key_here - - PROCESS_TIMER=5m - - PROCESS_ALL_MOVIE_LIBRARIES=true + - TMDB_READ_ACCESS_TOKEN=your_tmdb_read_access_token + - PROCESS_TIMER=1h + - MOVIE_PROCESS_ALL=true + - TV_PROCESS_ALL=true \ No newline at end of file diff --git a/go.mod b/go.mod index 6f33407..a149953 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module plex-labels +module github.com/nullable-eth/labelarr go 1.21 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e902e18 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,116 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// 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 +} + +// 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(), + } + + // Set protocol based on HTTPS requirement + if getBoolEnvWithDefault("PLEX_REQUIRES_HTTPS", false) { + config.Protocol = "https" + } else { + config.Protocol = "http" + } + + return config +} + +// ProcessMovies returns true if movies should be processed +func (c *Config) ProcessMovies() bool { + return c.MovieLibraryID != "" || c.MovieProcessAll +} + +// ProcessTVShows returns true if TV shows should be processed +func (c *Config) ProcessTVShows() bool { + return c.TVLibraryID != "" || c.TVProcessAll +} + +// IsRemoveMode returns true if the application is in remove mode +func (c *Config) IsRemoveMode() bool { + return c.RemoveMode != "" +} + +// Validate validates the configuration +func (c *Config) Validate() error { + if c.PlexToken == "" { + return fmt.Errorf("PLEX_TOKEN environment variable is required") + } + if c.TMDbReadAccessToken == "" { + return fmt.Errorf("TMDB_READ_ACCESS_TOKEN environment variable is required") + } + if c.PlexServer == "" { + return fmt.Errorf("PLEX_SERVER environment variable is required") + } + if c.PlexPort == "" { + return fmt.Errorf("PLEX_PORT environment variable is required") + } + if c.UpdateField != "label" && c.UpdateField != "genre" { + return fmt.Errorf("UPDATE_FIELD must be 'label' or 'genre'") + } + if c.RemoveMode != "" && c.RemoveMode != "lock" && c.RemoveMode != "unlock" { + return fmt.Errorf("REMOVE must be 'lock' or 'unlock'") + } + return nil +} + +func getEnvWithDefault(envVar, defaultValue string) string { + if value := os.Getenv(envVar); value != "" { + return value + } + return defaultValue +} + +func getProcessTimerFromEnv() time.Duration { + timerStr := getEnvWithDefault("PROCESS_TIMER", "1h") + timer, err := time.ParseDuration(timerStr) + if err != nil { + return 5 * time.Minute + } + return timer +} + +func getBoolEnvWithDefault(envVar string, defaultValue bool) bool { + value := os.Getenv(envVar) + if value == "" { + return defaultValue + } + result, err := strconv.ParseBool(value) + if err != nil { + return defaultValue + } + return result +} diff --git a/internal/media/processor.go b/internal/media/processor.go new file mode 100644 index 0000000..3f0da13 --- /dev/null +++ b/internal/media/processor.go @@ -0,0 +1,508 @@ +package media + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/nullable-eth/labelarr/internal/config" + "github.com/nullable-eth/labelarr/internal/plex" + "github.com/nullable-eth/labelarr/internal/tmdb" +) + +// MediaType represents the type of media being processed +type MediaType string + +const ( + MediaTypeMovie MediaType = "movie" + MediaTypeTV MediaType = "tv" +) + +// ProcessedItem tracks processing state for any media item +type ProcessedItem struct { + RatingKey string + Title string + TMDbID string + LastProcessed time.Time + KeywordsSynced bool +} + +// MediaItem interface for common media operations +type MediaItem interface { + GetRatingKey() string + GetTitle() string + GetYear() int + GetGuid() []plex.Guid + GetMedia() []plex.Media + GetLabel() []plex.Label + GetGenre() []plex.Genre +} + +// Processor handles media processing operations for any media type +type Processor struct { + config *config.Config + plexClient *plex.Client + tmdbClient *tmdb.Client + processedItems map[string]*ProcessedItem +} + +// NewProcessor creates a new generic media processor +func NewProcessor(cfg *config.Config, plexClient *plex.Client, tmdbClient *tmdb.Client) *Processor { + return &Processor{ + config: cfg, + plexClient: plexClient, + tmdbClient: tmdbClient, + processedItems: make(map[string]*ProcessedItem), + } +} + +// ProcessAllItems processes all items in the specified library +func (p *Processor) ProcessAllItems(libraryID string, mediaType MediaType) error { + var displayName, emoji string + switch mediaType { + case MediaTypeMovie: + displayName = "movies" + emoji = "🎬" + case MediaTypeTV: + displayName = "tv shows" + emoji = "πŸ“Ί" + default: + return fmt.Errorf("unsupported media type: %s", mediaType) + } + + fmt.Printf("πŸ“‹ Fetching all %s from library...\n", displayName) + + items, err := p.fetchItems(libraryID, mediaType) + if err != nil { + return fmt.Errorf("error fetching %s: %w", displayName, err) + } + + if len(items) == 0 { + fmt.Printf("❌ No %s found in library!\n", displayName) + return nil + } + + totalCount := len(items) + fmt.Printf("βœ… Found %d %s in library\n", totalCount, displayName) + + newItems := 0 + updatedItems := 0 + skippedItems := 0 + skippedAlreadyExist := 0 + + for _, item := range items { + processed, exists := p.processedItems[item.GetRatingKey()] + if exists && processed.KeywordsSynced { + skippedItems++ + skippedAlreadyExist++ + continue + } + + // Silently check if we need to process this item + tmdbID := p.extractTMDbID(item, mediaType) + if tmdbID == "" { + skippedItems++ + continue + } + + // Silently fetch keywords and details to check if processing is needed + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + skippedItems++ + continue + } + + details, err := p.getItemDetails(item.GetRatingKey(), mediaType) + if err != nil { + skippedItems++ + continue + } + + currentValues := p.extractCurrentValues(details) + + currentValuesMap := make(map[string]bool) + for _, val := range currentValues { + currentValuesMap[strings.ToLower(val)] = true + } + + allKeywordsExist := true + for _, keyword := range keywords { + if !currentValuesMap[strings.ToLower(keyword)] { + allKeywordsExist = false + break + } + } + + if allKeywordsExist { + // Silently skip - no verbose output + skippedItems++ + skippedAlreadyExist++ + continue + } + + // 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()) + fmt.Printf("πŸ”‘ TMDb ID: %s (%s)\n", tmdbID, item.GetTitle()) + fmt.Printf("🏷️ 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) + } + skippedItems++ + continue + } + + p.processedItems[item.GetRatingKey()] = &ProcessedItem{ + RatingKey: item.GetRatingKey(), + Title: item.GetTitle(), + TMDbID: tmdbID, + LastProcessed: time.Now(), + KeywordsSynced: true, + } + + if exists { + updatedItems++ + } else { + newItems++ + fmt.Printf("βœ… Successfully processed new %s: %s\n", strings.TrimSuffix(displayName, "s"), item.GetTitle()) + } + + time.Sleep(500 * time.Millisecond) + } + + 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) + if skippedAlreadyExist > 0 { + fmt.Printf(" ✨ Already have all keywords: %d\n", skippedAlreadyExist) + } + + return nil +} + +// RemoveKeywordsFromItems removes TMDb keywords from all items in the specified library +func (p *Processor) RemoveKeywordsFromItems(libraryID string, mediaType MediaType) error { + var displayName, emoji string + switch mediaType { + case MediaTypeMovie: + displayName = "movies" + emoji = "🎬" + case MediaTypeTV: + displayName = "tv shows" + emoji = "πŸ“Ί" + default: + return fmt.Errorf("unsupported media type: %s", mediaType) + } + + fmt.Printf("\nπŸ“‹ Fetching all %s for keyword removal...\n", displayName) + + items, err := p.fetchItems(libraryID, mediaType) + if err != nil { + return fmt.Errorf("error fetching %s: %w", displayName, err) + } + + if len(items) == 0 { + fmt.Printf("❌ No %s found in library!\n", displayName) + return nil + } + + fmt.Printf("βœ… 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 + } + + 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 + } + + currentValues := p.extractCurrentValues(details) + + if len(currentValues) == 0 { + skippedCount++ + continue + } + + keywords, err := p.getKeywords(tmdbID, mediaType) + if err != nil { + keywords = []string{} + } + + 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("πŸ”‘ TMDb ID: %s\n", tmdbID) + fmt.Printf("πŸ—‘οΈ 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 removing keywords from %s: %v\n", item.GetTitle(), err) + skippedCount++ + continue + } + + totalKeywordsRemoved += len(valuesToRemove) + removedCount++ + fmt.Printf("βœ… Successfully removed keywords from %s\n", item.GetTitle()) + + time.Sleep(500 * time.Millisecond) + } + + 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) + + return nil +} + +// fetchItems gets all items from the library based on media type +func (p *Processor) fetchItems(libraryID string, mediaType MediaType) ([]MediaItem, error) { + switch mediaType { + case MediaTypeMovie: + movies, err := p.plexClient.GetMoviesFromLibrary(libraryID) + if err != nil { + return nil, err + } + items := make([]MediaItem, len(movies)) + for i, movie := range movies { + items[i] = movie + } + return items, nil + + case MediaTypeTV: + tvShows, err := p.plexClient.GetTVShowsFromLibrary(libraryID) + if err != nil { + return nil, err + } + items := make([]MediaItem, len(tvShows)) + for i, tvShow := range tvShows { + items[i] = tvShow + } + return items, nil + + default: + return nil, fmt.Errorf("unsupported media type: %s", mediaType) + } +} + +// getItemDetails gets detailed information for an item based on media type +func (p *Processor) getItemDetails(ratingKey string, mediaType MediaType) (MediaItem, error) { + switch mediaType { + case MediaTypeMovie: + movie, err := p.plexClient.GetMovieDetails(ratingKey) + if err != nil { + return nil, err + } + return *movie, nil + + case MediaTypeTV: + tvShow, err := p.plexClient.GetTVShowDetails(ratingKey) + if err != nil { + return nil, err + } + return *tvShow, nil + + default: + return nil, fmt.Errorf("unsupported media type: %s", mediaType) + } +} + +// getKeywords gets keywords from TMDb based on media type +func (p *Processor) getKeywords(tmdbID string, mediaType MediaType) ([]string, error) { + switch mediaType { + case MediaTypeMovie: + return p.tmdbClient.GetMovieKeywords(tmdbID) + case MediaTypeTV: + return p.tmdbClient.GetTVShowKeywords(tmdbID) + default: + return nil, fmt.Errorf("unsupported media type: %s", mediaType) + } +} + +// syncFieldWithKeywords synchronizes the configured field with TMDb keywords +func (p *Processor) syncFieldWithKeywords(itemID, libraryID string, currentValues []string, keywords []string, mediaType MediaType) error { + mergedValues := append(currentValues, keywords...) + + // Remove duplicates while preserving order + seen := make(map[string]bool) + var uniqueValues []string + for _, value := range mergedValues { + lowerValue := strings.ToLower(value) + if !seen[lowerValue] { + seen[lowerValue] = true + uniqueValues = append(uniqueValues, value) + } + } + + return p.updateItemField(itemID, libraryID, uniqueValues, mediaType) +} + +// toPlexMediaType converts MediaType to the string format expected by plex client +func (p *Processor) toPlexMediaType(mediaType MediaType) (string, error) { + switch mediaType { + case MediaTypeMovie: + return "movie", nil + case MediaTypeTV: + return "show", nil + default: + return "", fmt.Errorf("unsupported media type: %s", mediaType) + } +} + +// updateItemField updates the configured field based on media type +func (p *Processor) updateItemField(itemID, libraryID string, keywords []string, mediaType MediaType) error { + plexMediaType, err := p.toPlexMediaType(mediaType) + if err != nil { + return err + } + + return p.plexClient.UpdateMediaField(itemID, libraryID, keywords, p.config.UpdateField, plexMediaType) +} + +// removeItemFieldKeywords removes specific keywords from the configured field based on media type +func (p *Processor) removeItemFieldKeywords(itemID, libraryID string, valuesToRemove []string, lockField bool, mediaType MediaType) error { + plexMediaType, err := p.toPlexMediaType(mediaType) + if err != nil { + return err + } + + return p.plexClient.RemoveMediaFieldKeywords(itemID, libraryID, valuesToRemove, p.config.UpdateField, lockField, plexMediaType) +} + +// extractCurrentValues extracts current values from the configured field +func (p *Processor) extractCurrentValues(item MediaItem) []string { + switch strings.ToLower(p.config.UpdateField) { + case "label": + labels := item.GetLabel() + values := make([]string, len(labels)) + for i, label := range labels { + values[i] = label.Tag + } + return values + case "genre": + genres := item.GetGenre() + values := make([]string, len(genres)) + for i, genre := range genres { + values[i] = genre.Tag + } + return values + default: + return []string{} + } +} + +// extractTMDbID extracts TMDb ID using the appropriate strategy for each media type +func (p *Processor) extractTMDbID(item MediaItem, mediaType MediaType) string { + switch mediaType { + case MediaTypeMovie: + return p.extractMovieTMDbID(item) + case MediaTypeTV: + return p.extractTVShowTMDbID(item) + default: + return "" + } +} + +// extractMovieTMDbID extracts TMDb ID from movie metadata or file paths +func (p *Processor) extractMovieTMDbID(item MediaItem) string { + // First, try to get TMDb ID from 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] + return tmdbID + } + } + } + + // If not found in metadata, try to extract from file paths + for _, mediaItem := range item.GetMedia() { + for _, part := range mediaItem.Part { + if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { + return tmdbID + } + } + } + + return "" +} + +// extractTVShowTMDbID extracts TMDb ID from TV show metadata or episode file paths +func (p *Processor) extractTVShowTMDbID(item MediaItem) string { + // First check if we have TMDb GUID in the TV show metadata + for _, guid := range item.GetGuid() { + if strings.HasPrefix(guid.ID, "tmdb://") { + return strings.TrimPrefix(guid.ID, "tmdb://") + } + } + + // If no TMDb GUID found, get episodes and check their file paths + episodes, err := p.plexClient.GetTVShowEpisodes(item.GetRatingKey()) + if err != nil { + fmt.Printf("⚠️ Error fetching episodes for %s: %v\n", item.GetTitle(), err) + return "" + } + + // Check file paths in episodes for TMDb ID - stop at first match + for _, episode := range episodes { + for _, mediaItem := range episode.Media { + for _, part := range mediaItem.Part { + if tmdbID := ExtractTMDbIDFromPath(part.File); tmdbID != "" { + return tmdbID + } + } + } + } + + return "" +} + +// ExtractTMDbIDFromPath extracts TMDb ID from file path using regex +func ExtractTMDbIDFromPath(filePath string) string { + // Updated regex pattern to match {tmdb-123456} anywhere in the path + re := regexp.MustCompile(`\{tmdb-(\d+)\}`) + matches := re.FindStringSubmatch(filePath) + if len(matches) > 1 { + return matches[1] + } + return "" +} diff --git a/internal/plex/client.go b/internal/plex/client.go new file mode 100644 index 0000000..b60f7a3 --- /dev/null +++ b/internal/plex/client.go @@ -0,0 +1,376 @@ +package plex + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/nullable-eth/labelarr/internal/config" +) + +// Client represents a Plex API client +type Client struct { + config *config.Config + httpClient *http.Client +} + +// NewClient creates a new Plex client +func NewClient(cfg *config.Config) *Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + + return &Client{ + config: cfg, + httpClient: &http.Client{Transport: tr}, + } +} + +// GetAllLibraries fetches all libraries from Plex +func (c *Client) GetAllLibraries() ([]Library, error) { + librariesURL := c.buildURL(fmt.Sprintf("/library/sections?X-Plex-Token=%s", c.config.PlexToken)) + + req, err := http.NewRequest("GET", librariesURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-Plex-Token", c.config.PlexToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch libraries: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("plex API returned status %d. Response: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var libraryResponse LibraryResponse + if err := json.Unmarshal(body, &libraryResponse); err != nil { + return nil, fmt.Errorf("failed to parse library response: %w. Response body: %s", err, string(body)) + } + + return libraryResponse.MediaContainer.Directory, nil +} + +// GetMoviesFromLibrary fetches all movies from a specific library +func (c *Client) GetMoviesFromLibrary(libraryID string) ([]Movie, error) { + moviesURL := c.buildURL(fmt.Sprintf("/library/sections/%s/all", libraryID)) + + req, err := http.NewRequest("GET", moviesURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-Plex-Token", c.config.PlexToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch movies: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("plex API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var plexResponse PlexResponse + if err := json.Unmarshal(body, &plexResponse); err != nil { + return nil, fmt.Errorf("failed to parse movies response: %w", err) + } + + return plexResponse.MediaContainer.Metadata, nil +} + +// GetMovieDetails fetches detailed information for a specific movie +func (c *Client) GetMovieDetails(ratingKey string) (*Movie, error) { + movieURL := c.buildURL(fmt.Sprintf("/library/metadata/%s", ratingKey)) + + req, err := http.NewRequest("GET", movieURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-Plex-Token", c.config.PlexToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch movie details: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("plex API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var plexResponse PlexResponse + if err := json.Unmarshal(body, &plexResponse); err != nil { + return nil, fmt.Errorf("failed to parse movie details: %w", err) + } + + if len(plexResponse.MediaContainer.Metadata) == 0 { + return nil, fmt.Errorf("no movie found with rating key %s", ratingKey) + } + + return &plexResponse.MediaContainer.Metadata[0], nil +} + +// 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 { + return c.updateMediaField(mediaID, libraryID, keywords, updateField, c.getMediaTypeForLibraryType(mediaType)) +} + +// RemoveMediaFieldKeywords removes keywords from a media item's field +func (c *Client) RemoveMediaFieldKeywords(mediaID, libraryID string, valuesToRemove []string, updateField string, lockField bool, mediaType string) error { + return c.removeMediaFieldKeywords(mediaID, libraryID, valuesToRemove, updateField, lockField, c.getMediaTypeForLibraryType(mediaType)) +} + +// GetTVShowsFromLibrary fetches all TV shows from a specific library +func (c *Client) GetTVShowsFromLibrary(libraryID string) ([]TVShow, error) { + tvShowsURL := c.buildURL(fmt.Sprintf("/library/sections/%s/all", libraryID)) + + req, err := http.NewRequest("GET", tvShowsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-Plex-Token", c.config.PlexToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch TV shows: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("plex API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var tvShowResponse TVShowResponse + if err := json.Unmarshal(body, &tvShowResponse); err != nil { + return nil, fmt.Errorf("failed to parse TV shows response: %w", err) + } + + return tvShowResponse.MediaContainer.Metadata, nil +} + +// GetTVShowDetails fetches detailed information for a specific TV show +func (c *Client) GetTVShowDetails(ratingKey string) (*TVShow, error) { + tvShowURL := c.buildURL(fmt.Sprintf("/library/metadata/%s", ratingKey)) + + req, err := http.NewRequest("GET", tvShowURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-Plex-Token", c.config.PlexToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch TV show details: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("plex API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var tvShowResponse TVShowResponse + if err := json.Unmarshal(body, &tvShowResponse); err != nil { + return nil, fmt.Errorf("failed to parse TV show details: %w", err) + } + + if len(tvShowResponse.MediaContainer.Metadata) == 0 { + return nil, fmt.Errorf("no TV show found with rating key %s", ratingKey) + } + + return &tvShowResponse.MediaContainer.Metadata[0], nil +} + +// GetTVShowEpisodes fetches episodes for a specific TV show (limited for TMDb ID extraction) +func (c *Client) GetTVShowEpisodes(ratingKey string) ([]Episode, error) { + episodesURL := c.buildURL(fmt.Sprintf("/library/metadata/%s/allLeaves?X-Plex-Container-Start=0&X-Plex-Container-Size=10", ratingKey)) + + req, err := http.NewRequest("GET", episodesURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-Plex-Token", c.config.PlexToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch TV show episodes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("plex API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var episodeResponse EpisodeResponse + if err := json.Unmarshal(body, &episodeResponse); err != nil { + return nil, fmt.Errorf("failed to parse episodes response: %w", err) + } + + return episodeResponse.MediaContainer.Metadata, nil +} + +// updateMediaField is a generic function to update media fields (movies: type=1, TV shows: type=2) +func (c *Client) updateMediaField(mediaID, libraryID string, keywords []string, updateField string, mediaType int) error { + // Build the base URL + baseURL := c.buildURL(fmt.Sprintf("/library/sections/%s/all", libraryID)) + + // 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) + } + + // Create query parameters + params := parsedURL.Query() + params.Set("type", fmt.Sprintf("%d", mediaType)) + params.Set("id", mediaID) + params.Set("includeExternalMedia", "1") + + // Add indexed label/genre parameters like label[0].tag.tag, label[1].tag.tag, etc. + for i, keyword := range keywords { + paramName := fmt.Sprintf("%s[%d].tag.tag", updateField, i) + params.Set(paramName, keyword) + } + + params.Set(fmt.Sprintf("%s.locked", updateField), "1") + + // Add the Plex token + params.Set("X-Plex-Token", c.config.PlexToken) + + // Set the query parameters back to the URL + parsedURL.RawQuery = params.Encode() + + req, err := http.NewRequest("PUT", parsedURL.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to update media field: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("plex API returned status %d when updating media field - Response: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// removeMediaFieldKeywords is a generic function to remove keywords from media fields (movies: type=1, TV shows: type=2) +func (c *Client) removeMediaFieldKeywords(mediaID, libraryID string, valuesToRemove []string, updateField string, lockField bool, mediaType int) error { + // Build the base URL + baseURL := c.buildURL(fmt.Sprintf("/library/sections/%s/all", libraryID)) + + // 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) + } + + // Create query parameters + params := parsedURL.Query() + params.Set("type", fmt.Sprintf("%d", mediaType)) + params.Set("id", mediaID) + params.Set("includeExternalMedia", "1") + + // Join values with commas for the -= operator + combinedValues := strings.Join(valuesToRemove, ",") + + // Add removal parameter using the -= operator + paramName := fmt.Sprintf("%s[].tag.tag-", updateField) + params.Set(paramName, combinedValues) + + if lockField { + params.Set(fmt.Sprintf("%s.locked", updateField), "1") + } else { + params.Set(fmt.Sprintf("%s.locked", updateField), "0") + } + // Add the Plex token + params.Set("X-Plex-Token", c.config.PlexToken) + + // Set the query parameters back to the URL + parsedURL.RawQuery = params.Encode() + + req, err := http.NewRequest("PUT", parsedURL.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to remove media field keywords: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("plex API returned status %d when removing media field keywords - Response: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// getMediaTypeForLibraryType converts library type strings to Plex API media type integers +func (c *Client) getMediaTypeForLibraryType(libraryType string) int { + switch libraryType { + case "movie": + return 1 + case "show": + return 2 + default: + // Default to 1 for unknown types (could log a warning here) + return 1 + } +} + +// buildURL constructs a full URL for Plex API requests +func (c *Client) buildURL(path string) string { + return fmt.Sprintf("%s://%s:%s%s", c.config.Protocol, c.config.PlexServer, c.config.PlexPort, path) +} diff --git a/internal/plex/types.go b/internal/plex/types.go new file mode 100644 index 0000000..c6188ea --- /dev/null +++ b/internal/plex/types.go @@ -0,0 +1,158 @@ +package plex + +import ( + "encoding/json" + "fmt" +) + +// Library represents a Plex library +type Library struct { + Key string `json:"key"` + Type string `json:"type"` + Title string `json:"title"` + Agent string `json:"agent"` +} + +// LibraryContainer holds library directory information +type LibraryContainer struct { + Size int `json:"size"` + Directory []Library `json:"Directory"` +} + +// LibraryResponse represents the response from library endpoints +type LibraryResponse struct { + MediaContainer LibraryContainer `json:"MediaContainer"` +} + +// Movie represents a Plex movie +type Movie struct { + RatingKey string `json:"ratingKey"` + Title string `json:"title"` + Year int `json:"year"` + Label []Label `json:"Label,omitempty"` + Genre []Genre `json:"Genre,omitempty"` + Guid FlexibleGuid `json:"Guid,omitempty"` + Media []Media `json:"Media,omitempty"` +} + +// MediaItem interface implementation for Movie +func (m Movie) GetRatingKey() string { return m.RatingKey } +func (m Movie) GetTitle() string { return m.Title } +func (m Movie) GetYear() int { return m.Year } +func (m Movie) GetGuid() []Guid { return []Guid(m.Guid) } +func (m Movie) GetMedia() []Media { return m.Media } +func (m Movie) GetLabel() []Label { return m.Label } +func (m Movie) GetGenre() []Genre { return m.Genre } + +// TVShow represents a Plex TV show +type TVShow struct { + RatingKey string `json:"ratingKey"` + Title string `json:"title"` + Year int `json:"year"` + Label []Label `json:"Label,omitempty"` + Genre []Genre `json:"Genre,omitempty"` + Guid FlexibleGuid `json:"Guid,omitempty"` + Media []Media `json:"Media,omitempty"` +} + +// MediaItem interface implementation for TVShow +func (t TVShow) GetRatingKey() string { return t.RatingKey } +func (t TVShow) GetTitle() string { return t.Title } +func (t TVShow) GetYear() int { return t.Year } +func (t TVShow) GetGuid() []Guid { return []Guid(t.Guid) } +func (t TVShow) GetMedia() []Media { return t.Media } +func (t TVShow) GetLabel() []Label { return t.Label } +func (t TVShow) GetGenre() []Genre { return t.Genre } + +// Label represents a Plex label +type Label struct { + Tag string `json:"tag"` +} + +// Genre represents a Plex genre +type Genre struct { + Tag string `json:"tag"` +} + +// Guid represents a Plex GUID +type Guid struct { + ID string `json:"id"` +} + +// Media represents Plex media information +type Media struct { + Part []Part `json:"Part,omitempty"` +} + +// Part represents a media part with file information +type Part struct { + File string `json:"file,omitempty"` +} + +// FlexibleGuid handles both string and array formats from Plex API +type FlexibleGuid []Guid + +func (fg *FlexibleGuid) UnmarshalJSON(data []byte) error { + // Try to unmarshal as array first + var guidArray []Guid + if err := json.Unmarshal(data, &guidArray); err == nil { + *fg = FlexibleGuid(guidArray) + return nil + } + + // If that fails, try as single string + var guidString string + if err := json.Unmarshal(data, &guidString); err == nil { + *fg = FlexibleGuid([]Guid{{ID: guidString}}) + return nil + } + + // If both fail, try as single Guid object + var singleGuid Guid + if err := json.Unmarshal(data, &singleGuid); err == nil { + *fg = FlexibleGuid([]Guid{singleGuid}) + return nil + } + + return fmt.Errorf("cannot unmarshal Guid field") +} + +// MediaContainer holds metadata for movies or TV shows +type MediaContainer struct { + Size int `json:"size"` + Metadata []Movie `json:"Metadata"` +} + +// TVShowContainer holds metadata for TV shows +type TVShowContainer struct { + Size int `json:"size"` + Metadata []TVShow `json:"Metadata"` +} + +// PlexResponse represents a standard Plex API response for movies +type PlexResponse struct { + MediaContainer MediaContainer `json:"MediaContainer"` +} + +// TVShowResponse represents a Plex API response for TV shows +type TVShowResponse struct { + MediaContainer TVShowContainer `json:"MediaContainer"` +} + +// Episode represents a Plex TV show episode +type Episode struct { + RatingKey string `json:"ratingKey"` + Title string `json:"title"` + Media []Media `json:"Media,omitempty"` +} + +// EpisodeContainer holds metadata for episodes +type EpisodeContainer struct { + Size int `json:"size"` + Metadata []Episode `json:"Metadata"` +} + +// EpisodeResponse represents a Plex API response for episodes +type EpisodeResponse struct { + MediaContainer EpisodeContainer `json:"MediaContainer"` +} diff --git a/internal/tmdb/client.go b/internal/tmdb/client.go new file mode 100644 index 0000000..8085526 --- /dev/null +++ b/internal/tmdb/client.go @@ -0,0 +1,115 @@ +package tmdb + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/nullable-eth/labelarr/internal/config" +) + +// Client represents a TMDb API client +type Client struct { + config *config.Config + httpClient *http.Client +} + +// NewClient creates a new TMDb client +func NewClient(cfg *config.Config) *Client { + return &Client{ + config: cfg, + httpClient: &http.Client{}, + } +} + +// GetMovieKeywords fetches keywords for a movie from TMDb +func (c *Client) GetMovieKeywords(tmdbID string) ([]string, error) { + keywordsURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%s/keywords", tmdbID) + + req, err := http.NewRequest("GET", keywordsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.TMDbReadAccessToken)) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch movie keywords: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + time.Sleep(1 * time.Second) + return c.GetMovieKeywords(tmdbID) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tmdb API returned status %d for movie %s", resp.StatusCode, tmdbID) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var keywordsResponse KeywordsResponse + if err := json.Unmarshal(body, &keywordsResponse); err != nil { + return nil, fmt.Errorf("failed to parse keywords response: %w", err) + } + + keywords := make([]string, len(keywordsResponse.Keywords)) + for i, keyword := range keywordsResponse.Keywords { + keywords[i] = keyword.Name + } + + return keywords, nil +} + +// GetTVShowKeywords fetches keywords for a TV show from TMDb +func (c *Client) GetTVShowKeywords(tmdbID string) ([]string, error) { + keywordsURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%s/keywords", tmdbID) + + req, err := http.NewRequest("GET", keywordsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.TMDbReadAccessToken)) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch TV show keywords: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + time.Sleep(1 * time.Second) + return c.GetTVShowKeywords(tmdbID) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tmdb API returned status %d for TV show %s", resp.StatusCode, tmdbID) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var tvKeywordsResponse TVKeywordsResponse + if err := json.Unmarshal(body, &tvKeywordsResponse); err != nil { + return nil, fmt.Errorf("failed to parse TV keywords response: %w", err) + } + + keywords := make([]string, len(tvKeywordsResponse.Results)) + for i, keyword := range tvKeywordsResponse.Results { + keywords[i] = keyword.Name + } + + return keywords, nil +} diff --git a/internal/tmdb/types.go b/internal/tmdb/types.go new file mode 100644 index 0000000..d241ffe --- /dev/null +++ b/internal/tmdb/types.go @@ -0,0 +1,26 @@ +package tmdb + +// Movie represents a TMDb movie +type Movie struct { + ID int `json:"id"` + Title string `json:"title"` + Overview string `json:"overview"` +} + +// Keyword represents a TMDb keyword +type Keyword struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// KeywordsResponse represents the response from TMDb movie keywords endpoint +type KeywordsResponse struct { + ID int `json:"id"` + Keywords []Keyword `json:"keywords"` +} + +// TVKeywordsResponse represents the response from TMDb TV keywords endpoint +type TVKeywordsResponse struct { + ID int `json:"id"` + Results []Keyword `json:"results"` +} diff --git a/main.go b/main.go deleted file mode 100644 index 3aacadd..0000000 --- a/main.go +++ /dev/null @@ -1,955 +0,0 @@ -package main - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "regexp" - "strings" - "time" -) - -// Configuration struct -type Config struct { - PlexServer string - PlexPort string - PlexToken string - LibraryID string - TMDbReadAccessToken string - ProcessTimer time.Duration - PlexRequiresHTTPS bool -} - -// Plex API response structures -type MediaContainer struct { - Size int `json:"size"` - Metadata []Movie `json:"Metadata"` -} - -type Movie struct { - RatingKey string `json:"ratingKey"` - Title string `json:"title"` - Year int `json:"year"` - Label []Label `json:"Label,omitempty"` - Genre []Genre `json:"Genre,omitempty"` - Guid FlexibleGuid `json:"Guid,omitempty"` - Media []Media `json:"Media,omitempty"` -} - -type Label struct { - Tag string `json:"tag"` -} - -type Genre struct { - Tag string `json:"tag"` -} - -type Guid struct { - ID string `json:"id"` -} - -type Media struct { - Part []Part `json:"Part,omitempty"` -} - -type Part struct { - File string `json:"file,omitempty"` -} - -// FlexibleGuid handles both string and array formats from Plex API -type FlexibleGuid []Guid - -func (fg *FlexibleGuid) UnmarshalJSON(data []byte) error { - // Try to unmarshal as array first - var guidArray []Guid - if err := json.Unmarshal(data, &guidArray); err == nil { - *fg = FlexibleGuid(guidArray) - return nil - } - - // If that fails, try as single string - var guidString string - if err := json.Unmarshal(data, &guidString); err == nil { - *fg = FlexibleGuid([]Guid{{ID: guidString}}) - return nil - } - - // If both fail, try as single Guid object - var singleGuid Guid - if err := json.Unmarshal(data, &singleGuid); err == nil { - *fg = FlexibleGuid([]Guid{singleGuid}) - return nil - } - - return fmt.Errorf("cannot unmarshal Guid field") -} - -type PlexResponse struct { - MediaContainer MediaContainer `json:"MediaContainer"` -} - -// Library structures for getting all libraries -type LibraryContainer struct { - Size int `json:"size"` - Directory []Library `json:"Directory"` -} - -type Library struct { - Key string `json:"key"` - Type string `json:"type"` - Title string `json:"title"` - Agent string `json:"agent"` -} - -type LibraryResponse struct { - MediaContainer LibraryContainer `json:"MediaContainer"` -} - -// TMDb API structures -type TMDbMovie struct { - ID int `json:"id"` - Title string `json:"title"` - Overview string `json:"overview"` -} - -type TMDbKeyword struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type TMDbKeywordsResponse struct { - ID int `json:"id"` - Keywords []TMDbKeyword `json:"keywords"` -} - -// Processing state -type ProcessedMovie struct { - RatingKey string - Title string - TMDbID string - LastProcessed time.Time - KeywordsSynced bool -} - -var processedMovies = make(map[string]*ProcessedMovie) -var totalMovieCount int - -func main() { - // Configuration from environment variables - config := Config{ - PlexServer: os.Getenv("PLEX_SERVER"), - PlexPort: os.Getenv("PLEX_PORT"), - PlexToken: os.Getenv("PLEX_TOKEN"), - LibraryID: os.Getenv("LIBRARY_ID"), // Will be auto-detected - TMDbReadAccessToken: os.Getenv("TMDB_READ_ACCESS_TOKEN"), - ProcessTimer: getProcessTimerFromEnv(), - PlexRequiresHTTPS: getBoolEnvWithDefault("PLEX_REQUIRES_HTTPS", true), - } - - processAllMovieLibraries := getBoolEnvWithDefault("PROCESS_ALL_MOVIE_LIBRARIES", false) - - // Add UPDATE_FIELD env variable - updateField := getEnvWithDefault("UPDATE_FIELD", "labels") - if updateField != "labels" && updateField != "genre" { - fmt.Println("❌ UPDATE_FIELD must be 'labels' or 'genre'") - os.Exit(1) - } - - // Add REMOVE env variable - removeMode := os.Getenv("REMOVE") - if removeMode != "" && removeMode != "lock" && removeMode != "unlock" { - fmt.Println("❌ REMOVE must be 'lock' or 'unlock'") - os.Exit(1) - } - isRemoveMode := removeMode != "" - - if config.PlexToken == "" { - fmt.Println("❌ Please set PLEX_TOKEN environment variable") - os.Exit(1) - } - - if config.TMDbReadAccessToken == "" { - fmt.Println("❌ Please set TMDB_READ_ACCESS_TOKEN environment variable") - os.Exit(1) - } - - protocol := "https" - if !config.PlexRequiresHTTPS { - protocol = "http" - } - - if isRemoveMode { - fmt.Printf("πŸ—‘οΈ Starting Labelarr in REMOVE mode (field: %s, lock: %s)...\n", updateField, removeMode) - } else { - fmt.Println("🏷️ Starting Labelarr with TMDb Integration...") - } - fmt.Printf("πŸ“‘ Server: %s://%s:%s\n", protocol, config.PlexServer, config.PlexPort) - if !isRemoveMode { - fmt.Printf("⏱️ Processing interval: %v\n", config.ProcessTimer) - } - - // Step 1: Get all libraries first - fmt.Println("\nπŸ“š Step 1: Fetching all libraries...") - libraries, err := getAllLibraries(config) - if err != nil { - fmt.Printf("❌ Error fetching libraries: %v\n", err) - os.Exit(1) - } - - if len(libraries) == 0 { - fmt.Println("❌ No libraries found!") - os.Exit(1) - } - - fmt.Printf("βœ… Found %d libraries:\n", len(libraries)) - for _, lib := range libraries { - fmt.Printf(" πŸ“ ID: %s - %s (%s)\n", lib.Key, lib.Title, lib.Type) - } - - var movieLibraries []Library - for _, lib := range libraries { - if lib.Type == "movie" { - movieLibraries = append(movieLibraries, lib) - } - } - - if len(movieLibraries) == 0 { - fmt.Println("❌ No movie library found!") - os.Exit(1) - } - - if processAllMovieLibraries { - fmt.Printf("\n🎯 Processing all %d movie libraries\n", len(movieLibraries)) - } else { - fmt.Printf("\n🎯 Using Movies library: %s (ID: %s)\n", movieLibraries[0].Title, movieLibraries[0].Key) - } - - // Handle REMOVE mode - run once and exit - if isRemoveMode { - fmt.Printf("\nπŸ—‘οΈ Starting keyword removal from %s...\n", updateField) - - if processAllMovieLibraries { - for _, lib := range movieLibraries { - fmt.Printf("\n==============================\n") - fmt.Printf("🎬 Processing library: %s (ID: %s)\n", lib.Title, lib.Key) - libConfig := config - libConfig.LibraryID = lib.Key - removeKeywordsFromMovies(libConfig, updateField, removeMode) - } - } else { - config.LibraryID = movieLibraries[0].Key - removeKeywordsFromMovies(config, updateField, removeMode) - } - - fmt.Println("\nβœ… Keyword removal completed. Exiting.") - os.Exit(0) - } - - // Start the periodic processing - fmt.Println("\nπŸ”„ Starting periodic movie processing...") - - processFunc := func() { - if processAllMovieLibraries { - for _, lib := range movieLibraries { - fmt.Printf("\n==============================\n") - fmt.Printf("🎬 Processing library: %s (ID: %s)\n", lib.Title, lib.Key) - libConfig := config - libConfig.LibraryID = lib.Key - processAllMovies(libConfig, updateField) - } - } else { - config.LibraryID = movieLibraries[0].Key - processAllMovies(config, updateField) - } - } - - // Process immediately on start - processFunc() - - // Set up timer for periodic processing - ticker := time.NewTicker(config.ProcessTimer) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - fmt.Printf("\n⏰ Timer triggered - processing movies at %s\n", time.Now().Format("15:04:05")) - processFunc() - } - } -} - -func processAllMovies(config Config, updateField string) { - fmt.Println("\nπŸ“‹ Fetching all movies from library...") - movies, err := getMoviesFromLibrary(config) - if err != nil { - fmt.Printf("❌ Error fetching movies: %v\n", err) - return - } - - if len(movies) == 0 { - fmt.Println("❌ No movies found in library!") - return - } - - totalMovieCount = len(movies) - fmt.Printf("βœ… Found %d movies in library\n", totalMovieCount) - - newMovies := 0 - updatedMovies := 0 - skippedMovies := 0 - - for i, movie := range movies { - processed, exists := processedMovies[movie.RatingKey] - if exists && processed.KeywordsSynced { - skippedMovies++ - continue - } - - tmdbID := extractTMDbID(movie) - if tmdbID == "" { - fmt.Printf("⚠️ No TMDb ID found for movie: %s\n", movie.Title) - continue - } - - keywords, err := getTMDbKeywords(config, tmdbID) - if err != nil { - fmt.Printf("❌ Error fetching TMDb keywords for %s: %v\n", movie.Title, err) - continue - } - - movieDetails, err := getMovieDetails(config, movie.RatingKey) - if err != nil { - fmt.Printf("❌ Error fetching movie details for %s: %v\n", movie.Title, err) - continue - } - - var currentValues []string - if updateField == "labels" { - currentValues = make([]string, len(movieDetails.Label)) - for j, label := range movieDetails.Label { - currentValues[j] = label.Tag - } - } else { - currentValues = make([]string, len(movieDetails.Genre)) - for j, genre := range movieDetails.Genre { - currentValues[j] = genre.Tag - } - } - - currentValuesMap := make(map[string]bool) - for _, val := range currentValues { - currentValuesMap[strings.ToLower(val)] = true - } - - allKeywordsExist := true - for _, keyword := range keywords { - if !currentValuesMap[strings.ToLower(keyword)] { - allKeywordsExist = false - break - } - } - - if allKeywordsExist { - skippedMovies++ - continue - } - - fmt.Printf("\n🎬 Processing movie %d/%d: %s (%d)\n", i+1, len(movies), movie.Title, movie.Year) - fmt.Printf("πŸ”‘ TMDb ID: %s (%s)\n", tmdbID, movie.Title) - fmt.Printf("🏷️ Found %d TMDb keywords\n", len(keywords)) - - err = syncMovieFieldWithKeywords(config, movie.RatingKey, currentValues, keywords, updateField) - if err != nil { - fmt.Printf("❌ Error syncing %s: %v\n", updateField, err) - continue - } - - processedMovies[movie.RatingKey] = &ProcessedMovie{ - RatingKey: movie.RatingKey, - Title: movie.Title, - TMDbID: tmdbID, - LastProcessed: time.Now(), - KeywordsSynced: true, - } - - if exists { - updatedMovies++ - } else { - newMovies++ - } - - fmt.Printf("βœ… Successfully processed: %s\n", movie.Title) - time.Sleep(500 * time.Millisecond) - } - - fmt.Printf("\nπŸ“Š Processing Summary:\n") - fmt.Printf(" πŸ“ˆ Total movies in library: %d\n", totalMovieCount) - fmt.Printf(" πŸ†• New movies processed: %d\n", newMovies) - fmt.Printf(" πŸ”„ Updated movies: %d\n", updatedMovies) - fmt.Printf(" ⏭️ Skipped movies: %d\n", skippedMovies) - fmt.Printf(" πŸ“‹ Total processed movies: %d\n", len(processedMovies)) -} - -func extractTMDbID(movie Movie) string { - // First, look for TMDb ID in Guid array - for _, guid := range movie.Guid { - // TMDb IDs typically come in format like "tmdb://12345" - if strings.Contains(guid.ID, "tmdb://") { - return strings.TrimPrefix(guid.ID, "tmdb://") - } - // Sometimes it might be in format "com.plexapp.agents.themoviedb://12345" - if strings.Contains(guid.ID, "themoviedb://") { - return strings.TrimSuffix(strings.TrimPrefix(guid.ID, "com.plexapp.agents.themoviedb://"), "?lang=en") - } - } - - // If not found in Guid, try to extract from other patterns in Guid - tmdbRegex := regexp.MustCompile(`tmdb-(\d+)`) - for _, guid := range movie.Guid { - if matches := tmdbRegex.FindStringSubmatch(guid.ID); len(matches) > 1 { - return matches[1] - } - } - - // If still not found, try to extract from file paths - // Look for patterns like {tmdb-12345} or [tmdb:12345] or (tmdb;12345) etc. - // This regex will match: - // 1. Any opening brace/bracket/parenthesis - // 2. Optional whitespace - // 3. "tmdb" (case insensitive) - // 4. Any non-digit characters (separators) - // 5. One or more digits (the ID) - // 6. Any closing brace/bracket/parenthesis - filePathRegex := regexp.MustCompile(`[\[\{\(\<]?\s*tmdb\D+?(\d+)[\]\}\)\>]?`) - - for _, media := range movie.Media { - for _, part := range media.Part { - if part.File != "" { - // Convert backslashes to forward slashes for consistency - normalizedPath := strings.ReplaceAll(part.File, "\\", "/") - - // Check both the full path and individual path components - if matches := filePathRegex.FindStringSubmatch(normalizedPath); len(matches) > 1 { - return matches[1] - } - - // Split path and check each component - pathComponents := strings.Split(normalizedPath, "/") - for _, component := range pathComponents { - if matches := filePathRegex.FindStringSubmatch(component); len(matches) > 1 { - return matches[1] - } - } - } - } - } - - return "" -} - -func getTMDbKeywords(config Config, tmdbID string) ([]string, error) { - keywordsURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%s/keywords", tmdbID) - - req, err := http.NewRequest("GET", keywordsURL, nil) - if err != nil { - return nil, fmt.Errorf("creating TMDb request: %w", err) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.TMDbReadAccessToken)) - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("making TMDb request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("TMDb API HTTP %d: %s - Response: %s", resp.StatusCode, resp.Status, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading TMDb response: %w", err) - } - - var keywordsResponse TMDbKeywordsResponse - if err := json.Unmarshal(body, &keywordsResponse); err != nil { - return nil, fmt.Errorf("parsing TMDb JSON: %w", err) - } - - keywords := make([]string, len(keywordsResponse.Keywords)) - for i, keyword := range keywordsResponse.Keywords { - keywords[i] = keyword.Name - } - - return keywords, nil -} - -func syncMovieFieldWithKeywords(config Config, movieID string, currentValues []string, keywords []string, updateField string) error { - currentValuesMap := make(map[string]bool) - for _, val := range currentValues { - currentValuesMap[strings.ToLower(val)] = true - } - - valuesToAdd := make([]string, 0) - for _, keyword := range keywords { - if !currentValuesMap[strings.ToLower(keyword)] { - valuesToAdd = append(valuesToAdd, keyword) - } - } - - fmt.Printf(" πŸ“ %s to add: %v\n", strings.Title(updateField), valuesToAdd) - fmt.Printf(" 🏷️ Existing %s: %v\n", updateField, currentValues) - - allValues := make([]string, 0, len(currentValues)+len(valuesToAdd)) - allValues = append(allValues, currentValues...) - allValues = append(allValues, valuesToAdd...) - - return updateMovieFieldWithKeywords(config, movieID, allValues, updateField) -} - -func updateMovieFieldWithKeywords(config Config, movieID string, keywords []string, updateField string) error { - basePath := fmt.Sprintf("/library/sections/%s/all?type=1&id=%s&includeExternalMedia=1", config.LibraryID, movieID) - - if updateField == "labels" { - for i, keyword := range keywords { - encodedKeyword := url.PathEscape(keyword) - basePath += fmt.Sprintf("&label%%5B%d%%5D.tag.tag=%s", i, encodedKeyword) - } - basePath += "&label.locked=1" - } else { - for i, keyword := range keywords { - encodedKeyword := url.PathEscape(keyword) - basePath += fmt.Sprintf("&genre%%5B%d%%5D.tag.tag=%s", i, encodedKeyword) - } - basePath += "&genre.locked=1" - } - basePath += fmt.Sprintf("&X-Plex-Token=%s", config.PlexToken) - - updateURL := buildPlexURL(config, basePath) - - fmt.Printf(" πŸ“€ Updating movie %s...\n", updateField) - - req, err := http.NewRequest("PUT", updateURL, nil) - if err != nil { - return fmt.Errorf("creating update request: %w", err) - } - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("making update request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("HTTP %d: %s - Response: %s", resp.StatusCode, resp.Status, string(body)) - } - - return nil -} - -func getAllLibraries(config Config) ([]Library, error) { - librariesURL := buildPlexURL(config, fmt.Sprintf("/library/sections/?X-Plex-Token=%s", config.PlexToken)) - - fmt.Printf("πŸ”— Attempting to connect to: %s\n", librariesURL) - - req, err := http.NewRequest("GET", librariesURL, nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - req.Header.Set("Accept", "application/json") - - client := &http.Client{ - Timeout: 30 * time.Second, // Add 30 second timeout - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - fmt.Println("πŸ“‘ Making request to Plex server...") - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("making request to %s: %w", librariesURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("HTTP %d: %s - Response: %s", resp.StatusCode, resp.Status, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - - var libraryResponse LibraryResponse - if err := json.Unmarshal(body, &libraryResponse); err != nil { - return nil, fmt.Errorf("parsing JSON response: %w", err) - } - - return libraryResponse.MediaContainer.Directory, nil -} - -func getMovieDetails(config Config, ratingKey string) (*Movie, error) { - // Use the individual metadata endpoint which includes labels by default - movieURL := buildPlexURL(config, fmt.Sprintf("/library/metadata/%s?X-Plex-Token=%s", ratingKey, config.PlexToken)) - - req, err := http.NewRequest("GET", movieURL, nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - req.Header.Set("Accept", "application/json") - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("making request to %s: %w", movieURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("HTTP %d: %s - Response: %s", resp.StatusCode, resp.Status, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - - var plexResponse PlexResponse - if err := json.Unmarshal(body, &plexResponse); err != nil { - return nil, fmt.Errorf("parsing JSON response: %w", err) - } - - if len(plexResponse.MediaContainer.Metadata) == 0 { - return nil, fmt.Errorf("no movie found with ratingKey %s", ratingKey) - } - - return &plexResponse.MediaContainer.Metadata[0], nil -} - -func getMoviesFromLibrary(config Config) ([]Movie, error) { - moviesURL := buildPlexURL(config, fmt.Sprintf("/library/sections/%s/all?X-Plex-Token=%s", config.LibraryID, config.PlexToken)) - - req, err := http.NewRequest("GET", moviesURL, nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - req.Header.Set("Accept", "application/json") - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("making request to %s: %w", moviesURL, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("HTTP %d: %s - Response: %s", resp.StatusCode, resp.Status, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - - var plexResponse PlexResponse - if err := json.Unmarshal(body, &plexResponse); err != nil { - return nil, fmt.Errorf("parsing JSON response: %w", err) - } - - return plexResponse.MediaContainer.Metadata, nil -} - -func getEnvWithDefault(envVar, defaultValue string) string { - if value, exists := os.LookupEnv(envVar); exists { - return value - } - return defaultValue -} - -func getProcessTimerFromEnv() time.Duration { - if value, exists := os.LookupEnv("PROCESS_TIMER"); exists { - duration, err := time.ParseDuration(value) - if err == nil { - return duration - } - } - return 5 * time.Minute -} - -func getBoolEnvWithDefault(envVar string, defaultValue bool) bool { - if value, exists := os.LookupEnv(envVar); exists { - return value == "true" - } - return defaultValue -} - -func buildPlexURL(config Config, path string) string { - protocol := "https" - if !config.PlexRequiresHTTPS { - protocol = "http" - } - return fmt.Sprintf("%s://%s:%s%s", protocol, config.PlexServer, config.PlexPort, path) -} - -// New function to remove keywords from movies -func removeKeywordsFromMovies(config Config, updateField string, removeMode string) { - fmt.Println("\nπŸ“‹ Fetching all movies from library...") - movies, err := getMoviesFromLibrary(config) - if err != nil { - fmt.Printf("❌ Error fetching movies: %v\n", err) - return - } - - if len(movies) == 0 { - fmt.Println("❌ No movies found in library!") - return - } - - totalMovieCount = len(movies) - fmt.Printf("βœ… Found %d movies in library\n", totalMovieCount) - - processedMovies := 0 - skippedMovies := 0 - - for i, movie := range movies { - tmdbID := extractTMDbID(movie) - if tmdbID == "" { - fmt.Printf("⚠️ No TMDb ID found for movie: %s - skipping\n", movie.Title) - skippedMovies++ - continue - } - - // Get TMDb keywords to know what to remove - tmdbKeywords, err := getTMDbKeywords(config, tmdbID) - if err != nil { - fmt.Printf("❌ Error fetching TMDb keywords for %s: %v - skipping\n", movie.Title, err) - skippedMovies++ - continue - } - - if len(tmdbKeywords) == 0 { - fmt.Printf("⚠️ No TMDb keywords found for movie: %s - skipping\n", movie.Title) - skippedMovies++ - continue - } - - // Fetch detailed movie information - movieDetails, err := getMovieDetails(config, movie.RatingKey) - if err != nil { - fmt.Printf("❌ Error fetching movie details for %s: %v - skipping\n", movie.Title, err) - skippedMovies++ - continue - } - - var currentValues []string - if updateField == "labels" { - currentValues = make([]string, len(movieDetails.Label)) - for j, label := range movieDetails.Label { - currentValues[j] = label.Tag - } - } else { - currentValues = make([]string, len(movieDetails.Genre)) - for j, genre := range movieDetails.Genre { - currentValues[j] = genre.Tag - } - } - - // Create map of TMDb keywords for fast lookup (case-insensitive) - tmdbKeywordsMap := make(map[string]bool) - for _, keyword := range tmdbKeywords { - tmdbKeywordsMap[strings.ToLower(keyword)] = true - } - - // Filter out TMDb keywords, keeping only non-TMDb values - var filteredValues []string - var removedKeywords []string - for _, value := range currentValues { - if tmdbKeywordsMap[strings.ToLower(value)] { - removedKeywords = append(removedKeywords, value) - } else { - filteredValues = append(filteredValues, value) - } - } - - // Skip if no keywords to remove - if len(removedKeywords) == 0 { - fmt.Printf("⚠️ No TMDb keywords found in %s for movie: %s - skipping\n", updateField, movie.Title) - skippedMovies++ - continue - } - - fmt.Printf("\nπŸ—‘οΈ Processing movie %d/%d: %s (%d)\n", i+1, len(movies), movie.Title, movie.Year) - fmt.Printf("πŸ”‘ TMDb ID: %s\n", tmdbID) - fmt.Printf("πŸ—‘οΈ Removing %d keywords from %s: %v\n", len(removedKeywords), updateField, removedKeywords) - fmt.Printf("🏷️ Keeping %d non-keyword values: %v\n", len(filteredValues), filteredValues) - - // Update the field with filtered values - lockField := removeMode == "lock" - err = removeMovieFieldKeywords(config, movie.RatingKey, filteredValues, updateField, lockField) - if err != nil { - fmt.Printf("❌ Error removing keywords from %s: %v\n", updateField, err) - continue - } - - processedMovies++ - fmt.Printf("βœ… Successfully processed: %s\n", movie.Title) - - // Small delay to avoid overwhelming the APIs - time.Sleep(500 * time.Millisecond) - } - - fmt.Printf("\nπŸ“Š Removal Summary:\n") - fmt.Printf(" πŸ“ˆ Total movies in library: %d\n", totalMovieCount) - fmt.Printf(" πŸ—‘οΈ Movies processed: %d\n", processedMovies) - fmt.Printf(" ⏭️ Skipped movies: %d\n", skippedMovies) - fmt.Printf(" πŸ”’ Field lock status: %s\n", removeMode) -} - -// New function to remove keywords from movie field -func removeMovieFieldKeywords(config Config, movieID string, remainingValues []string, updateField string, lockField bool) error { - // Get current movie details to determine what keywords need to be removed - movieDetails, err := getMovieDetails(config, movieID) - if err != nil { - return fmt.Errorf("fetching movie details: %w", err) - } - - var currentValues []string - if updateField == "labels" { - currentValues = make([]string, len(movieDetails.Label)) - for j, label := range movieDetails.Label { - currentValues[j] = label.Tag - } - } else { - currentValues = make([]string, len(movieDetails.Genre)) - for j, genre := range movieDetails.Genre { - currentValues[j] = genre.Tag - } - } - - // Create map of values to keep for fast lookup (case-insensitive) - remainingValuesMap := make(map[string]bool) - for _, value := range remainingValues { - remainingValuesMap[strings.ToLower(value)] = true - } - - // Find values that need to be removed - var valuesToRemove []string - for _, value := range currentValues { - if !remainingValuesMap[strings.ToLower(value)] { - valuesToRemove = append(valuesToRemove, value) - } - } - - if len(valuesToRemove) == 0 { - fmt.Printf(" ⚠️ No %s values to remove\n", updateField) - return nil - } - - // Build request to remove specific values using the -= operator - removePath := fmt.Sprintf("/library/sections/%s/all?type=1&id=%s&includeExternalMedia=1", config.LibraryID, movieID) - - // Encode each value individually, then join with URL-encoded commas - var encodedValues []string - for _, value := range valuesToRemove { - encodedValues = append(encodedValues, url.PathEscape(value)) - } - combinedValues := strings.Join(encodedValues, ",") // %2C is URL encoded comma - combinedValues = url.PathEscape(combinedValues) - - // Add removal parameters for all values to remove in one shot - if updateField == "labels" { - removePath += fmt.Sprintf("&label%%5B%%5D.tag.tag-=%s", combinedValues) - - if lockField { - removePath += "&label.locked=1" - } else { - removePath += "&label.locked=0" - } - } else { - removePath += fmt.Sprintf("&genre%%5B%%5D.tag.tag-=%s", combinedValues) - - if lockField { - removePath += "&genre.locked=1" - } else { - removePath += "&genre.locked=0" - } - } - removePath += fmt.Sprintf("&X-Plex-Token=%s", config.PlexToken) - - removeURL := buildPlexURL(config, removePath) - - fmt.Printf(" πŸ“€ Removing %d %s values: %v\n", len(valuesToRemove), updateField, valuesToRemove) - - req, err := http.NewRequest("PUT", removeURL, nil) - if err != nil { - return fmt.Errorf("creating remove request: %w", err) - } - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("making remove request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("remove HTTP %d: %s - Response: %s", resp.StatusCode, resp.Status, string(body)) - } - - lockStatus := "unlocked" - if lockField { - lockStatus = "locked" - } - - fmt.Printf(" βœ… Successfully removed %d %s values and %s field\n", len(valuesToRemove), updateField, lockStatus) - - return nil -}