# Architecture This document describes how Curator is put together: the layers, what owns what on disk, and the handful of rules the rest of the code relies on. The per-module API reference is in [`modules/`](README.md). ## Layers ``` Curator.py entry point — certificates, State, run() │ ▼ src/ui/qt_app.py PySide6 GUI: QtApp (main window) + dialogs │ never touches the filesystem directly ▼ src/core/file_manager.py FileManager — the service layer │ owns: filelist, pool index, library config, global config │ ├── domain model file.py (File), movie.py (Movie, MediaFile), │ series.py (Series, Episode), tag.py, tag_manager.py ├── persistence pool_index.py (.Curator.!index), config.py │ legacy_meta.py (reads pre-index layouts) ├── naming naming.py (movie stems, 3D), series.py (SxxEyy), │ subtitles.py (sidecar names), movie.identity_key ├── external services tmdb.py, csfd.py, jellyfin.py, nfo.py (read-only) └── output and checks hardlink_manager.py (miniDLNA / Filmotéka), transforms.py, integrity.py (ffmpeg), migrate.py ``` Dependencies only point downwards. The GUI calls `FileManager` methods and receives plain objects back; the core modules never import Qt. ### Entry point `Curator.py` builds a `State` (one `TagManager` and one `FileManager`) and hands both to `src.ui.qt_app.run`. Before that it asks the OS certificate store for HTTPS, because ČSFD and TMDb are reached from behind corporate proxies. ### GUI `QtApp` is the main window: the menu bar, a tag sidebar (an AND filter over the movie list), the movie and series table, a detail panel and the status bar. Each menu action is a method on `QtApp`; most of them open a dialog and then call one `FileManager` method. The dialogs are separate classes in the same module — import, edit, duplicate check, series overview, library organiser and so on. ### Service layer `FileManager` is the only object that changes the pool. Everything the user can do maps onto one of its public methods, for example `import_movie`, `add_movie_version`, `remove_movie_version`, `merge_as_version`, `apply_movie_edits`, `import_episodes`, the miniDLNA scope methods (`minidlna_scope`, `minidlna_prune_targets`) and `find_duplicates`. Methods that change files also keep the pool index and the in-memory `filelist` in step. ## What lives where on disk The pool is one folder. Each top-level folder inside it is a **library**, and each library has a **kind** that decides how Curator treats it: | Kind | Holds | Layout | |------|-------|--------| | `movie` | single-file movies and grouped movies | `Title (rok) [tmdbid].ext` or `Title (rok) [tmdbid]/…` | | `series` | series trees | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` | | `mixed` | movies and series in one folder | movies flat or in a movie folder, series folders with `Season NN` inside | | `homevideo` | files without TMDb ids | mirrored as-is | The default pool has `Filmy` (movie) and `Seriály` (series). Library configuration lives in the pool index, not in the global config. Two more files sit in the pool root: - `.Curator.!index` — the pool index (JSON, version 2). See below. - nothing else: there are no per-file sidecars. Curator's own metadata is in the index; Jellyfin writes its NFO and artwork next to the media. Outside the pool, the Filmotéka / miniDLNA output is a tree of hardlinks made by `hardlink_manager`. It never holds metadata of its own. ## The pool index `pool_index.py` keeps one JSON document per pool: ```json { "version": 2, "settings": { "libraries": [...], "tag_schema": [...], "minidlna_libraries": null }, "movies": { "tmdb-19995": { "title": "Avatar", "year": "2009", "tmdb_id": 19995, "library": "Filmy", "tags": ["Žánr/Sci-Fi"], "csfd_url": null, "attributes": {}, "date": null, "media": [ {"role": "video", "label": "", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995].mkv"}, {"role": "video", "label": "3D HSBS", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"} ] } } } ``` The key of a movie record is its **identity**: - `tmdb-` when the TMDb id is known; - otherwise `name-` built from the title and year, with diacritics folded (`identity_key` in `movie.py`). Everything about a film — title, tags, ČSFD link, attributes, date added — is on that one record. Its `media` list holds every playable file of the film. A version therefore never carries metadata of its own, and removing one version never loses the metadata of the others. Version 1 of the index was keyed by file path. `PoolIndex.load` migrates it on first open: metadata only, no file is moved, and the old file is kept as `.Curator.!index.v1.bak`. ## Movies and versions A film starts as one flat file. Adding a second file for the same film turns it into a **grouped movie**: a folder named after the film, with every file in it named after that folder. ``` Filmy/ Avatar (2009) [tmdbid-19995]/ Avatar (2009) [tmdbid-19995].mkv ← default version, no label Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv ← version with popisek "3D HSBS" Avatar (2009) [tmdbid-19995] - 4K.mkv Avatar (2009) [tmdbid-19995].cs.srt ← subtitles follow their video ``` Rules that the code depends on: - **Default version.** The plain version has no label. In the UI it is shown as "Výchozí". A flat movie that has been promoted keeps its 3D formát, if it had one, as its label; otherwise it becomes the bare default. - **3D formát.** A version whose popisek is `3D ` is 3D; a flat movie carries the same information as `.3D.` in its name. Promotion and demotion carry the formát across, so a movie that ends up with only a 3D version is flat again with its tag intact. - **Demotion.** When one version is left, the movie goes back to a flat file named after the folder (plus the 3D tag of the survivor, if any) and the folder is removed. - **Identity.** A file belongs to the film whose identity matches its folder name (grouped) or its own stem minus any `.3D.` tag (flat). Duplicate checking, import collision handling and index lookups all use this rule. - **Mixed libraries.** A movie's folder and a series folder have the same outer name. They are told apart by content: a series always nests episodes in a `Season NN` or `Specials` subfolder, a movie folder never does (`series.looks_like_series_folder`). ## Series A series is a folder with `Season NN` (and `Specials` for season 0) subfolders. Episodes are named `SxxEyy - Series.ext`; an episode split across several physical files gets Jellyfin's stacking suffix `.ptN`. Series carry no index record: they are scanned from disk each time (`series.scan_series`), so deleting an episode file is the whole of removing it. ## Subtitles Subtitles are sidecar files that share the video's stem, with an optional language and flag: `Name.cs.srt`, `Name.cs.forced.srt`. They are not index entries. Every operation that renames or moves a video calls `subtitles.rename_subtitles_with`, so the sidecars keep their match. ## Output and external services - **miniDLNA / Filmotéka** — `FileManager.minidlna_scope` decides which libraries take part; `HardlinkManager` builds the genre tree and mirrors the copy-as-is libraries. Libraries left out of scope have their old links swept on the next run. - **TMDb** (`tmdb.py`) — the identity source. Turns a TMDb id or a title/year search into a canonical title and year, and supplies genres and years for tags. - **ČSFD** (`csfd.py`) — a bridge for Czech titles: filename → ČSFD film → original title and year → TMDb. - **Jellyfin** (`jellyfin.py`) — library scan trigger and a read-back of what Jellyfin sees, used to verify the pool. - **NFO** (`nfo.py`) — read-only metadata written by Jellyfin, used to fill fields of files imported without them. ## Configuration Two stores, by responsibility (see `config.py`): - the **global config** (`.Curator.!gtag`, next to `Curator.py`) holds application state only — window geometry, recent folders and the pool and output paths; - the **pool index** holds everything that describes a pool — libraries, tag schema, miniDLNA selection and all movie records. Secrets come from `.env`: `TMDB_API_KEY`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`, and an optional debug flag read by `constants.py`. ## Testing Tests live in `tests/` and run with pytest. The core modules are tested directly against temporary pools; the Qt dialogs are exercised headless (`QT_QPA_PLATFORM=offscreen`) through their methods.