Files
Curator/docs/architecture.md

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. 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:

{
  "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.