# 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 API reference is generated from the source with `cargo doc --open`. ## Layers ``` src/main.rs entry point — .env, logging, ui::run() │ ▼ src/ui/ egui GUI: CuratorApp (main window) + dialogs/ │ never touches the filesystem directly ▼ src/core/file_manager/ FileManager — the service layer │ owns: filelist, tag manager, pool index, global config │ ├── domain model file.rs (File), movie.rs (Movie, MediaFile), │ series.rs (Series, Episode), tag.rs, tag_manager.rs ├── persistence pool_index.rs (.Curator.!index), config.rs │ legacy_meta.rs (reads pre-index layouts) ├── naming naming.rs (movie stems, 3D), series.rs (SxxEyy), │ subtitles.rs (sidecar names), movie::identity_key ├── external services tmdb.rs, csfd.rs, jellyfin.rs (over http.rs), │ nfo.rs (read-only) └── output and checks hardlink_manager.rs (miniDLNA tree), transforms.rs, integrity.rs (ffmpeg), migrate.rs ``` Dependencies only point downwards. The GUI calls `FileManager` methods and gets plain values back; `core` never depends on egui. Files are addressed by their current path: methods take a `&Path` and return the new path when they move it. ### Entry point `main.rs` loads `.env`, sets up `tracing` (`RUST_LOG`, or debug when `ENV_DEBUG` is set), builds a `FileManager` and hands it to `ui::run`. HTTPS uses the OS certificate store (reqwest + rustls platform verifier), which matters behind corporate SSL inspection. ### GUI `CuratorApp` (`ui/app.rs`) 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. It keeps a small view model (rows, sidebar counts) recomputed after every change. Each menu action is a method on the app; most open a dialog from `ui/dialogs/` or ask a `Prompt`, then call one `FileManager` method. Dialogs own their form state and talk back through `Env` (the manager, the message queue and `Event`s such as *refresh*, *reload series* or *open another dialog*). Questions inside a dialog (version popisek, confirmations, choices) are `common::Prompt`s stacked on top of it. Long operations run off the UI thread so the window keeps repainting: - **Tasks** (`ui/task.rs`) need the manager — import, TMDb refresh, integrity scan, bulk renames, miniDLNA generation. The `FileManager` is moved into a worker thread and handed back with the result; a modal shows progress and a cancel button meanwhile. - **Jobs** (`common::Job`) are network calls that don't — TMDb / ČSFD lookups in dialogs, Jellyfin checks. ### 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`, `generate_minidlna` and `find_duplicates`. Methods that change files also keep the pool index and the in-memory `filelist` in step (`with_file` runs an operation on the loaded file, or on a freshly loaded one when it isn't in the list). ## 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.rs` 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.rs`). 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::open` 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** — `FileManager::minidlna_scope` decides which libraries take part; `FileManager::generate_minidlna` builds the genre tree through `HardlinkManager`, mirrors the series / homevideo libraries, prunes mirrors that fell out of scope and refills "- Tipy dne" and "- Nově přidané". Libraries left out of scope have their old links swept on the next run. The standalone `tipy-dne` binary refreshes only the tips folder (for a systemd timer). - **TMDb** (`tmdb.rs`) — the identity source. Turns a TMDb id or a title/year search into a canonical title and year, and supplies genres, countries, year and rating for tags. - **ČSFD** (`csfd.rs`) — a bridge for Czech titles: filename → ČSFD film → original title and year → TMDb. Solves ČSFD's Anubis proof-of-work wall. - **Jellyfin** (`jellyfin.rs`) — library scan trigger and a read-back of what Jellyfin sees, used to verify the pool. - **NFO** (`nfo.rs`) — read-only metadata written by Jellyfin, used for tags and to pre-fill imports. ## Configuration Two stores, by responsibility (see `config.rs`): - the **global config** holds application state only — window size and the pool and output paths. It lives in the platform config directory (`~/.config/curator/.Curator.!gtag` on Linux), or wherever `CURATOR_CONFIG` points. On first run a `.Curator.!gtag` left by the Python version in the working directory is picked up, so the paths carry over; - the **pool index** holds everything that describes a pool — libraries, tag schema, miniDLNA selection and all movie records. Its format is unchanged from the Python version, so an existing pool opens as-is. Secrets come from `.env`: `TMDB_API_KEY`, `TMDB_LANGUAGE`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`, and `ENV_DEBUG`. ## Testing `cargo test` runs: - unit tests next to the code (`#[cfg(test)] mod tests`), core modules tested against temporary pools; - `tests/ui.rs` — the GUI driven headlessly with `egui_kittest`: open a dialog, type, click, then check the pool on disk. `examples/screenshot.rs` renders the window (optionally with a dialog open) to a PNG without showing it, for checking layout.