Migrate from Poetry and pyenv to uv
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# 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).
|
||||
|
||||
## 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:
|
||||
|
||||
```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.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 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 / 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
|
||||
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 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.
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user