55 lines
3.7 KiB
Markdown
55 lines
3.7 KiB
Markdown
---
|
||
name: plex-title-authority-for-relayout
|
||
description: "Read Plex's metadata cache as the clean-title authority (ziggurat schema, immutable read, no sqlite3 CLI) when naming or regrouping a media library from scripts instead of filename parsing."
|
||
---
|
||
|
||
Plex has already matched most of a library to real titles. When a script must name folders/files (Jellyfin relayout, dedup reports, library census), **read Plex's DB and let it override filename parsing** — `Alien.Directors.Cut.1979.1080p.BRrip.x265-RARBG` → `Alien`, `1917.2019.2160p…` → `1917`. Measured on dankserver's Movies section: 3,773 parts, **0** filename-shaped titles, 80% of media files matched (unmatched = extras, BDMV disc trees, TV).
|
||
|
||
## Locate and open the DB (no sqlite3 CLI needed)
|
||
|
||
```
|
||
DB="<data>/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
|
||
# data dir: /var/lib/plexmediaserver (host install) or the container's volume
|
||
```
|
||
|
||
```python
|
||
con = sqlite3.connect(f"file:{DB}?mode=ro&immutable=1", uri=True) # no copy, no wal, no lock
|
||
```
|
||
|
||
`immutable=1` is the whole trick: the DB was 1.5 GiB and Plex was live — no copy, no `sqlite3` binary, no risk of touching the service. Set `busy_timeout`; expect occasional inconsistency, acceptable for a read-only census.
|
||
|
||
## Schema trap: Plex renamed the tables ("ziggurat")
|
||
|
||
The old `sections` table **does not exist** — querying it throws, which reads like "wrong DB". Real names:
|
||
|
||
| table | join | key columns |
|
||
|---|---|---|
|
||
| `library_sections` | — | `id`, `name`, `section_type` (`movie`/`show`), `agent`, `scanner` |
|
||
| `section_locations` | `library_section_id` | `root_path`, `available` |
|
||
| `metadata_items` | `parent_id` → own `id` | `metadata_type`, `title`, `original_title`, `edition_title`, `year`, `indexed`, `deleted_at` |
|
||
| `media_items` | `metadata_item_id` | `container`, `video_codec`, `audio_codec`, `width`, `height` |
|
||
| `media_parts` | `media_item_id` | **`file`** (full path), `size`, `duration`, `deleted_at` |
|
||
|
||
Census section coverage first (`count(*) where file like '<root>/%'`), then dump:
|
||
|
||
```sql
|
||
select p.file, mi.title, mi.original_title, mi.edition_title, mi.year, m.video_codec, m.width, m.height
|
||
from media_parts p
|
||
join media_items m on m.id = p.media_item_id
|
||
join metadata_items mi on mi.id = m.metadata_item_id
|
||
where p.file like '<root>/%' and p.deleted_at is null and mi.deleted_at is null
|
||
```
|
||
|
||
For a movie library the leaf *is* the film; `parent_id` is NULL, so a `left join` on parent returns the same title. `metadata_type`: 1 = movie, 2 = show (a show section riding the same root means episodes live in the MOVIES folder — worth reporting).
|
||
|
||
## Using it in a renamer
|
||
|
||
- **One film, one name:** match any copy → apply that title to the whole group. Keyed by `file` path, so the map is exact.
|
||
- **Override, don't replace:** the parser still names the unmatched (extras, disc trees, TV, unscanned). Keep it as a fallback tier.
|
||
- **Bucket on the old key, name with Plex:** group/score sidecars with the parser's words (a `.srt` matches on the year token), format the folder from Plex: `f"{title} ({year})"`.
|
||
- **Sanitize for the FS:** Plex titles carry `:` and `&` (`2001: A Space Odyssey`, `Rock & Rule`) — those must go through the same sanitizer as filename-derived names.
|
||
|
||
## Cross-check before trusting a parser
|
||
|
||
Score the parser against the cache: count titles matching `2160p|1080p|x26[45]|bluray|hevc|10bit` — Plex returned **0**. Every parser mismatch is a real bug class: year-echo pseudo-titles (`1917.2019.2160p…`), two-year titles (`2001…1968`), numeric titles eaten as track indexes (`12 Monkeys`, `007 Skyfall`, `000 Miles to Graceland`, `0 - First Dates` — keep the number unless it's 1–2 digits glued to the words with a year present, i.e. `01. Bye Bye Baby`).
|