326 lines
20 KiB
Markdown
326 lines
20 KiB
Markdown
# 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
|