# Plex Metadata Fix Workflow

Fix Plex series that matched to the wrong show. Common with German ZDF/ARD mini-series where Plex's agents pick a completely unrelated series.

## Problem Pattern

Plex matches a mini-series folder to a wrong umbrella series. Example: `Terra_X` with 3 episodes of "Weltstädte mit Leon Windscheid" (2024) matched to "Terra X - Rätsel alter Weltkulturen" (1982).

## Solution

### 1. Rename for specificity

Generic folder names cause bad matches. Add distinguishing suffix:

```bash
cd "/media/HDD_1TB/Medien/Serien"
sudo mv Terra_X Terra_X_Weltstaedte
sudo mv "Terra_X_Weltstaedte/S01/Terra_X_S01E01.mp4" "Terra_X_Weltstaedte/S01/Terra_X_Weltstaedte_S01E01.mp4"
# ... repeat for all episodes
```

### 2. Trigger library scan

```bash
PLEX_TOKEN="UZBRFc2Y1psjzPNvnycU"
# Get library section IDs
curl -s "http://127.0.0.1:32400/library/sections?X-Plex-Token=$PLEX_TOKEN" -H "Accept: application/json"
# Scan
curl -s "http://127.0.0.1:32400/library/sections/{id}/refresh?X-Plex-Token=$PLEX_TOKEN"
```

### 3. Check Plex DB for match result

```bash
sqlite3 "/DATA/AppData/plex/config/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db" \
  "SELECT id, title, metadata_type, \"index\", parent_id, year, guid FROM metadata_items WHERE title LIKE '%Terra%Welt%' ORDER BY parent_id, \"index\";"
```

### 4. Search for correct match

```bash
curl -s "http://127.0.0.1:32400/library/metadata/{series_id}/matches?manual=1&title=Weltst%C3%A4dte&year=2024&agent=tv.plex.agents.series&X-Plex-Token=$PLEX_TOKEN" -H "Accept: application/json"
```

### 5. Manual metadata fix (when no online match exists)

Plex PUT endpoint uses **query parameters**, NOT JSON body. JSON body returns 400 Bad Request.

```bash
# Series
curl -s -X PUT "http://127.0.0.1:32400/library/metadata/{id}?title.value=Terra%20X%3A%20Weltst%C3%A4dte&year.value=2024&summary.value=...&studio.value=ZDF&X-Plex-Token=$PLEX_TOKEN"

# Episodes
curl -s -X PUT "http://127.0.0.1:32400/library/metadata/{ep_id}?title.value=New%20York&year.value=2024&X-Plex-Token=$PLEX_TOKEN"
```

### 6. Upload poster — CRITICAL: aspect ratio

Plex expects **2:3 portrait** posters (e.g. 1000×1500). Uploading a 16:9 landscape image (e.g. 1920×1080) causes Plex to show **black/nothing** instead of the image. Always crop/resize to portrait before uploading:

```python
from PIL import Image
img = Image.open('landscape_poster.jpg')
w, h = img.size
# Crop center to 2:3 ratio using full height
crop_w = int(h * 2/3)
left = (w - crop_w) // 2
portrait = img.crop((left, 0, left + crop_w, h))
portrait = portrait.resize((1000, 1500), Image.LANCZOS)
portrait.save('poster_portrait.jpg', quality=90)
```

**Preferred: Local Media Assets** — place `poster.jpg` directly in the series folder and `S01/poster.jpg` in the season folder. Plex's Local Media Assets agent picks it up on next scan. This is the most reliable method and avoids API upload issues entirely.

```bash
sudo cp poster_portrait.jpg "/media/HDD_1TB/Medien/Serien/Show Name (Year)/poster.jpg"
sudo cp poster_portrait.jpg "/media/HDD_1TB/Medien/Serien/Show Name (Year)/S01/poster.jpg"
```

**API alternative:** Use `--data-binary`, NOT `-F` (multipart). Plex corrupts multipart uploads by writing boundary headers into the JPEG file.

```bash
curl -s -X POST "http://127.0.0.1:32400/library/metadata/{id}/posters?X-Plex-Token=$PLEX_TOKEN" \
  --data-binary @poster_portrait.jpg \
  -H "Content-Type: image/jpeg"
```

**PITFALL: `curl -F` corrupts poster uploads.** Plex writes the entire multipart/form-data body (boundary headers + file content) into the bundle instead of just the JPEG. The file starts with `------------------...` instead of `JFIF`. Result: black/nothing in Plex. Never use `-F` for Plex poster uploads.

ZDF og:image is typically 1920×1080 landscape — always convert to portrait before uploading to Plex.

### 7. Delete old wrong match

```bash
curl -s -X DELETE "http://127.0.0.1:32400/library/metadata/{old_id}?X-Plex-Token=$PLEX_TOKEN"
```

## Key Pitfalls

- **Plex PUT uses query params, not JSON body** — JSON body returns 400 Bad Request silently
- **ZDF API requires authentication** — can't query episode data directly from ZDF API without a token. Extract episode titles from the HTML page's `__next_f.push` GraphQL payload instead
- **ZDF page is a Next.js SPA** — `curl` gets the full HTML with embedded GraphQL data in `self.__next_f.push([1,"...escaped JSON..."])` calls. Parse these to extract episode titles, dates, and canonical URLs
- **Plex DB schema**: `metadata_items` table uses `index` (not `episode_index`), `parent_id` for hierarchy, `metadata_type` (2=show, 3=season, 4=episode)
- **Plex token** is stable and stored in Plex config. Current token: `UZBRFc2Y1psjzPNvnycU`

## ZDF Next.js Data Extraction

ZDF pages are Next.js SPAs. Episode data is embedded in `self.__next_f.push()` calls:

```python
import re, json

# Find the GraphQL response
scripts = re.findall(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', html, re.DOTALL)
for s in scripts:
    if 'smartCollectionByCanonical' in s and '"episodes"' in s:
        unescaped = s.replace('\\"', '"').replace('\\\\', '\\')
        data = json.loads(unescaped)
        # Navigate: data.smartCollectionByCanonical.seasons.nodes[].episodes.nodes[]
```

Also check `<title>` tag and `og:title`/`og:description` meta tags for series-level metadata.
