8.8 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 per-module
API reference is in modules/.
Layers
Curator.py entry point — certificates, State, run()
│
▼
src/ui/qt_app.py PySide6 GUI: QtApp (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
│
├── 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
Dependencies only point downwards. The GUI calls FileManager methods and
receives plain objects back; the core modules never import Qt.
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.
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.
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.
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.py 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.py).
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
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 / Filmotéka —
FileManager.minidlna_scopedecides which libraries take part;HardlinkManagerbuilds 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 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.
Configuration
Two stores, by responsibility (see config.py):
- the global config (
.Curator.!gtag, next toCurator.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.
Secrets come from .env: TMDB_API_KEY, JELLYFIN_URL, JELLYFIN_API_KEY,
and an optional debug flag read by constants.py.
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.