Files
Curator/PROJECT.md
T

326 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PROJECT.md
This file is project-specific. Only include information directly related to the concrete project — goals, current state, architecture decisions, known issues, and tasks.
## Origin
Curator is a fork of the former **Tagger** project. The tagging, filtering and
hardlink-tree parts are inherited and keep working as before. On top of that,
Curator becomes a full **movie library manager (Filmotéka)**.
## Core idea
Curator manages a personal movie library based on two folders:
- **Pool** — the managed repository of video files. This is the **single source
of truth**. Curator manages the pool itself (insert/remove file), so files are
never moved by hand. The pool has exactly two top-level folders: **Filmy**
(movies — tag-based tree) and **Seriály** (series — a "copy-as-is" folder
mirrored 1:1 into the output; see Design decisions). Every file lives here
exactly once.
- **Filmotéka (output)** — a generated, browsable directory tree made only of
**hardlinks** into the pool (the same mechanism as today's hardlink manager).
It is fully disposable: deleting the Filmotéka folder loses nothing, because
it can always be regenerated from the pool.
### Workflow
1. The user configures two folders: the **pool** and the **Filmotéka output**.
2. The user picks a video file via "Open file".
3. Curator opens a dialog to fill in basic info — at minimum the **title/name**
and a **ČSFD link**.
4. Curator **renames** the file and **moves** it into the managed pool, and
writes a **metadata file** describing it.
5. From the pool, Curator **generates the Filmotéka** — a complex tree of
hardlinks built from each file's tags/metadata (like the current hardlink
manager, but driven by the pool).
6. Deleting the Filmotéka has no effect on the pool; the tree is regenerated on
demand.
## Current state
- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata),
`FileManager` (folder scan, filtering, ignore patterns), 3-level config,
`HardlinkManager` (create/sync/cleanup), pytest suite.
- Rename Tagger → Curator done across code, spec, config filenames
(`.Curator.!gtag` / `.Curator.!ftag`) and tests.
- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is
the entry point; the old tkinter `src/ui/gui.py` is retained for reference.
- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`;
`FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`),
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`.
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an
index is injected. Without an index a `File` is an in-memory object only — the
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
removed.
### GUI decision
The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and
**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar,
movie table, and one-click Filmotéka generation.
## Design decisions
- **Metadata storage:** one **unified metadata file** for the whole pool (a
central index), not per-file sidecars. Justified because Curator owns the pool
and files are never moved manually, so it is not exposed to path drift.
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
pointers (which must stay global: the index is found *through* them). Everything
that describes a specific library — `tag_schema` and `copyasis_folders` — lives
in the pool index under a `settings` section, so it travels with the pool.
`FileManager` migrates any pre-existing global values into the index on first
open. The old per-folder `.!ftag` config and per-file `.!tag` sidecars were
removed (they were unused Tagger leftovers), and the orphaned tkinter
`src/ui/gui.py` went with them.
- **Import dialog:** **multi-file** — pick several videos at once and give each
its own **Title** + **ČSFD link** (one row per file, more can be added from the
dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into
a query and fills the first ČSFD search hit; existing links are kept). A single
**copy/move** toggle decides whether the sources are copied (default) or moved
into the pool. Each file is renamed to `Title.ext`. When a
ČSFD link is given, Curator fetches the movie and assigns Žánr / Rok / Země
původu / Hodnocení (ten-point band) tags automatically; further tags can be
added via the UI. Directors and the first 10 actors are fetched and cached too,
but **deliberately not turned into tags/folders** (there would be too many).
- **Genres / countries:** a movie can have **multiple genres** and, for a
co-production, **multiple countries of origin** (ČSFD writes them
slash-separated, e.g. "USA / Velká Británie"). Each becomes its own tag, so the
film appears under every matching genre and country branch in the Filmotéka
(multiple hardlinks).
- **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are
the first target; the Seriály branch follows the "copy-as-is" rule below.
- **Copy-as-is folders (Seriály):** a subfolder inside the pool can be marked as
**copy / as-is**. For such a folder Curator does **not** build a tag-based tree;
instead it **mirrors the exact directory hierarchy** from the pool into the
Filmotéka output, with the files materialized as **hardlinks** into the pool.
So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure,
hardlinked files). This is how Seriály work.
- **File naming:** pooled movies follow the convention **`Title (YYYY).ext`** — a
clean title plus a four-digit year in parentheses (`naming.py`:
`canonical_pool_stem` / `parse_pool_stem`). The year lets two same-named films
coexist and makes the filename self-describing (`File.name_context` recovers
the year from the filename when no ČSFD/tag year is present). The GUI **import
requires a four-digit year** (per-row *Rok* field, pre-filled from the source
filename and fillable from ČSFD — a ČSFD-suggested year is tinted blue to be
verified, a name colliding with the pool is tinted red). *Filmy → "Přejmenovat
dle ČSFD (rok)…"* (`FileManager.rename_all_to_canonical`) brings older
`Title.ext` files up to the convention from their ČSFD metadata; files without
a known year are skipped. `File.title` always stays the clean title (no year).
- **Import copy vs move:** by default the original file is **copied** into the
pool (non-destructive); the import dialog also offers a **move** option that
relocates the source into the pool instead.
- **Filmotéka tree layout:** driven by a category → root-folder map
(`FILMOTEKA_CATEGORY_ROOTS`). At the output root sit the **genre folders
directly** (`output/Akční/film`, …), next to the copy-as-is mirrors
(**Seriály**), plus two grouping folders: **`Dle roku`** (`output/Dle
roku/<rok>/film`) and **`Dle země původu`** (`output/Dle země
původu/<země>/film`), plus `Dle hodnocení`. Each is a hardlink.
`HardlinkManager` supports an empty root (tag folders placed directly at the
output root) and restricts obsolete cleanup to the tag-tree's own top-level
folders so mirrors are never touched.
- **Tag schema (config-driven, not hard-coded):** the categories, their ČSFD
source field + transform, and their Filmotéka folder mapping all live in
`tag_schema` in the global config (default `config.DEFAULT_TAG_SCHEMA`, edited
via *Nastavení → Tag schéma…*). Both `apply_csfd_tags` (which fields → tags)
and the Filmotéka layout (`FileManager.filmoteka_category_roots`) read from it,
so adding a category or changing a folder rule needs no code change. A category
can be made filter-only (no folders) by setting its `filmoteka_root` to null.
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
- **Filmotéka link naming (clean title, year on collision):** the default
hardlink name in the tag tree is the movie's **clean title** — `Title.ext`,
*not* the pooled `Title (YYYY).ext`. When two different films would collide on
that name **within one folder**, the colliding ones keep the disambiguating
**year** (`Title (YYYY).ext`); a residual same-title-and-year clash gets a
stable numeric suffix. `HardlinkManager._plan_links` resolves the whole tag
tree to a collision-free `(file, path)` plan that **both** link creation and
obsolete-detection consume, so they can never disagree on a name (the previous
churn source). Copy-as-is mirrors (Seriály) are unaffected — they mirror 1:1.
- **Per-category filename template** (`filename_template` in a schema entry):
overrides the default naming **inside that category's folders only**, rendered
from the movie's metadata (`File.name_context`: title/year/rating/ext/stem/
filename plus any free-form attributes), e.g. a Kolekce with
`"{collection_sort} - {title}{ext}"`. Applied via
`filmoteka_category_filename_templates`.
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
`key → value` metadata stored in the index and merged into `name_context`, so
custom fields like `collection_sort` can drive filename templates.
- **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD
(`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so
changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones.
- **Special folders + "- " prefix:** besides the tag tree, generation produces
`- Tipy dne` (15 random) and `- Nově přidané` (10 newest by `File.added`, mtime
fallback — ctime is bumped by hardlinking). All grouping folders and these
specials are prefixed with **"- "** so
DLNA/TV browsers sort them before the genre folders at the output root. Specials
are `reserved_subfolders` so cleanup never touches them; renaming old unprefixed
folders is handled automatically (they become stale root-level folders and are
swept on the next generation).
- **Date added:** `import_movie` stamps `File.added` (ISO); older items without it
fall back to the file's mtime for the "recently added" ordering (ctime is
unreliable — creating the Filmotéka hardlinks bumps it).
## Direction — Jellyfin pivot (in progress)
Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first
library manager**. Agreed shape:
- **Pool = the Jellyfin library set.** Files are named `Title (rok) [tmdbid-N].ext`
— the form Jellyfin matches on. Libraries are physical top-level pool folders,
each with a **kind** (`libraries` setting; managed in *Pool → Knihovny…*):
- `movie` — single-file movies, TMDb naming (done)
- `series` — series/season/episode tree, mirrored 1:1 (done)
- `mixed` — movies **and** series in one folder (Jellyfin "Mixed" type;
supported-but-discouraged by Jellyfin, but low-risk here thanks to the strict
structure, and fully reversible — see decisions). **Scanning TBD (phase 9).**
- `homevideo` — files with no TMDb (Jellyfin "Home videos" type). **TBD (ph. 8).**
- **Target library layout (the user's plan):** `Hlavní` (mixed, incl. kids),
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
(homevideo). Kids are **not** a separate folder — see decisions.
- **Naming convention:**
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form.
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext`
(season 0 → `Specials`); the episode keeps its original filename after the
`SxxEyy` code. Episode numbers are recognised from the filename; editable in
the series GUI.
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
`tmdbid` (yearless title search picks the wrong film, hence the earlier
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
TMDb has no cs title); last fallback = the original filename. The filename is
always editable in the GUI.
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
kept **only** as an optional CZ-title source for the filename (not for tags/
matching — it exposes no TMDb/IMDb id, verified).
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
(CZ-title source/override); filename hand-editable before/after import.
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
`naming.py`), not from ČSFD tags.
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
key + URL in `.env`. NFO on disk stays authoritative.
### Decisions (locked)
- **Kids = Jellyfin parental rating, not a folder** (variant B). Kids content
stays in the normal libraries; a child Jellyfin account gets a max-rating limit
with **"block unrated" ON**. Ratings come from TMDb via Jellyfin. Caveat: Czech
certifications are sparse — set the library metadata country to one with fuller
ratings (US) and fix odd cases in Jellyfin (Identify + lock).
- **Mixed libraries are OK and reversible.** Jellyfin discourages them (unreliable
metadata) but our disciplined structure (series = `Season/SxxEyy`, movie =
single file + `[tmdbid]`) gives the scanner clear signals. Because metadata is
**NFO on disk** + **tmdbid in the name**, switching mixed↔separate later is just
moving folders (Jellyfin re-reads NFO, no re-fetch, no loss).
- **Home videos** (Chajda) use Jellyfin's **"Home videos"** content type — no
online lookup, just serves the files.
- **Always match by tmdbid, not by yearless title.** TMDb cs-CZ titles are good
when the right film is matched (lookup by id, or search with a year); a bare
title search trips on remakes/same-name films (verified: Home Alone → 2021
reboot, Vejška → 2006). Import supplies the id; migration searches with a year.
### Phasing
(1) TMDb identity + tmdbid naming + import rework [done]; (2) movie libraries as
physical partitions + import-into / move-between [done]; (3) Jellyfin API — scan +
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
library organizer (unified `libraries` model with kinds) [done].
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs);
**(9) mixed-library scanning + generation** (movies + series in one folder).
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
[tmdbid].ext`); per-movie folders are a later refinement.
## Tasks
### Phase 8 — import without TMDb / without year (homevideo, no-TMDb docs)
- Relax the import dialog: allow a row with **no year and no tmdbid** (currently
a 4-digit year is mandatory). Pool name then falls back to `Title.ext`
(movies) / `Series/Season NN/SxxEyy.ext` (series), no `[tmdbid]`.
- Drive this by **library kind**: `homevideo` (and no-TMDb rows in `mixed`)
skip the TMDb requirement; `movie` keeps requiring a year (as today).
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
- Tests: import with no year/id lands correctly; homevideo library loads.
### Phase 9 — mixed-library scanning + generation
- Scan a `mixed` library by structure: a top-level **single video file = movie**;
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse
`series.scan_series` for the series part and the flat movie scan for the rest.
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`,
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`.
- miniDLNA generation + NFO refresh must cover mixed libraries too.
- The library filter / table should surface mixed libraries once loadable.
- Tests: a mixed folder with both a movie and a series loads/generates correctly.
### Later / optional
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the
filename in the GUI. The ČSFD **link** is already stored per movie
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later —
revisit fetching only if TMDb proves insufficient in practice.
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
path-aware `library_of` (currently single-segment).
## Done
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
- Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
quick (ffprobe), over selected movies or the whole pool
- Pool-root and Filmotéka-output folder settings in the global config
- Filmy / Seriály top-level folder handling in the pool
- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext
- Rename a pooled movie from the app (`FileManager.rename_movie`): renames the
file in pool/Filmy and moves its metadata to the new index key
- Remove-from-pool (delete file + its metadata)
- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Země původu /
Hodnocení)
- Filmotéka fully regenerable from the pool alone (delete output = no loss)
- GUI reframed around the Filmotéka and rewritten in PySide6
- Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as
hardlinks (`HardlinkManager.mirror_as_is`), wired into Filmotéka generation
- Fixed `media_utils` missing `subprocess` import
- Unified pool metadata index (`pool_index.py`): one `.Curator.!index` per pool;
`File` reads/writes it when injected, `FileManager` uses it for the pool
- Configurable copy-as-is folders (`copyasis_folders` in global config, editable
from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default)
- README.md written (overview, concepts, workflow, run/build instructions)
- ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags`
fetches a movie and assigns Žánr / Rok / Země původu tags (cached in metadata); wired
into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD").
Parsing updated for current ČSFD HTML and verified live against Matrix
(film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL)
- ČSFD Anubis anti-bot wall handled: `csfd.py` detects the proof-of-work
challenge page, solves it (SHA-256 PoW matching the bundled worker JS) and
replays via a shared `requests.Session`, so Žánr / Rok / Země původu tags load again
(the "nalezeno 1 film, načteno 0 tagů" symptom). Verified live (Matrix 1999)
- Removed the inherited Tagger predefined tags: `DEFAULT_TAGS` is now empty
(no Hodnocení ⭐ / Barva categories) and new files no longer get an automatic
`Stav/Nové` tag. Tags now come from ČSFD (Žánr / Rok / Země původu) and manual edits.
Note: `Hodnocení` is still listed in `FILMOTEKA_CATEGORIES`, so that branch is
simply empty until something assigns a Hodnocení tag again
- Fixed template cruft: `src/constants.py` made consistent (Curator values,
`get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed
the imported `tagger/` devel dump