Files
Curator/docs/architecture.md

229 lines
10 KiB
Markdown

# 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-<id>` when the TMDb id is known;
- otherwise `name-<slug>` 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 <FORMÁT>` is 3D; a flat movie
carries the same information as `.3D.<FORMÁT>` 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.<FORMAT>` 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.