Rewrite Curator in Rust with an egui GUI

This commit is contained in:
2026-10-17 11:28:17 +02:00
parent 364b515e3c
commit 65b5b74c9f
156 changed files with 24359 additions and 25795 deletions
+86 -54
View File
@@ -1,57 +1,75 @@
# 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).
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
```
Curator.py entry point — certificates, State, run()
src/main.rs entry point — .env, logging, ui::run()
│
▼
src/ui/qt_app.py PySide6 GUI: QtApp (main window) + dialogs
src/ui/ egui GUI: CuratorApp (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
src/core/file_manager/ FileManager — the service layer
│ owns: filelist, tag manager, pool index, 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
├── 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
receives plain objects back; the core modules never import Qt.
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
`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.
`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
`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.
`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`, 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.
`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
@@ -79,7 +97,7 @@ Outside the pool, the Filmotéka / miniDLNA output is a tree of hardlinks made b
## The pool index
`pool_index.py` keeps one JSON document per pool:
`pool_index.rs` keeps one JSON document per pool:
```json
{
@@ -103,14 +121,14 @@ 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.py`).
(`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.load` migrates it on
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`.
@@ -146,51 +164,65 @@ Rules that the code depends on:
- **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::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
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`,
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
- **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.py`) — read-only metadata written by Jellyfin, used to fill fields
of files imported without them.
- **NFO** (`nfo.rs`) — read-only metadata written by Jellyfin, used for tags and
to pre-fill imports.
## Configuration
Two stores, by responsibility (see `config.py`):
Two stores, by responsibility (see `config.rs`):
- 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.
- 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`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`,
and an optional debug flag read by `constants.py`.
Secrets come from `.env`: `TMDB_API_KEY`, `TMDB_LANGUAGE`, `JELLYFIN_URL`,
`JELLYFIN_API_KEY`, and `ENV_DEBUG`.
## 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.
`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.