10 KiB
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 Events such as refresh, reload series or open another
dialog). Questions inside a dialog (version popisek, confirmations, choices)
are common::Prompts 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. TheFileManageris 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:
{
"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_keyinmovie.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 NNorSpecialssubfolder, 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_scopedecides which libraries take part;FileManager::generate_minidlnabuilds the genre tree throughHardlinkManager, 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 standalonetipy-dnebinary 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.!gtagon Linux), or whereverCURATOR_CONFIGpoints. On first run a.Curator.!gtagleft 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 withegui_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.