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
+35 -32
View File
@@ -1,41 +1,44 @@
# Curator documentation
Curator is a PySide6 desktop application that manages a Jellyfin-first movie and
series pool: it imports files, identifies them through TMDb, groups multiple
versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA /
Filmotéka tree.
Curator is a Rust desktop application (egui) that manages a Jellyfin-first movie
and series pool: it imports files, identifies them through TMDb, groups multiple
versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA
tree of hardlinks.
- [Architecture](architecture.md) — layers, on-disk layout, the pool index and the
rules about versions, series and subtitles. Read this first.
- [Module reference](#module-reference) — one document per source module, with a
description of the module and of every class and function.
- **API reference** — every public type and function is documented in the source;
`cargo doc --open` renders it.
## Module reference
## Module overview
| Source | Purpose |
|--------|---------|
| [`Curator.py`](modules/entry-point.md) | Application entry point. |
| [`src/_version.py`](modules/_version.md) | Auto-generated — do not edit manually. |
| [`src/constants.py`](modules/constants.md) | Application-level constants for Curator (build/runtime metadata). |
| [`src/core/config.py`](modules/core.config.md) | Configuration for Curator. |
| [`src/core/csfd.py`](modules/core.csfd.md) | ČSFD.cz scraper — a *title bridge* to TMDb. |
| [`src/core/file.py`](modules/core.file.md) | The `File` class: one playable movie file as the UI sees it. |
| [`src/core/file_manager.py`](modules/core.file_manager.md) | `FileManager`: the application's service layer. |
| [`src/core/hardlink_manager.py`](modules/core.hardlink_manager.md) | Hardlink Manager for Curator |
| [`src/core/integrity.py`](modules/core.integrity.md) | Video integrity checking via FFmpeg. |
| [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. |
| [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. |
| [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. |
| [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. |
| [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. |
| [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. |
| [`src/core/nfo.py`](modules/core.nfo.md) | NFO reader — Curator's metadata source after ČSFD. |
| [`src/core/pool_index.py`](modules/core.pool_index.md) | Unified metadata index for the Curator pool. |
| [`src/core/series.py`](modules/core.series.md) | Series naming + scanning for the Jellyfin TV layout. |
| [`src/core/subtitles.py`](modules/core.subtitles.md) | External subtitle sidecars — attach, discover, keep in sync on rename. |
| [`src/core/tag.py`](modules/core.tag.md) | The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). |
| [`src/core/tag_manager.py`](modules/core.tag_manager.md) | `TagManager`: the registry of all tag categories and their tags. |
| [`src/core/tmdb.py`](modules/core.tmdb.md) | TMDb (The Movie Database) client — Curator's identity source. |
| [`src/core/transforms.py`](modules/core.transforms.md) | Tag-schema value transforms for miniDLNA folder names. |
| [`src/core/utils.py`](modules/core.utils.md) | Small filesystem helpers shared across the core modules. |
| [`src/ui/qt_app.py`](modules/ui.qt_app.md) | PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. |
| [`src/main.rs`](../src/main.rs) | Entry point: `.env`, logging, opens the window. |
| [`src/bin/tipy-dne.rs`](../src/bin/tipy-dne.rs) | Standalone "Tipy dne" generator for a systemd timer. |
| [`src/constants.rs`](../src/constants.rs) | App name, version (from `Cargo.toml`), debug flag, window title. |
| [`src/core/config.rs`](../src/core/config.rs) | Global config (window, pool and output paths) and the default tag schema. |
| [`src/core/error.rs`](../src/core/error.rs) | `CoreError`, the error type of every core operation. |
| [`src/core/paths.rs`](../src/core/paths.rs) | `pathlib`-style helpers: suffix/stem, POSIX-relative paths, recursive walk, moves. |
| [`src/core/naming.rs`](../src/core/naming.rs) | The `Title (YYYY) [tmdbid-N].3D.FMT` naming convention: build, parse, guess from filenames. |
| [`src/core/movie.rs`](../src/core/movie.rs) | The movie record (`Movie`, `MediaFile`), identity keys, version/part labels. |
| [`src/core/file.rs`](../src/core/file.rs) | `File`: one pooled video, a view on one media entry of a movie record. |
| [`src/core/pool_index.rs`](../src/core/pool_index.rs) | `PoolIndex`: the `.Curator.!index` JSON store (v1 → v2 migration). |
| [`src/core/series.rs`](../src/core/series.rs) | Series naming (`SxxEyy`, `Season NN`, `.ptN`) and scanning. |
| [`src/core/subtitles.rs`](../src/core/subtitles.rs) | Subtitle sidecars: find, attach, follow their video on rename. |
| [`src/core/tag.rs`](../src/core/tag.rs), [`tag_manager.rs`](../src/core/tag_manager.rs) | Tags (`Category/Name`) and the registry of known tags. |
| [`src/core/transforms.rs`](../src/core/transforms.rs) | Folder-name transforms (rating → ten-point band). |
| [`src/core/nfo.rs`](../src/core/nfo.rs) | Read-only NFO reader (genres, year, countries, rating, ids). |
| [`src/core/legacy_meta.rs`](../src/core/legacy_meta.rs) | Recovery of ČSFD link / title / year from pre-index layouts. |
| [`src/core/tmdb.rs`](../src/core/tmdb.rs) | TMDb client: lookups, searches, details for tags. |
| [`src/core/csfd.rs`](../src/core/csfd.rs) | ČSFD scraper with the Anubis proof-of-work solver — the title bridge to TMDb. |
| [`src/core/jellyfin.rs`](../src/core/jellyfin.rs) | Jellyfin client: library scan, read back movies. |
| [`src/core/http.rs`](../src/core/http.rs) | Shared blocking HTTP client (OS certificate store). |
| [`src/core/integrity.rs`](../src/core/integrity.rs) | Video integrity check via ffmpeg / ffprobe. |
| [`src/core/migrate.rs`](../src/core/migrate.rs) | Classification of TMDb match proposals for an old pool. |
| [`src/core/hardlink_manager.rs`](../src/core/hardlink_manager.rs) | The miniDLNA hardlink tree, mirrors, special folders, obsolete-link sweep. |
| [`src/core/file_manager/`](../src/core/file_manager/) | `FileManager`, the service layer: libraries and settings (`mod.rs`), movies (`movies.rs`), versions and parts (`versions.rs`), series (`series_ops.rs`), tools (`tools.rs`), miniDLNA generation (`minidlna.rs`). |
| [`src/ui/app.rs`](../src/ui/app.rs) | The main window: menu, tag filter, table, detail panel, status bar, actions. |
| [`src/ui/dialogs/`](../src/ui/dialogs/) | One file per dialog (import, edit, series, duplicates, libraries, migration, tags, schema, stats). |
| [`src/ui/common.rs`](../src/ui/common.rs), [`task.rs`](../src/ui/task.rs) | Message boxes, prompts, background jobs and tasks with progress. |
| [`examples/screenshot.rs`](../examples/screenshot.rs) | Developer tool: render the window offscreen to a PNG. |
+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.
-5
View File
@@ -1,5 +0,0 @@
# `src/_version.py`
Auto-generated — do not edit manually.
Source: [`src/_version.py`](../../src/_version.py)
-34
View File
@@ -1,34 +0,0 @@
# `src/constants.py`
Application-level constants for Curator (build/runtime metadata).
This module derives the app version and debug flag. The version is loaded from
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
It is the single source of truth for app metadata used across the GUI and build
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
Source: [`src/constants.py`](../../src/constants.py)
## Functions
### `get_version`
*function · line 26*
```python
get_version() -> str
```
Return the project version (pyproject → _version.py → unknown).
### `get_debug_mode`
*function · line 55*
```python
get_debug_mode() -> bool
```
Return True when ENV_DEBUG is set to a truthy value in the environment.
-57
View File
@@ -1,57 +0,0 @@
# `src/core/config.py`
Configuration for Curator.
Two stores, by responsibility:
1. Global config (`.Curator.!gtag` next to Curator.py) — **application data
only**: window state, MRU folders, and the pointers to where the pool and the
Filmotéka output live (`pool_dir` / `filmoteka_dir`). Those pointers must
stay here because the pool index is found *through* them.
2. Pool index (`<pool>/.Curator.!index`, see `pool_index.py`) — everything
describing a particular library: per-movie metadata **and** library-level
settings (`tag_schema`, `copyasis_folders`). It travels with the pool.
Source: [`src/core/config.py`](../../src/core/config.py)
## Functions
### `load_global_config`
*function · line 68*
```python
load_global_config() -> dict
```
Load global application config
### `save_global_config`
*function · line 84*
```python
save_global_config(cfg: dict)
```
Save global application config
### `load_config`
*function · line 94*
```python
load_config()
```
Legacy function - returns global config
### `save_config`
*function · line 99*
```python
save_config(cfg: dict)
```
Legacy function - saves global config
-251
View File
@@ -1,251 +0,0 @@
# `src/core/csfd.py`
ČSFD.cz scraper — a *title bridge* to TMDb.
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
distribution title is unreliable (remakes, localized names, "weird" hits).
ČSFD, on the other hand, indexes Czech release names well **and** lists a
film's *original* title. So the bridge is::
filename → ČSFD search → ČSFD film page → original title + year
→ TMDb search → tmdbid
This module only scrapes ČSFD; the TMDb half lives in
`src.core.file_manager` (`FileManager.resolve_via_csfd`). Nothing
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
request gets a 200 with a JS challenge instead of the movie. We detect it,
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
request through a shared `requests.Session` (so a batch pays the PoW
cost only once).
Source: [`src/core/csfd.py`](../../src/core/csfd.py)
## Classes
### `CsfdError`
*class · line 62*
```python
class CsfdError(RuntimeError):
```
A ČSFD lookup could not be completed (deps missing, network, anti-bot).
### `CsfdFilm`
*class · line 67*
```python
class CsfdFilm:
```
The slice of a ČSFD film page Curator needs to reach a TMDb id.
#### `CsfdFilm.tmdb_query`
*property · line 77*
```python
tmdb_query -> tuple[str, Optional[str]]
```
`(title, year)` best suited for a TMDb search — original title first.
## Functions
### `dependencies_available`
*function · line 82*
```python
dependencies_available() -> bool
```
True when `requests` + `beautifulsoup4` are importable.
### `_check_dependencies`
*function · line 87*
```python
_check_dependencies() -> None
```
Raise `CsfdError` when the optional `requests` / `beautifulsoup4` packages are missing (they are needed only for ČSFD scraping).
### `_extract_csfd_id`
*function · line 95*
```python
_extract_csfd_id(url: str) -> Optional[int]
```
Pull the numeric film id out of a ČSFD URL, or `None`.
### `_extract_json_blob`
*function · line 105*
```python
_extract_json_blob(html: str, element_id: str)
```
Return the parsed JSON from an Anubis `<script id=...>` blob, or None.
### `_solve_anubis_pow`
*function · line 120*
```python
_solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]
```
Brute-force the Anubis proof-of-work.
Mirrors the bundled `sha256-purejs` worker: find the smallest `nonce`
such that `sha256(random_data + str(nonce))` has `difficulty` leading
zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
### `_solve_anubis_challenge`
*function · line 143*
```python
_solve_anubis_challenge(session, html: str, url: str)
```
Solve the Anubis challenge in `html` and return the real page response.
### `_get_page`
*function · line 180*
```python
_get_page(session, url: str)
```
GET `url` through `session`, transparently clearing an Anubis wall.
### `_parse_json_ld`
*function · line 193*
```python
_parse_json_ld(soup) -> tuple[str, Optional[str]]
```
`(name, year)` from the film page's JSON-LD (name is the Czech title).
### `_parse_origin`
*function · line 215*
```python
_parse_origin(soup) -> tuple[Optional[str], Optional[str]]
```
`(origin_country, year)` from the `.origin` line.
ČSFD separates values with inline bullet `<span>` elements (no commas),
e.g. `USA • 1999 • 136 min`. For a co-production only the first country
is returned — enough to match it against a name's flag.
### `_parse_original_title`
*function · line 245*
```python
_parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]
```
The film's original title from `.film-names` — or `None` to use the CZ title.
Each `<li>` carries a country/language flag (`img.flag` `title`) and a
title. The original title is the entry whose flag matches the **country of
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
Czech title here when it equals the `<h1>`, so a Czech film has only
foreign/festival alt-titles in this list (`[angličtina] Cosy Dens`,
`[Slovinsko] Samotarji`) — picking one of those would be wrong, so we
return `None` and let the caller fall back to the Czech `<h1>` title.
### `fetch_film`
*function · line 276*
```python
fetch_film(url: str, session=None) -> CsfdFilm
```
Fetch a ČSFD film page → `CsfdFilm` (Czech + original title, year).
Pass a shared `requests.Session` to reuse the Anubis auth cookie across a
batch. Raises `CsfdError` on a network / anti-bot / parse failure.
### `_films_section`
*function · line 311*
```python
_films_section(soup)
```
The `<section>` holding the *Filmy* results, or the whole document.
ČSFD's search page groups hits into `Filmy` / `Seriály` / `Tvůrci` /
`Uživatelé` sections; `a.film-title-name` matches in all of them, so
without scoping a bare-title search happily returns a series episode.
### `_is_film_href`
*function · line 325*
```python
_is_film_href(href: str) -> bool
```
True for a film URL `/film/<id>-slug/` — a series episode has two segments
(`/film/<series>/<episode>/`) and is rejected.
### `search_films`
*function · line 332*
```python
search_films(query: str, limit: int=5, session=None) -> list[CsfdFilm]
```
Search ČSFD for `query`; film hits only (no series/episodes), best first.
### `find_csfd_url`
*function · line 365*
```python
find_csfd_url(query: str, session=None) -> Optional[str]
```
Return the first ČSFD film URL matching `query`, or `None`.
ČSFD's full-text search treats a trailing year as a search term (not a
filter) and can miss on `"Title 2006"` while `"Title"` hits — so a
yearful query with no result is retried without the year.
### `clean_filename_to_query`
*function · line 397*
```python
clean_filename_to_query(filename: str) -> str
```
Turn a (possibly release-named) filename into a ČSFD search query.
Strips the path/extension, splits on common separators and keeps the words
before the first release marker (year, resolution, codec, source, …). The
detected year is appended back as a disambiguator::
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
-227
View File
@@ -1,227 +0,0 @@
# `src/core/file.py`
The `File` class: one playable movie file as the UI sees it. A `File` is a *view* onto one media entry of a movie record in the pool index — metadata (title, tags, ČSFD link, attributes, date) lives on the shared record, so every version of a film reads the same values. See `docs/architecture.md`.
Source: [`src/core/file.py`](../../src/core/file.py)
## Classes
### `File`
*class · line 5*
```python
class File:
```
One movie file in the pool, as the UI sees it. Holds the path, the list of tags and the metadata accessors; metadata itself is stored on the movie record in the pool index and the `File` reads it through it.
#### `File.__init__`
*method · line 6*
```python
__init__(file_path: Path, tagmanager=None, index=None) -> None
```
Create a file object for `file_path`. Metadata is loaded from the pool index when `index` is given; without an index the object is in-memory only.
#### `File._identity_from_path`
*method · line 41*
```python
_identity_from_path() -> tuple[str, str, str | None, int | None, str]
```
`(key, title, year, tmdb_id, label)` implied by this file's location.
A file inside its own `Title (rok) [tmdbid]` folder is one version of
that movie — identity comes from the **folder**, and the rest of its own
stem is the version label. A flat file carries both in its own name.
#### `File.get_metadata`
*method · line 67*
```python
get_metadata() -> None
```
Load this file's metadata from the movie record that owns it.
Since the pool index became identity-keyed (see `src.core.movie`),
a File is a *view* on one media entry of a movie record — every version
of the same film therefore reads the same title/tags/ČSFD/… A file the
index doesn't know yet joins the movie of the same identity when there
is one (that's how a newly added version inherits the shared metadata
instead of blanking it), otherwise it starts a new record.
#### `File._init_new_metadata`
*method · line 95*
```python
_init_new_metadata() -> None
```
Reset all metadata fields to their defaults for a file the index doesn't know yet.
#### `File._build_record`
*method · line 107*
```python
_build_record() -> dict
```
Serialise this file's metadata into the dictionary stored in the pool index (tags as full paths, auto-derived tags, date, title, ČSFD link, …).
#### `File._apply_record`
*method · line 126*
```python
_apply_record(data: dict) -> None
```
Load metadata from an index record dictionary into this object's fields (the inverse of `_build_record`). Missing keys fall back to defaults.
#### `File.save_metadata`
*method · line 152*
```python
save_metadata()
```
Persist metadata into this file's movie record (no-op without index).
Writes the shared fields onto the movie record and makes sure the
record lists this file among its media. A file the index has never seen
starts a new record under its own identity key.
#### `File._library_name`
*method · line 182*
```python
_library_name() -> str
```
Top-level pool folder this file sits in (its library), or `""`.
#### `File.added_timestamp`
*method · line 190*
```python
added_timestamp() -> float
```
Epoch seconds for 'date added' — the stored `added` or the file mtime.
Used to order the "recently added" folder (newer = larger). The fallback
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
inode's ctime, which would make ctime-based ordering meaningless, whereas
mtime (preserved from the source on import) stays stable.
#### `File.name_context`
*method · line 209*
```python
name_context() -> dict
```
Fields for a miniDLNA filename template (see config tag schema).
Provides title / year / rating / ext / stem / filename from the movie's
metadata (year & rating come from the NFO cache, year falling back to a
`Rok` tag, then the canonical filename). Missing values render empty.
#### `File.set_attribute`
*method · line 242*
```python
set_attribute(key: str, value: str | None) -> None
```
Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.
#### `File.delete_metadata`
*method · line 253*
```python
delete_metadata() -> None
```
Drop this file from its movie record; the last one takes the record.
Removing one version of a multi-version movie leaves the movie (and its
metadata) in place — only when its last media entry goes does the whole
record go with it.
#### `File.relocate`
*method · line 273*
```python
relocate(new_path: Path) -> None
```
Point this File at a new path, moving its media entry along.
The physical file must already have been moved/renamed by the caller;
this only repoints the movie record's media entry (and the File), so a
rename never detaches a file from its movie.
#### `File.set_date`
*method · line 294*
```python
set_date(date_str: str | None)
```
Set the date the movie was added (`YYYY-MM-DD`) or clear it with `None` / an empty string, then save the metadata.
#### `File.apply_nfo`
*method · line 303*
```python
apply_nfo(nfo, schema: list[dict] | None=None) -> dict
```
Regenerate the auto (NFO-derived) tags from the movie's NFO.
Reads genres/year/countries/rating from `nfo` (`nfo.NfoData`) per the
tag schema's source fields and (re)creates those tags; user-added tags are
kept. Also caches year/rating (for folder grouping) and fills the title
when missing. `nfo` of `None` (no NFO yet) is a no-op.
Returns:
dict with 'success' and 'tags_added'.
#### `File.add_tag`
*method · line 347*
```python
add_tag(tag)
```
Attach a tag to this file. Accepts a `Tag` or a `category/name` string; a string without a category goes to the `default` category. Saves on change.
#### `File.remove_tag`
*method · line 364*
```python
remove_tag(tag)
```
Detach a tag, given as a `Tag` or a `category/name` string. Saves on change.
File diff suppressed because it is too large Load Diff
-440
View File
@@ -1,440 +0,0 @@
# `src/core/hardlink_manager.py`
Hardlink Manager for Curator
Creates directory structure based on file tags and creates hardlinks
to organize files without duplicating them on disk.
Example:
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
output/
├── žánr/
│ ├── Komedie/
│ │ └── film.mkv (hardlink)
│ └── Akční/
│ └── film.mkv (hardlink)
└── rok/
└── 1988/
└── film.mkv (hardlink)
Source: [`src/core/hardlink_manager.py`](../../src/core/hardlink_manager.py)
## Classes
### `_SafeDict`
*class · line 30*
```python
class _SafeDict(dict):
```
dict for str.format_map that leaves unknown fields as an empty string.
### `HardlinkManager`
*class · line 37*
```python
class HardlinkManager:
```
Manager for creating hardlink-based directory structures from tagged files.
The output layout is driven by a *category → root folder* mapping
(`category_roots`). Each tag is placed at
`output/<root>/<tag_name>/<file>`; an empty root means the tag's own
folders sit directly at the output root (e.g. genre folders next to the
"Dle roku" / "Dle země původu" folders). The legacy `categories` list
(folder == category name) is still accepted and treated as the identity
mapping `{cat: cat}`.
#### `HardlinkManager.__init__`
*method · line 49*
```python
__init__(output_dir: Path)
```
Initialize HardlinkManager.
Args:
output_dir: Base directory where the tag-based structure will be created
#### `HardlinkManager._resolve_roots`
*method · line 60*
```python
_resolve_roots(categories: Optional[List[str]], category_roots: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]
```
Normalize the two filter styles into a category → root-folder map.
`None` means "all categories", folder == category name.
#### `HardlinkManager._clean_title`
*method · line 75*
```python
_clean_title(file_obj: File) -> str
```
The movie's clean title (no trailing `(YYYY)`), path-safe.
#### `HardlinkManager._default_link_name`
*method · line 85*
```python
_default_link_name(file_obj: File) -> str
```
Filmotéka hardlink name: the clean title + extension (no year).
#### `HardlinkManager._year_link_name`
*method · line 89*
```python
_year_link_name(file_obj: File) -> Optional[str]
```
`Title (YYYY).ext` used to disambiguate a name collision, or None.
#### `HardlinkManager._render_template`
*method · line 96*
```python
_render_template(file_obj: File, template: str) -> str
```
Render a per-category filename template; fall back to the clean name.
#### `HardlinkManager._plan_links`
*method · line 105*
```python
_plan_links(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]], templates: Optional[Dict[str, str]]) -> List[Tuple[File, Path]]
```
Resolve the tag tree to a collision-free `(file, link_path)` plan.
The default hardlink name is the movie's **clean title** (no year). When
two *different* movies would land on the same name in the same folder,
the colliding ones fall back to the year-qualified `Title (YYYY).ext` to
tell them apart; a residual clash (same title *and* year) gets a stable
numeric suffix so every path is unique. A per-category
`filename_template` overrides the default inside that category's folders.
Both link creation and obsolete detection consume this one plan, so they
can never disagree on a name (which is what caused hardlink churn before).
#### `HardlinkManager._folder_value`
*method · line 172*
```python
_folder_value(tag, transforms: Optional[Dict[str, str]]) -> str
```
Folder name for a tag — its value run through the category transform.
The tag keeps its exact value; the grouping (e.g. rating → ten-point
band) only happens here, when naming the folder.
#### `HardlinkManager._target_dir`
*method · line 183*
```python
_target_dir(tag, roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None) -> Optional[Path]
```
Output directory for a tag, or None if its category is excluded.
#### `HardlinkManager._managed_top_dirs`
*method · line 197*
```python
_managed_top_dirs(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Optional[Set[str]]
```
Top-level output folders owned by the tag tree (None = all of them).
A category with a non-empty root owns that root folder. A category placed
at the output root (empty root, e.g. genres) owns its own folders at the
root — and, so a genre whose last movie dropped the tag still gets its
now-stale folder cleaned, *every* root-level folder is treated as owned
except the grouping roots and any `reserved_subfolders` (copy-as-is
mirrors, "Tipy dne", …). The obsolete check still only removes links to
current pool files, so reserved/foreign folders are doubly protected.
#### `HardlinkManager.create_structure_for_files`
*method · line 236*
```python
create_structure_for_files(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> Tuple[int, int]
```
Create hardlink structure for given files based on their tags.
Args:
files: List of File objects to process
categories: Optional list of categories to include (None = all)
dry_run: If True, only simulate without creating actual links
category_roots: Optional category → root-folder map (see class doc);
overrides `categories` when given.
category_transforms: Optional category → transform-name map applied to
the tag value when naming its folder (e.g. rating → decade band).
category_filename_templates: Optional category → hardlink-name template
applied only inside that category's folders.
Returns:
Tuple of (successful_links, failed_links)
#### `HardlinkManager.mirror_as_is`
*method · line 294*
```python
mirror_as_is(source_dir: Path, subfolder: str | None=None, dry_run: bool=False) -> Tuple[int, int]
```
Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
Recreates the exact directory hierarchy of `source_dir` under
`output_dir/subfolder` (or directly under `output_dir` when
`subfolder` is None) and hardlinks every file. Curator metadata files
(`.!tag` / `.!ftag` / `.!gtag` / `.!index`) are skipped.
Used for Seriály: the pool structure is the source of truth and is cloned
verbatim instead of being rebuilt from tags.
Returns:
Tuple of (successful_links, failed_links)
#### `HardlinkManager.remove_mirror`
*method · line 345*
```python
remove_mirror(name: str) -> bool
```
Remove `output_dir/name` entirely (file or folder tree).
Used to drop a whole mirrored library (or one entry of the shared
mixed-library-series bucket) when it falls out of the miniDLNA
generation's scope. Mirrors are pure hardlinks, so deleting the folder
loses nothing — it's fully regenerable from the pool. `name` may
contain "/" to address one entry inside a bucket folder.
Returns whether anything was actually removed.
#### `HardlinkManager._fill_special_folder`
*method · line 365*
```python
_fill_special_folder(chosen: List[File], subfolder: str, dry_run: bool) -> Tuple[int, int]
```
Empty `subfolder` and hardlink `chosen` files into it.
#### `HardlinkManager.generate_recently_added`
*method · line 405*
```python
generate_recently_added(files: List[File], count: int=10, subfolder: str='- Nově přidané', dry_run: bool=False) -> Tuple[int, int]
```
(Re)fill `subfolder` with the `count` most recently added movies.
Ordering is by `File.added_timestamp()` (import time, ctime fallback),
newest first. Returns (created_links, failed_links).
#### `HardlinkManager.generate_random_tips`
*method · line 420*
```python
generate_random_tips(files: List[File], count: int=10, subfolder: str='Tipy dne', dry_run: bool=False) -> Tuple[int, int]
```
(Re)fill `subfolder` with `count` random files as hardlinks.
The folder is emptied first so each run picks a fresh random selection.
`files` should be the pool movies only (copy-as-is mirrors excluded).
Returns:
Tuple of (created_links, failed_links)
#### `HardlinkManager._is_same_file`
*method · line 438*
```python
_is_same_file(path1: Path, path2: Path) -> bool
```
Check if two paths point to the same file (same inode).
#### `HardlinkManager._get_unique_name`
*method · line 445*
```python
_get_unique_name(path: Path) -> Path
```
Get a unique filename by adding a numeric suffix.
#### `HardlinkManager.remove_created_links`
*method · line 459*
```python
remove_created_links() -> int
```
Remove all hardlinks created by the last operation.
Returns:
Number of links removed
#### `HardlinkManager._remove_empty_parents`
*method · line 481*
```python
_remove_empty_parents(path: Path) -> None
```
Remove empty parent directories up to output_dir.
#### `HardlinkManager.get_preview`
*method · line 492*
```python
get_preview(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> List[Tuple[Path, Path]]
```
Get a preview of what links would be created.
Args:
files: List of File objects
categories: Optional list of categories to include
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
Returns:
List of tuples (source_path, target_path)
#### `HardlinkManager.find_obsolete_links`
*method · line 519*
```python
find_obsolete_links(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> List[Tuple[Path, Path]]
```
Find hardlinks in the output directory that no longer match file tags.
Scans the managed parts of the output directory for hardlinks that point
to source files but whose path no longer matches the file's current tags.
Only the tag-tree's own top-level folders are scanned, so copy-as-is
mirrors (e.g. Seriály) are left untouched.
Args:
files: List of File objects (source files)
categories: Optional list of categories to check (None = all)
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
Returns:
List of tuples (link_path, source_path) for obsolete links
#### `HardlinkManager.remove_obsolete_links`
*method · line 608*
```python
remove_obsolete_links(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, List[Path]]
```
Remove hardlinks that no longer match file tags.
Args:
files: List of File objects
categories: Optional list of categories to check
dry_run: If True, only return what would be removed
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
reserved_subfolders: Output top-level folders to never touch (mirrors,
"Tipy dne", …).
Returns:
Tuple of (removed_count, list_of_removed_paths)
#### `HardlinkManager.sync_structure`
*method · line 655*
```python
sync_structure(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, int, int, int]
```
Synchronize hardlink structure with current file tags.
This will:
1. Remove hardlinks for removed tags
2. Create new hardlinks for new tags
Args:
files: List of File objects
categories: Optional list of categories to sync
dry_run: If True, only simulate
category_roots: Optional category → root-folder map (overrides
`categories` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
reserved_subfolders: Output top-level folders to never touch (mirrors,
"Tipy dne", …).
Returns:
Tuple of (created, create_failed, removed, remove_failed)
## Functions
### `create_hardlink_structure`
*function · line 707*
```python
create_hardlink_structure(files: List[File], output_dir: Path, categories: Optional[List[str]]=None) -> Tuple[int, int, List[Tuple[Path, str]]]
```
Convenience function to create hardlink structure.
Args:
files: List of File objects to process
output_dir: Base directory for output
categories: Optional list of categories to include
Returns:
Tuple of (successful_count, failed_count, errors_list)
-77
View File
@@ -1,77 +0,0 @@
# `src/core/integrity.py`
Video integrity checking via FFmpeg.
Detects corrupted or unreadable video files. Two depths:
- **deep** (default): a full `ffmpeg` decode to the null muxer
(`-f null`). It actually decodes every frame, so it catches mid-file data
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
it reads and decodes the whole file.
- **quick**: an `ffprobe` container/stream probe. Fast but shallow — it catches
an unreadable container, a truncated header or a missing video stream, but not
corruption buried inside otherwise-parseable data.
Both tools ship with FFmpeg; `ffmpeg_available` reports whether they are on the
PATH so callers can degrade gracefully.
Source: [`src/core/integrity.py`](../../src/core/integrity.py)
## Classes
### `IntegrityResult`
*class · line 30*
```python
class IntegrityResult:
```
Outcome of checking one file.
`ok` — the file decoded/probed cleanly.
`checked` — the check actually ran; False means it couldn't (missing tool,
missing file), which is *not* the same as "corrupted".
`error` — a short reason when not `ok`.
## Functions
### `ffmpeg_available`
*function · line 45*
```python
ffmpeg_available(deep: bool=True) -> bool
```
Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?
### `check_video_integrity`
*function · line 50*
```python
check_video_integrity(path: Path, deep: bool=True, timeout: Optional[int]=None) -> IntegrityResult
```
Check a single video file for corruption.
Args:
path: the video file to check.
deep: True → full ffmpeg decode (catches data corruption, slow);
False → ffprobe probe (catches unreadable/truncated files, fast).
timeout: per-file timeout in seconds (None = no limit).
Returns:
An `IntegrityResult`. `checked=False` when the check could not
be run at all (tool or file missing) — distinct from a real failure.
### `_first_error_line`
*function · line 108*
```python
_first_error_line(stderr: str) -> str
```
First non-empty stderr line, truncated — a compact reason for the report.
-105
View File
@@ -1,105 +0,0 @@
# `src/core/jellyfin.py`
Jellyfin server API client — thin, hybrid integration.
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
this client only does what the disk can't: **trigger a library scan** and **read
back what Jellyfin currently sees** (to verify the pool against it). Connection
details come from `.env` — `JELLYFIN_URL` and `JELLYFIN_API_KEY` (created in
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
Source: [`src/core/jellyfin.py`](../../src/core/jellyfin.py)
## Classes
### `JellyfinMovie`
*class · line 25*
```python
class JellyfinMovie:
```
A movie as Jellyfin currently sees it (for verification).
### `JellyfinError`
*class · line 35*
```python
class JellyfinError(RuntimeError):
```
A Jellyfin request could not be completed (no config, network, status).
## Functions
### `base_url`
*function · line 39*
```python
base_url() -> Optional[str]
```
The configured Jellyfin base URL (no trailing slash), or None.
### `api_key`
*function · line 45*
```python
api_key() -> Optional[str]
```
The configured Jellyfin API key, or None.
### `api_available`
*function · line 51*
```python
api_available() -> bool
```
True when both the Jellyfin URL and API key are configured.
### `_request`
*function · line 56*
```python
_request(method: str, path: str, params: Optional[dict]=None, timeout: Optional[int]=None) -> requests.Response
```
Send one authenticated request to the Jellyfin server (`X-Emby-Token` header). Raises `JellyfinError` when the URL/key are missing or the server can't be reached.
### `server_version`
*function · line 74*
```python
server_version(timeout: Optional[int]=None) -> str
```
Return the Jellyfin server version (a quick connectivity check).
### `scan_all`
*function · line 80*
```python
scan_all() -> None
```
Trigger a scan of all Jellyfin libraries (picks up on-disk changes).
### `fetch_movies`
*function · line 85*
```python
fetch_movies() -> list[JellyfinMovie]
```
Read every movie Jellyfin currently has, with its TMDb id and path.
-83
View File
@@ -1,83 +0,0 @@
# `src/core/legacy_meta.py`
Readers for Curator's *pre-index* metadata layouts.
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
a movie's metadata in a **per-file sidecar** `.{filename}.!tag` (JSON) sitting
next to the video. When importing a file that came from such a pool we can
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
`.Curator.!index` found by walking up from the file.
This is read-only recovery for the import dialog; nothing here writes.
Source: [`src/core/legacy_meta.py`](../../src/core/legacy_meta.py)
## Classes
### `LegacyMeta`
*class · line 25*
```python
class LegacyMeta:
```
What an old sidecar / index record can still tell us about a movie.
## Functions
### `_record_to_meta`
*function · line 34*
```python
_record_to_meta(record: object, source: str) -> Optional[LegacyMeta]
```
Pull the useful fields out of a stored movie record (any schema version).
### `_from_sidecar`
*function · line 51*
```python
_from_sidecar(video_path: Path) -> Optional[LegacyMeta]
```
Read the metadata from the per-file `.{name}.!tag` JSON sidecar next to a video, or return `None` when there is no readable sidecar.
### `_lookup_in_index`
*function · line 62*
```python
_lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optional[dict]
```
Find the record for `video_path` in an index's `movies` map.
Tries the exact pool-relative key first, then falls back to a unique
basename match (the file may have moved since the index was written).
### `_from_legacy_index`
*function · line 79*
```python
_from_legacy_index(video_path: Path) -> Optional[LegacyMeta]
```
Read the metadata from a `.Curator.!index` file found by walking up at most `_MAX_INDEX_WALK_UP` directories from the video. Returns `None` if nothing matches.
### `read_legacy_meta`
*function · line 99*
```python
read_legacy_meta(video_path: Path) -> Optional[LegacyMeta]
```
Recover ČSFD link / title / year from a pre-index layout, or `None`.
Order: a per-file `.{name}.!tag` sidecar next to the video, then a
`.Curator.!index` found walking up (max 4 levels) from the file.
-47
View File
@@ -1,47 +0,0 @@
# `src/core/list_manager.py`
Sorting of file lists by name or by date added. Small helper used by the file list views.
Source: [`src/core/list_manager.py`](../../src/core/list_manager.py)
## Classes
### `ListManager`
*class · line 4*
```python
class ListManager:
```
Holds the current sort mode (`name` or `date`) and sorts file lists accordingly.
#### `ListManager.__init__`
*method · line 5*
```python
__init__()
```
Start sorting by name.
#### `ListManager.set_sort`
*method · line 9*
```python
set_sort(mode: str)
```
Switch the sort mode to `name` or `date`; other values are ignored.
#### `ListManager.sort_files`
*method · line 13*
```python
sort_files(files: List[File]) -> List[File]
```
Return the files sorted by name (case-insensitive) or by date. Files without a date go last in date mode.
-62
View File
@@ -1,62 +0,0 @@
# `src/core/migrate.py`
One-off migration of an existing pool to the Jellyfin naming convention.
Older pools carry files like `Title.ext` / `Title (rok).ext` with **no TMDb
id**. This matches each such movie against TMDb by title + year and proposes a
`tmdbid` (and the missing year, which TMDb supplies), so the file can be renamed
to `Title (rok) [tmdbid].ext` and Jellyfin can match it deterministically.
The classification is deliberately **conservative**: only an exact
(diacritics-insensitive) title match with a matching year and a single candidate
is `auto`; everything else is `ambiguous` for the user to confirm.
Source: [`src/core/migrate.py`](../../src/core/migrate.py)
## Classes
### `MatchProposal`
*class · line 19*
```python
class MatchProposal:
```
One movie's proposed TMDb match.
## Functions
### `normalize`
*function · line 33*
```python
normalize(text: str) -> str
```
Fold a title for comparison: strip diacritics/punctuation, lowercase.
### `_year_close`
*function · line 41*
```python
_year_close(a: Optional[str], b: Optional[str]) -> bool
```
True when two year strings are both numeric and differ by at most one year (release years often disagree by one).
### `classify`
*function · line 47*
```python
classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]
```
Return `(status, chosen)` for a title/year against TMDb candidates.
`auto` requires an exact normalized-title match, a year within ±1, and that
exactly one candidate qualifies. Otherwise `ambiguous` (top hit preselected)
or `no_match` when there are no candidates.
-207
View File
@@ -1,207 +0,0 @@
# `src/core/movie.py`
The movie object — metadata first, files second.
A movie is identified by `(title, year, tmdb_id)` and owns **its files**, not
the other way round. That inversion is the point: the pool index used to key a
record by a file path, so metadata was a passenger on one particular file — and
everything about multi-version movies got awkward (which file "holds" the tags,
why renaming a grouped movie had to be blocked, why an import could silently
create a duplicate instead of a version).
Here the record is the movie; each playable file is a `MediaFile` under
it, told apart by a free-text `label` (`3D HSBS`, `4K`, `Director's
Cut`, …). Where those files *live* is then a pure function of the record —
`Movie.desired_layout` — so promoting to a folder, demoting back, or
renaming the whole thing are all the same operation: recompute and move what
doesn't match.
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
one video <Library>/Title (rok) [tmdbid-N].ext
two or more <Library>/Title (rok) [tmdbid-N]/
Title (rok) [tmdbid-N] - Výchozí.ext
Title (rok) [tmdbid-N].3D.HSBS.ext
Every file in a movie folder must start with the folder's exact name; both
`" - label"` and `".3D.FMT"` are valid separators, so a 3D file keeps the
documented `.3D.<FORMAT>` spelling whether it sits flat or grouped.
Subtitles are not media entries — they ride along with their video by filename
stem (see `src.core.subtitles`).
Source: [`src/core/movie.py`](../../src/core/movie.py)
## Classes
### `MediaFile`
*class · line 102*
```python
class MediaFile:
```
One file belonging to a movie (today always a playable video).
#### `MediaFile.format_3d`
*property · line 110*
```python
format_3d -> Optional[str]
```
The 3D format this version's label denotes, if any.
#### `MediaFile.to_record`
*method · line 114*
```python
to_record(pool_dir: Path) -> dict
```
Serialise for the pool index (path stored pool-relative, POSIX).
#### `MediaFile.from_record`
*class method · line 123*
```python
from_record(data: dict, pool_dir: Path) -> 'MediaFile'
```
Build a `MediaFile` from one entry of a movie record's `media` list, resolving its relative path against the pool directory.
### `Movie`
*class · line 132*
```python
class Movie:
```
A film in the pool: its identity, its metadata, and its media files.
#### `Movie.videos`
*property · line 156*
```python
videos -> list[MediaFile]
```
Playable versions, in a stable order (labelled ones by label).
#### `Movie.is_grouped`
*property · line 163*
```python
is_grouped -> bool
```
True when the movie needs its own folder (more than one version).
#### `Movie.stem`
*property · line 168*
```python
stem -> str
```
Canonical `Title (rok) [tmdbid-N]` — the folder / filename base.
Falls back to the bare title when no four-digit year is known (a movie
can be imported without one).
#### `Movie.primary`
*property · line 182*
```python
primary -> Optional[MediaFile]
```
The version that represents the movie (first by `videos`).
#### `Movie.desired_layout`
*method · line 187*
```python
desired_layout(pool_dir: Path) -> list[tuple[MediaFile, Path]]
```
`(media, where it belongs)` for every file, from the record alone.
The whole flat-vs-folder rule lives here: one video sits directly in
the library, two or more move into `<Library>/<stem>/`. Callers move
whatever doesn't already match — which makes promotion, demotion and
renaming the same operation.
#### `Movie.to_record`
*method · line 208*
```python
to_record(pool_dir: Path) -> dict
```
Serialise the movie into the dictionary stored in the pool index, with media paths relative to the pool directory.
#### `Movie.from_record`
*class method · line 227*
```python
from_record(key: str, data: dict, pool_dir: Path, tagmanager=None) -> 'Movie'
```
Build a `Movie` from an index record stored under `key`. Tags are resolved through `tagmanager` when one is given.
## Functions
### `normalize_3d_label`
*function · line 53*
```python
normalize_3d_label(label: object) -> Optional[str]
```
The 3D format a version label denotes (upper-case), or `None`.
Lets a plain label double as the 3D marker — `"3D HSBS"` typed in the
version dialog and `"hsbs"` picked from a dropdown mean the same thing.
### `media_filename`
*function · line 63*
```python
media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str
```
Filename for one media file of a movie whose canonical stem is `stem`.
- no label → `stem.ext` (a lone, plain video)
- 3D label → `stem.3D.<FORMAT>.ext` (Jellyfin's documented 3D form)
- anything else → `stem - label.ext`
`grouped` only decides whether an empty label is allowed: inside a movie
folder every file gets one, so it falls back to `DEFAULT_VERSION_LABEL`.
### `identity_key`
*function · line 84*
```python
identity_key(tmdb_id: object=None, title: str='', year: object=None) -> str
```
The pool-index key for a movie — `tmdb-<id>`, else a title/year slug.
Keying by identity (rather than by file path) is what lets a second file of
the same film find its movie instead of becoming a separate entry.
-119
View File
@@ -1,119 +0,0 @@
# `src/core/naming.py`
Pool file naming convention.
Every movie in the pool is named `Title (YYYY) [tmdbid-N].ext` — a title, a
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
is exactly the form Jellyfin matches on. The `[tmdbid-N]` part is optional: a
plain `Title (YYYY)` is still valid (Jellyfin then matches by name). The year
lets two same-named films coexist and makes the filename self-describing.
A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
`Title (YYYY) [tmdbid-N].3D.HSBS.ext`. `FORMAT` is one of
`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
writes it upper-case for a single canonical form.
These helpers build, validate and parse the stem (the filename without its
extension).
Source: [`src/core/naming.py`](../../src/core/naming.py)
## Functions
### `is_valid_year`
*function · line 35*
```python
is_valid_year(year: object) -> bool
```
True when `year` is (or stringifies to) exactly four digits.
### `canonical_pool_stem`
*function · line 40*
```python
canonical_pool_stem(title: str, year: object, tmdb_id: object=None, format_3d: object=None) -> str
```
Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` /
`.3D.<FORMAT>` if given).
Raises:
ValueError: the title is empty, the year is not four digits, a given
`tmdb_id` is not a positive integer, or `format_3d` isn't one
of `THREE_D_FORMATS`.
### `parse_pool_stem`
*function · line 73*
```python
parse_pool_stem(stem: str) -> tuple[str, int] | None
```
Split a canonical stem into `(title, year)`; `None` if it isn't one.
A trailing `[tmdbid-N]` is tolerated and dropped (use `parse_tmdb_id`
to read it), so the returned title is always the clean title.
### `parse_tmdb_id`
*function · line 85*
```python
parse_tmdb_id(stem: str) -> int | None
```
Read the `[tmdbid-N]` from a canonical stem, or `None` if absent.
### `parse_3d_format`
*function · line 93*
```python
parse_3d_format(stem: str) -> str | None
```
Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased), or
`None` if absent.
### `is_canonical_pool_stem`
*function · line 102*
```python
is_canonical_pool_stem(stem: str) -> bool
```
True when `stem` matches the `Title (YYYY)` convention.
### `year_from_filename`
*function · line 107*
```python
year_from_filename(name: str) -> str | None
```
Best-effort four-digit year guessed from a raw (release) filename.
Prefers a parenthesised year (`… (1999) …`) and otherwise falls back to the
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
user confirms it. Returns the year as a string, or `None`.
### `three_d_format_from_filename`
*function · line 129*
```python
three_d_format_from_filename(name: str) -> str | None
```
Best-effort 3D format guessed from a raw (release) filename, e.g.
`Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. Unlike `parse_3d_format`
this doesn't require the rest of the name to be a canonical pool stem — used
to pre-fill a version label while importing. Returns `None` if absent.
-103
View File
@@ -1,103 +0,0 @@
# `src/core/nfo.py`
NFO reader — Curator's metadata source after ČSFD.
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
`.nfo` next to each movie plus artwork. Curator only **reads** it — to drive the
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
writes NFO (Jellyfin owns that).
For a flat pool file `Title (rok) [tmdbid].mkv` the sidecar is
`Title (rok) [tmdbid].nfo`; a per-movie folder may instead hold `movie.nfo`.
Both are found. Missing or unparsable NFO → `None` (the movie simply has no
genres until Jellyfin has scanned it).
Source: [`src/core/nfo.py`](../../src/core/nfo.py)
## Classes
### `NfoData`
*class · line 20*
```python
class NfoData:
```
The bits of a movie `.nfo` Curator uses.
#### `NfoData.field_values`
*method · line 31*
```python
field_values(name: str) -> list[str]
```
Values for a tag-schema source field (genres/countries/year/rating).
## Functions
### `find_nfo`
*function · line 44*
```python
find_nfo(video_path: Path) -> Optional[Path]
```
Locate the `.nfo` for a movie: sibling `<stem>.nfo` or `movie.nfo`.
### `_texts`
*function · line 56*
```python
_texts(root: ET.Element, tag: str) -> list[str]
```
All non-empty stripped texts of the child elements called `tag`.
### `_parse_rating`
*function · line 60*
```python
_parse_rating(root: ET.Element) -> Optional[int]
```
Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands).
### `_parse_tmdb_id`
*function · line 74*
```python
_parse_tmdb_id(root: ET.Element) -> Optional[int]
```
The TMDb id from a Kodi NFO: a `<uniqueid type="tmdb">` element, or a legacy `<tmdbid>` element. `None` if neither holds a number.
### `_parse_csfd_url`
*function · line 82*
```python
_parse_csfd_url(root: ET.Element) -> Optional[str]
```
A ČSFD film URL from the NFO, if any.
Czech Kodi scrapers record ČSFD as `<uniqueid type="csfd">` (a bare id or a
full URL) or a legacy `<csfdid>` / `<csfd>` element. A bare id is expanded
to `https://www.csfd.cz/film/<id>/`.
### `read_nfo`
*function · line 105*
```python
read_nfo(video_path: Path) -> Optional[NfoData]
```
Read the movie's `.nfo` into `NfoData`, or None if absent/broken.
-191
View File
@@ -1,191 +0,0 @@
# `src/core/pool_index.py`
Unified metadata index for the Curator pool.
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
at `<pool>/.Curator.!index`. Curator owns the pool (it inserts/removes files
itself), so files never move behind its back and a central index is safe.
The index also carries **library-level settings** (`tag_schema`,
`copyasis_folders`) under a `settings` section, so everything describing a
particular pool travels with the pool. The global config keeps only app data
(window state, MRU) and the pointers to where the pool/output live.
On-disk shape (**v2**)::
{"version": 2,
"settings": {...},
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
Records are keyed by the movie's **identity** (`tmdb-<id>`, or a title/year
slug when there's no id) and own their files — see `src.core.movie`. The
original **v1** shape keyed a record by one file's pool-relative path, which
made metadata a passenger on that file; `PoolIndex.load` migrates such an
index in place (backing the old file up first). Migration only rewrites
metadata — no file on disk is moved, because a one-video movie is already in
its v2 position.
Source: [`src/core/pool_index.py`](../../src/core/pool_index.py)
## Classes
### `PoolIndex`
*class · line 35*
```python
class PoolIndex:
```
Single-file JSON metadata store for all files in a pool.
#### `PoolIndex.__init__`
*method · line 38*
```python
__init__(pool_dir: Path) -> None
```
Open the index of the pool at `pool_dir` (loads it, migrating an older version if needed).
#### `PoolIndex._key`
*method · line 45*
```python
_key(file_path: Path) -> str
```
Pool-relative POSIX key for a file path.
#### `PoolIndex.load`
*method · line 53*
```python
load() -> None
```
Load the index from disk (missing/corrupt index = empty).
A v1 (path-keyed) index is migrated to v2 in place — the old file is
copied to `.Curator.!index.v1.bak` first.
#### `PoolIndex._migrate_v1_to_v2`
*method · line 76*
```python
_migrate_v1_to_v2() -> None
```
Re-key path-keyed records into identity-keyed movie records.
Each v1 record described one file; several of them can describe one
movie (the interim multi-version layout put them in a shared folder),
so they are merged by identity. Metadata only — nothing is moved on
disk, since a v1 movie's files already sit where v2 wants them.
#### `PoolIndex.save`
*method · line 139*
```python
save() -> None
```
Persist the index to disk.
#### `PoolIndex.get_setting`
*method · line 150*
```python
get_setting(key: str, default=None)
```
Return a library-level setting stored in the index.
#### `PoolIndex.set_setting`
*method · line 154*
```python
set_setting(key: str, value) -> None
```
Upsert a library-level setting and persist the index.
#### `PoolIndex.get_movie`
*method · line 163*
```python
get_movie(key: str) -> dict | None
```
Return the movie record under `key`, or None.
#### `PoolIndex.set_movie`
*method · line 167*
```python
set_movie(key: str, data: dict) -> None
```
Upsert a movie record and persist the index.
#### `PoolIndex.delete_movie`
*method · line 172*
```python
delete_movie(key: str) -> None
```
Remove a movie record (if present) and persist the index.
#### `PoolIndex.rekey_movie`
*method · line 177*
```python
rekey_movie(old_key: str, new_key: str) -> None
```
Move a record to a new identity key (e.g. its TMDb id was set/fixed).
#### `PoolIndex.all_movies`
*method · line 184*
```python
all_movies() -> dict[str, dict]
```
Every movie record, keyed by identity.
#### `PoolIndex.record_for`
*method · line 188*
```python
record_for(file_path: Path) -> dict | None
```
The movie record that owns `file_path`, or None if untracked.
#### `PoolIndex.find_movie_by_media`
*method · line 193*
```python
find_movie_by_media(file_path: Path) -> tuple[str, dict] | None
```
`(key, record)` of the movie owning `file_path`, or None.
The reverse lookup a file needs now that records are keyed by identity
rather than by path.
-227
View File
@@ -1,227 +0,0 @@
# `src/core/series.py`
Series naming + scanning for the Jellyfin TV layout.
Jellyfin wants series laid out as::
Seriály/Series (year) [tmdbid-N]/
tvshow.nfo (Jellyfin writes it)
Season 01/
Series (year) S01E01.ext
Specials/ (season 0)
Episodes are matched by `SxxEyy`. These helpers parse the episode number out of
a raw filename, build the canonical episode/season/series names, and scan a
series library into a `Series → Season → Episode` tree with a naming status.
Source: [`src/core/series.py`](../../src/core/series.py)
## Classes
### `Episode`
*class · line 49*
```python
class Episode:
```
One episode file found in a series folder: its path, its season and episode numbers (`None` when not recognised) and its part number when the episode is split across several files.
#### `Episode.code`
*property · line 58*
```python
code -> str
```
Human-readable episode code, e.g. `S01E02`, with `.ptN` appended for a part (`S01E02.pt3`). `?` when the season or episode is unknown.
### `Series`
*class · line 66*
```python
class Series:
```
One series folder with its title, year, TMDb id, library and the episodes found below it.
#### `Series.seasons`
*method · line 74*
```python
seasons() -> dict[int, list[Episode]]
```
Episodes grouped by season number (`-1` = unrecognised).
## Functions
### `parse_episode`
*function · line 84*
```python
parse_episode(name: str) -> Optional[tuple[int, int]]
```
Extract `(season, episode)` from a filename, or `None`.
### `parse_episode_part`
*function · line 94*
```python
parse_episode_part(name: str) -> Optional[int]
```
Part number implied by a Jellyfin stacking suffix in a filename, or `None`.
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
right before the extension (`Show S01E02.pt2.mkv`,
`Show S01E02-part-2.mkv`, …) — the same tokens Jellyfin itself scans for.
### `parse_episode_code`
*function · line 105*
```python
parse_episode_code(text: str) -> Optional[tuple[int, int, Optional[int]]]
```
Parse a compact user-typed code: `SxxEyy`, optionally + a part.
Accepts everything `parse_episode` does, plus a trailing part marker
right after it — `S01E02P3`, `S01E02 pt3`, `S01E02.part.3`,
`S01E02 cd2` — for a file that is only *one part* of that episode.
Returns `(season, episode, part)` with `part` `None` when absent, or
`None` when no `SxxEyy` is found at all.
### `collect_episode_files`
*function · line 126*
```python
collect_episode_files(folder: Path) -> list[Path]
```
Every video file under `folder` (recursively), sidecars excluded, sorted.
Lets the episode-import dialog take a whole `Series/Season NN/` tree (or any
nested layout) in one pick instead of file-by-file. Season/episode numbers
are still read per file from its name by `parse_episode`.
### `season_folder_name`
*function · line 142*
```python
season_folder_name(season: int) -> str
```
Folder name for a season — `Specials` for 0, else `Season NN`.
### `is_season_folder`
*function · line 147*
```python
is_season_folder(name: str) -> bool
```
True for a season folder name — `Specials` or `Season <n>` (any padding).
### `looks_like_series_folder`
*function · line 152*
```python
looks_like_series_folder(folder: Path) -> bool
```
True when `folder` holds at least one `Season NN`/`Specials` subfolder.
That's the shape Curator always gives a series (episodes always land in a
season folder, never loose at the series root) — a movie's version folder
never has one, its files sit flat inside it. This is how a `mixed`
library tells the two apart even though both use the identical outer
`Title (rok) [tmdbid]/` name: `FileManager.is_movie_group_member`
and `scan_series` both check it before deciding which one a
canonical-shaped subfolder is.
### `sanitize_title`
*function · line 173*
```python
sanitize_title(title: str) -> str
```
A series/episode title made safe to sit in a filename (spaces collapsed).
### `episode_filename`
*function · line 179*
```python
episode_filename(season: int, episode: int, series_title: str, ext: str, *, part: Optional[int]=None) -> str
```
Canonical episode filename `SxxEyy - {series title}.ext`.
The release string is deliberately **not** kept — identity is the `SxxEyy`
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
the code + the series folder's `[tmdbid-N]`). An empty title collapses to
`SxxEyy.ext`. `part` marks one physical part of that episode — an old
recording split across several files — with Jellyfin's own `.ptN`
stacking suffix right before the extension.
### `series_folder_stem`
*function · line 200*
```python
series_folder_stem(title: str, year: object=None, tmdb_id: object=None) -> str
```
Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
Reuses the movie convention when a year is present; otherwise falls back to
`Title [tmdbid-N]` or just `Title` so a year-less series still works.
### `is_canonical_episode`
*function · line 216*
```python
is_canonical_episode(path: Path, series_title: str) -> bool
```
True when `path` is already `SxxEyy - {series title}.ext` in its season.
### `scan_series`
*function · line 229*
```python
scan_series(lib_dir: Path, library: str, *, skip_movie_folders: bool=False) -> list[Series]
```
Scan one series library into a list of `Series`.
Each immediate subfolder of `lib_dir` is a series; its name yields the
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
parsed from the filename). Non-video sidecars are skipped.
`skip_movie_folders=True` (a `mixed` library, where a movie's version
folder and a series folder both use the identical
`Title (rok) [tmdbid]/` shape) additionally skips a canonical-shaped
folder with no `Season NN`/`Specials` inside it — that's a movie's
version folder instead, left for `FileManager.load_pool_movies` to
read. Left `False` (the default, a plain `series`-kind library, which
never holds movies) every subfolder is read as a series regardless of
shape — a raw, not-yet-renamed import may have loose episode files
sitting at the series root, with no season folder yet.
-102
View File
@@ -1,102 +0,0 @@
# `src/core/subtitles.py`
External subtitle sidecars — attach, discover, keep in sync on rename.
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
file natively as long as it shares the video's filename stem, with an optional
language code and flag suffix::
Title (rok) [tmdbid-N].mkv
Title (rok) [tmdbid-N].cs.srt # language
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
pool index. What Curator does own: copying a picked file into place with the
right name (`attach_subtitle`), and moving a subtitle along whenever its
video gets renamed so it never silently detaches (`rename_subtitles_with`).
Source: [`src/core/subtitles.py`](../../src/core/subtitles.py)
## Classes
### `SubtitleFile`
*class · line 30*
```python
class SubtitleFile:
```
One subtitle sidecar found next to a video.
#### `SubtitleFile.label`
*property · line 38*
```python
label -> str
```
Human-readable `jazyk (flag)` for display, e.g. `cs (forced)`.
## Functions
### `subtitle_filename`
*function · line 44*
```python
subtitle_filename(video_stem: str, ext: str, language: Optional[str]=None, flag: Optional[str]=None) -> str
```
Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
### `_parse_subtitle_name`
*function · line 57*
```python
_parse_subtitle_name(video_stem: str, filename: str) -> Optional[tuple[Optional[str], Optional[str]]]
```
`(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
### `find_subtitles`
*function · line 83*
```python
find_subtitles(video_path: Path) -> list[SubtitleFile]
```
Every external subtitle sidecar sitting next to `video_path`, sorted.
### `attach_subtitle`
*function · line 101*
```python
attach_subtitle(video_path: Path, source: Path, *, language: Optional[str]=None, flag: Optional[str]=None, move: bool=False) -> Path
```
Copy (or move) `source` next to `video_path`, named to match it.
An existing subtitle already at that exact name (e.g. replacing the Czech
track) is overwritten; nothing else next to the video is touched.
Raises:
ValueError: `source`'s extension isn't a recognised subtitle format.
### `rename_subtitles_with`
*function · line 128*
```python
rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]
```
Move every subtitle sidecar of `old_video_path` to match `new_video_path`.
Call this wherever a video gets renamed/moved so an attached subtitle never
silently detaches (its stem would stop matching and Jellyfin would drop it).
No-op when the video's path didn't actually change. Returns the new paths.
-37
View File
@@ -1,37 +0,0 @@
# `src/core/tag.py`
The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). Hashable and comparable, so tags can live in sets and be compared across the tag manager and the files.
Source: [`src/core/tag.py`](../../src/core/tag.py)
## Classes
### `Tag`
*class · line 1*
```python
class Tag:
```
A tag: a category and a name. Two tags are equal when both parts match.
#### `Tag.__init__`
*method · line 2*
```python
__init__(category: str, name: str)
```
Create a tag in `category` with the given `name`.
#### `Tag.full_path`
*property · line 7*
```python
full_path
```
The tag as `Category/Name`, the form used in the index and in strings.
-107
View File
@@ -1,107 +0,0 @@
# `src/core/tag_manager.py`
`TagManager`: the registry of all tag categories and their tags. Initialised with the default categories; the GUI tag tree and the AND-filter read from it.
Source: [`src/core/tag_manager.py`](../../src/core/tag_manager.py)
## Classes
### `TagManager`
*class · line 16*
```python
class TagManager:
```
Registry of tag categories (`tags_by_category`). Adding a tag also creates its category; removing the last tag of a category removes the category.
#### `TagManager.__init__`
*method · line 17*
```python
__init__()
```
Create the registry and fill it with the default tags.
#### `TagManager._init_default_tags`
*method · line 21*
```python
_init_default_tags()
```
Initialize default tags (ratings and colors)
#### `TagManager.add_category`
*method · line 27*
```python
add_category(category: str)
```
Create an empty category if it does not exist yet.
#### `TagManager.remove_category`
*method · line 31*
```python
remove_category(category: str)
```
Delete a category and all its tags, if it exists.
#### `TagManager.add_tag`
*method · line 35*
```python
add_tag(category: str, name: str) -> Tag
```
Register a tag in `category` (the category is created when missing) and return it.
#### `TagManager.remove_tag`
*method · line 41*
```python
remove_tag(category: str, name: str)
```
Unregister one tag. When it was the category's last tag, the category is removed too.
#### `TagManager.get_all_tags`
*method · line 48*
```python
get_all_tags()
```
Every registered tag as a `Category/Name` string.
#### `TagManager.get_categories`
*method · line 52*
```python
get_categories()
```
The names of all registered categories.
#### `TagManager.get_tags_in_category`
*method · line 55*
```python
get_tags_in_category(category: str) -> list[Tag]
```
Get tags in category, sorted by predefined order for default categories
-160
View File
@@ -1,160 +0,0 @@
# `src/core/tmdb.py`
TMDb (The Movie Database) client — Curator's identity source.
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
into a canonical **title + year** for naming the pool file. The rich metadata
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
Needs a free API key in `TMDB_API_KEY` (loaded from `.env`). Everything
degrades gracefully when the key or the network is missing: `api_available`
reports readiness and the lookups raise `TmdbError` with a clear message.
Source: [`src/core/tmdb.py`](../../src/core/tmdb.py)
## Classes
### `TmdbResult`
*class · line 29*
```python
class TmdbResult:
```
A resolved TMDb entry — just enough to name the pool file.
### `TmdbDetails`
*class · line 39*
```python
class TmdbDetails:
```
Full TMDb data used to (re)generate a movie's tags (genres etc.).
Exposes `field_values` so it plugs into the tag schema the same way the
NFO reader does (fields: genres / countries / year / rating).
#### `TmdbDetails.field_values`
*method · line 53*
```python
field_values(name: str) -> list[str]
```
The values of one tag source field (`genres`, `countries`, `year`, `rating`, …) as a list of strings, ready to become tags. Unknown field names return an empty list.
### `TmdbError`
*class · line 65*
```python
class TmdbError(RuntimeError):
```
A TMDb request could not be completed (no key, network, bad status).
## Functions
### `api_key`
*function · line 69*
```python
api_key() -> Optional[str]
```
The configured TMDb API key, or None.
### `api_available`
*function · line 75*
```python
api_available() -> bool
```
True when a TMDb API key is configured.
### `_get`
*function · line 80*
```python
_get(path: str, params: dict) -> dict
```
GET a TMDb API path with the configured key and language. Raises `TmdbError` on a missing key, a network failure or an error response.
### `_year_of`
*function · line 98*
```python
_year_of(date_str: Optional[str]) -> Optional[str]
```
The four-digit year at the start of a TMDb date (`YYYY-MM-DD`), or `None`.
### `lookup_movie`
*function · line 104*
```python
lookup_movie(tmdb_id: int) -> Optional[TmdbResult]
```
Resolve a movie by its TMDb id → title + year, or None if it has no title.
### `movie_details`
*function · line 113*
```python
movie_details(tmdb_id: int) -> TmdbDetails
```
Full movie data from TMDb (genres/countries/rating/year), localized to cs.
### `lookup_tv`
*function · line 127*
```python
lookup_tv(tmdb_id: int) -> Optional[TmdbResult]
```
Resolve a series by its TMDb id → name + first-air year, or None.
### `lookup`
*function · line 136*
```python
lookup(tmdb_id: int, kind: str='movie') -> Optional[TmdbResult]
```
Resolve a TMDb id as a movie (default) or a series (`kind="tv"`).
### `search_movie`
*function · line 141*
```python
search_movie(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
```
Search movies by title (optionally narrowed by year); best matches first.
### `search_tv`
*function · line 161*
```python
search_tv(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
```
Search series by name (optionally narrowed by first-air year).
-43
View File
@@ -1,43 +0,0 @@
# `src/core/transforms.py`
Tag-schema value transforms for miniDLNA folder names.
Applied when building a folder name from a tag value — the tag keeps its exact
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
(→ "…/80–89 %"). Config-driven and extensible via `TAG_TRANSFORMS`.
Source: [`src/core/transforms.py`](../../src/core/transforms.py)
## Functions
### `rating_band`
*function · line 11*
```python
rating_band(rating: int) -> str
```
Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
### `_decade_band`
*function · line 21*
```python
_decade_band(value) -> str
```
`decade_band` transform: pull the leading integer and bucket it.
### `apply_transform`
*function · line 35*
```python
apply_transform(value: str, transform: Optional[str]) -> str
```
Apply a named tag-schema transform to a value, returning a string.
-17
View File
@@ -1,17 +0,0 @@
# `src/core/utils.py`
Small filesystem helpers shared across the core modules.
Source: [`src/core/utils.py`](../../src/core/utils.py)
## Functions
### `list_files`
*function · line 3*
```python
list_files(folder_path: str | Path) -> list[Path]
```
All regular files below `folder_path`, searched recursively. Raises `NotADirectoryError` when the path is not a folder.
-53
View File
@@ -1,53 +0,0 @@
# `Curator.py`
Application entry point. Creates the shared `FileManager` and `TagManager`, applies the OS certificate store for HTTPS, and starts the Qt main window.
Source: [`Curator.py`](../../Curator.py)
## Classes
### `State`
*class · line 21*
```python
class State:
```
Holds the application's shared services: one `TagManager` and one `FileManager` built on top of it. Created once in `main()`.
#### `State.__init__`
*method · line 22*
```python
__init__() -> None
```
Create the tag manager and the file manager that uses it.
## Functions
### `_use_system_certificates`
*function · line 7*
```python
_use_system_certificates() -> None
```
Make HTTPS (ČSFD fetching) use the OS certificate store.
Required behind corporate SSL inspection, where the proxy's root CA is in the
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
optional `truststore` package is unavailable.
### `main`
*function · line 27*
```python
main() -> None
```
Program entry point: installs the system certificate store, builds the `State` and runs the Qt application on its file manager and tag manager.
File diff suppressed because it is too large Load Diff