229 lines
10 KiB
Markdown
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.
|