Compare commits

21 Commits
Author SHA1 Message Date
Honza 78c463e5c1 Ignore both Rust and Python build artifacts 2026-10-17 11:32:48 +02:00
Honza 98afd6166d Release v1.13.0 2026-10-17 09:08:00 +02:00
Honza 4daf4e960d Build v1.13.0 2026-10-17 09:03:35 +02:00
Honza 7cc2a1c63e Version sync 2026-10-17 08:57:36 +02:00
Honza c69f5f9166 Migrate from Poetry and pyenv to uv 2026-10-12 15:48:26 +02:00
Honza c399a6aed3 Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping 2026-09-13 17:16:48 +02:00
Honza c0ad1c1553 Unify movies and series list, add TMDb-sourced tags and detail panel 2026-09-09 07:13:36 +02:00
Honza 79c9e34a5d Add edit dialogs, library organizer, and TMDb pool migration 2026-09-08 07:37:40 +02:00
Honza 39712d7283 Rework Curator into a Jellyfin-first library manager 2026-08-31 20:40:41 +02:00
Honza 6a2baf243f Rework Curator into a Jellyfin-first library manager 2026-08-31 18:34:03 +02:00
Honza 1c78ca9013 Adopt "Title (YYYY)" pool naming and clean Filmotéka link names 2026-07-27 20:51:31 +02:00
Honza 4c4180f28c Add FFmpeg-based video integrity check 2026-07-11 11:32:07 +02:00
Honza 97a39709e8 Fix endless Filmotéka hardlink churn and show pool/output paths in the status bar 2026-07-07 16:18:24 +02:00
Honza 7ae0709f38 Store library settings in the pool index and drop legacy sidecars 2026-07-03 16:06:45 +02:00
Honza f0f38d4257 Add statistics and data-consistency menus, recently-added folder 2026-07-02 06:10:14 +02:00
Honza b5c2023212 Add standalone "Tipy dne" script with systemd service and timer 2026-06-29 16:31:29 +02:00
Honza ee99fc957d Add "Tipy dne" folder and fix stale genre folder cleanup 2026-06-29 15:33:44 +02:00
Honza a71b209539 Add per-movie attributes and per-category filename templates 2026-06-16 17:39:39 +02:00
Honza b3a61f9e86 Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout 2026-06-15 17:31:52 +02:00
Honza 86c689b9f1 Add ČSFD Anubis bypass, drop legacy preset tags, rename Země → Země původu 2026-06-12 20:30:14 +02:00
Jan Doubravský 22a14b1e41 Rework Tagger fork into Curator movie-library manager (PySide6 GUI, pool index, ČSFD import) 2026-06-12 16:01:54 +02:00
112 changed files with 27755 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# --- Rust ---------------------------------------------------------------
# Cargo build output (debug/release builds, incremental caches)
/target/
# --- Python -------------------------------------------------------------
__pycache__/
*.pyc
*.pyo
# Virtual environment
.venv/
# Tooling caches
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Logs (loguru file sink)
logs/
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
build/
# --- Common -------------------------------------------------------------
# Environment and secrets
.env
# Curator runtime metadata (user/machine-specific, regenerated)
.Curator.!gtag
*.!gtag
*.!ftag
*.!tag
*.!index
# IDE
.vscode/
.idea/
AGENTS.md
CLAUDE.md
DESIGN_DOCUMENT.md
.claude/
+870
View File
@@ -0,0 +1,870 @@
# Changelog
Document all notable changes to the project, grouped by version and release date.
## Format
Each version entry uses these sections (include only those that apply):
- **Added** — new features
- **Changed** — changes to existing functionality
- **Fixed** — bug fixes
- **Removed** — removed features
- **Dependencies** — added, updated, or removed dependencies
## Rules
- Follow semantic versioning: `MAJOR.MINOR.PATCH`
- Newest version goes at the top
- Always update this file before bumping the version in `pyproject.toml`
- Document changes as they are made, not all at once at release time
## Unreleased
### Changed
- **The standalone build is now a single file.** `Curator.spec` produces one
self-contained `dist/Curator` executable (onefile) instead of a
`dist/Curator/` folder of libraries. It unpacks to a temp folder on every
start, so launching takes a few seconds longer.
### Dependencies
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
now works out of the box (it was used for builds but never declared).
## 1.13.0 — 2026-10-12
### Changed
- **Custom attributes are gone from the GUI.** The attribute table in
*Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are
removed. Values already stored keep working in miniDLNA filename templates
(`File.name_context`); they just can't be edited from the app anymore.
- **The pool index is keyed by the movie, not by a file path** (index **v2**,
`src/core/movie.py`). A record used to describe one *file*, so a movie's
metadata was a passenger on one particular file — which is why the "primary
version" had to carry the tags, why removing it needed a metadata-rescue
hack, and why renaming a multi-version movie had to be disabled outright.
Now the record **is** the movie (`tmdb-603`, or a diacritics-folded
`name-<title>-<year>` slug without an id) and owns its files:
```json
"tmdb-19995": {"title": "Avatar", "year": "2009", "tmdb_id": 19995,
"library": "Filmy", "tags": [...], "csfd_url": …,
"media": [{"role": "video", "label": "", "file": "…"},
{"role": "video", "label": "3D HSBS", "file": "…"}]}
```
Every version of a film therefore reads and writes **the same** metadata —
adding a version inherits it, removing any version (including the one shown
in the table) leaves it intact, and the rescue hack (plus the interim
`File.is_primary_version` flag) is gone. A newly seen file also joins an
existing record of the same identity instead of blanking its metadata.
`Movie.desired_layout` derives where the files belong from the record alone
(one video → flat in the library, two or more → its own
`Title (rok) [tmdbid]/` folder; a 3D label keeps Jellyfin's documented
`.3D.<FORMAT>` spelling in both layouts, other labels use `" - label"`) —
**not wired in yet**: `add_movie_version` / `remove_movie_version` still lay
files out themselves, and the edit dialog doesn't apply a recomputed layout
on rename. A move now updates the record's `library` as well.
`check_data_consistency` compares disk against the records' media paths.
`File` is now a *view* on one media entry of a record (same public
interface); `PoolIndex` gained `get_movie`/`set_movie`/`delete_movie`/
`rekey_movie`/`all_movies`/`find_movie_by_media`/`record_for`, and
**migrates a v1 index on first open** — metadata only, **no file is moved**
(a v1 movie already sits where v2 wants it), with the old index copied to
`.Curator.!index.v1.bak` first.
- **"Odebrat z poolu" removes the whole movie** (`FileManager.remove_movie`):
on a multi-version row it deletes every version, their subtitles and the
version folder — it used to delete only the file shown in the table and
leave the rest behind as a half-empty group. Subtitles of a flat movie are
removed too now.
### Added
- **Upravit film: one "Média filmu" section with a folder mode.** A flat
movie (one file in the library root, plus subtitles) shows as "Samostatný
film"; adding a second medium moves it into its own folder automatically.
The folder has two modes, chosen with a **"Verze / Díly"** switch:
**versions** — alternatives, each with its own popisek (free text such as
"kinoverze", "zkrácená verze", or a 3D preset via **"✏️ Popisek / 3D…"**);
**parts** — pieces played back to back, listed in order and reordered with
**↑ / ↓**. "➕ Přidat médium…" adds by the chosen mode (for a flat movie the
switch decides what the second file becomes). Switching an existing folder
converts it: to parts numbers the files in their current order; to versions
makes the first file the default and asks a popisek for each other one.
New `FileManager.movie_group_mode`, `ordered_media` (parts sorted
numerically, `part2` before `part10`), `move_movie_part`,
`set_movie_group_mode`; all of them relabel the folder through temporary
names, so swapping two parts never overwrites a file.
- **Movie parts (Jellyfin multi-part / stacking)** — a film that TMDb lists
as one movie but that you have as several files. *Upravit film* gets
**"➕ Přidat díl…"**: the film moves into its own folder (Jellyfin requires
it for parts, even with no other versions) as `Folder-part1.ext`,
`Folder-part2.ext`, … and Jellyfin plays them back to back as one movie —
unlike versions, which it offers as alternatives. Under the hood a part is
a version whose popisek is `partN`, so everything that handles versions
handles parts too (removing down to one demotes to flat, subtitles follow
each part, renaming works). Any Jellyfin spelling typed as a popisek
(`part2`, `CD2`, `pt 2`, `disc-2`) is written back as `-part2` and the
existing file becomes `part1` — so the import collision dialog's "Přidat
jako verzi" with popisek `CD2` works the same way. The version table shows
"díl N", the main table "Film · N díly". Parts and versions can't be
combined in one folder yet (`add_movie_part` refuses). New
`FileManager.add_movie_part`, `movie.normalize_part_label` / `part_label`.
- **Upravit seriál: TMDb lookup, optionally from a movie id.** The series
editor gets a **"🔎 Dohledat"** button (title + year from TMDb, by the typed
id or by the title) and a **"Metadata projednou z ID filmu (ne seriálu)"**
checkbox for series that TMDb lists only as a movie (a miniseries cut
together, a documentary in parts): ticked, the lookup uses the movie
endpoints instead of the TV ones. The movie id is then **not** written into
the series folder's `[tmdbid-N]` — Jellyfin's TV provider would resolve it as
a TV id and match an unrelated show — so Jellyfin matches the series by
title and year instead (`SeriesEditDialog._lookup`, `.tmdb_id`).
- **Import movies: assign a version right in the batch** (e.g. a 2D + a 3D
file of the same film picked together). A row's new **"🔗 Přiřadit jako
verzi…"** button picks which other row in the dialog it's a version of and
a popisek (pre-filled with the 3D formát detected in its filename via
`naming.three_d_format_from_filename` — e.g. `Avatar.2009.3D.HSBS.mkv` →
`3D HSBS` — free text otherwise, same `VersionLabelDialog` as *Upravit
film → Verze*); its own název/rok/TMDb ID/ČSFD fields grey out since they
come from the film it's a version of. On import the target row lands
first via `import_movie`, then every linked row is added onto it via
`FileManager.add_movie_version` — one movie with several versions instead
of two separate (duplicate) movies. Removing a row it points to un-links
it back to standalone; a row already pointed at by others can't itself
become a version (no chains).
- **Import movies: "3D formát" per row**, for the opposite case — a film
whose *only* file is entirely 3D, with no plain version to make it a
version *of*. A combo box (pre-filled from the filename, same detection as
the popisek above) tags a standalone row's import straight to
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`, exactly like the 3D formát dropdown
in *Upravit film* — no more importing plain, then editing it in just to add
the tag. Disabled (and reset) the moment a row is linked as a version or
gains one as a follower, since a grouped movie's 3D-ness belongs on the
specific version's popisek instead, never on the group as a whole.
- **Duplicate check** (*Nástroje → Kontrola duplicit…*,
`FileManager.find_duplicates`). Lists movies that are in the pool more than
once — a version folder counts as one movie, never as a duplicate of itself.
Flagged when two movies share the **same TMDb id** (e.g. `X.mkv` and
`X.3D.HSBS.mkv` lying flat side by side, or one film in two libraries), the
**same title + year** (one copy paired with TMDb, the other not), or a
**byte-identical file size** (a plain copy); overlapping hits form one group
with every reason listed. Per file the dialog offers **"Sloučit jako verzi
do…"** (`FileManager.merge_as_version`: moves the file into the other movie's
version folder — promoting it from flat when needed, a 3D file pre-labelled
`3D <FORMÁT>` — carries its subtitles along, keeps the target's metadata and
only adds missing tags / ČSFD link, and drops the duplicate's own record) or
**"Smazat vybraný z poolu…"**.
- **Multi-version movies are marked in the main table**: `▣` before the bold
name, `Film · N verze` in the Typ column, the size summed over all versions
and a tooltip listing every file; the detail panel shows the folder and the
version count.
- **Episodes split into physical parts** — an old recording cut into several
files, as opposed to `E01-E02` (several *episodes* in one file) —
get Jellyfin's own `.ptN` stacking suffix: `SxxEyy - {série}.ptN.ext`.
Recognised from *cd/dvd/part/pt/disc/disk* + a number already in the source
filename (`Show.S01E02.pt2.mkv`, `Show S01E01-part-1.mkv`, …), or typed by
hand in the import dialog's SxxEyy column as `S01E02P3` (new
`series.parse_episode_part`/`parse_episode_code`); carried through
scanning, import, "Napravit názvy souborů…" and the series tree.
- **Přehled seriálů → "Odebrat z poolu…" now targets whatever is selected in
the tree**, not always the whole series: pick a single episode to delete
just that file (+ its subtitles), a season to delete the whole `Season NN`/
`Specials` folder, or the series itself as before. New
`FileManager.remove_episodes`/`remove_season`; a season folder left
completely empty by the removal is cleaned up, one still holding something
else is left alone.
- **Multiple versions of one movie** (Jellyfin's own "multiple versions" —
2D+3D, 4K+1080p, Director's Cut, …). A movie is identified by its
`(title, year, tmdbid)` and stays a **flat file** while it has exactly one
playable version (today's layout, unchanged). Adding a second version
**promotes it automatically** into its own folder — Jellyfin's required
shape, every file prefixed with the exact folder name:
```
Title (rok) [tmdbid]/
Title (rok) [tmdbid] - Výchozí.ext
Title (rok) [tmdbid] - 3D HSBS.ext
```
*Upravit film* gets a **„Verze"** list — **"➕ Přidat verzi…"** (pick a file,
then a small `VersionLabelDialog`: a **3D formát** preset — its label becomes
exactly `3D <FORMÁT>`, the same tokens Jellyfin scans for in a flat
`.3D.<FORMÁT>` filename — or free text for a non-3D version like `4K` /
`Director's Cut`), **"✏️ Přejmenovat verzi…"** (`FileManager.rename_movie_version`
— same dialog, pre-filled with the current label; renames the file + its
subtitles in place, shared metadata untouched) and **"➖ Odebrat verzi"**
(removing down to one version **demotes back to flat** automatically). A
flat movie always shows one row too — labelled **„Výchozí"**, a placeholder
since it has no on-disk label yet; renaming is disabled until a second
version exists (there's nothing to rename it *from* until then). The table
still shows **one row per movie** — a group's other versions live only in
that list, not as
separate rows (`FileManager.visible_movies`; the row shown is the plain /
`Výchozí` version, `primary_version`). All versions share **one metadata
record** (see *Changed → index v2*), so a new version inherits the title/
tags/ČSFD/atributy and removing any version — even the one shown in the table
— leaves them intact. Renaming/moving/retitling a grouped movie isn't
supported yet (those fields are disabled in the edit dialog; remove the
version or edit the files directly). `FileManager.is_movie_group_member` /
`list_movie_versions` / `primary_version` / `add_movie_version` /
`remove_movie_version`. Works in `mixed` libraries too (see *Fixed* below
for how a version folder there is told apart from a series).
- **3D movie tagging** (`naming.py`, Jellyfin's own convention —
https://jellyfin.org/docs/general/server/media/movies/). *Upravit film* gets
a **„3D formát"** dropdown (žádný / HSBS / FSBS / HTAB / FTAB / MVC),
pre-filled from the current filename; picking one renames the pool file to
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`. The tag is preserved across every
future rename (editing other fields, "Přejmenovat dle metadat") the same way
`[tmdbid-N]` already is — `canonical_pool_stem` / `parse_3d_format` /
`THREE_D_FORMATS`; `apply_movie_edits(format_3d=…)` (omitted = keep, `""` =
clear). This is for a movie that's **only ever going to have one file** —
deliberately **not** exposed in the import dialog: importing a second file
for a movie already in the pool that way would create a plain, ungrouped
duplicate instead of a proper version (see "Multiple movie versions" above,
which is the right tool for 2D+3D of the same film). `import_movie` still
takes `format_3d=…` as a building block, just with no UI wired to it.
- **miniDLNA hardcopies `homevideo` libraries too.** A `homevideo` library
(no TMDb id, arbitrary internal structure) has no genre tags to build a tree
from — same reasoning as series — so it's now mirrored whole into
`output/<název knihovny>` alongside the series copy-as-is mirrors
(`FileManager.mirror_as_is_libraries` = `series` + `homevideo`; the
series-only `copyasis_folders` used by the library organizer is unchanged).
Respects the per-library miniDLNA selection.
- **miniDLNA covers series inside `mixed` libraries.** A `mixed` library's
top-level files are movies (genre-tagged as usual); any **series in its
subfolders** now get hardcopied into `output/Seriály/<vlastní název>/…` —
no genre tags apply to a series, so it bypasses the tag tree entirely, same
mechanism as a dedicated series library's copy-as-is mirror but scoped to one
series' own folder. Respects the per-library miniDLNA selection below.
`FileManager.minidlna_scope` now returns a third element, the in-scope
mixed-library `series.Series` list.
- **miniDLNA: choose which libraries generate.** *miniDLNA → "Knihovny pro
generování…"* — a checklist of the pool's libraries (persisted in the pool
index, default = all, matching the previous behaviour). "Vygenerovat miniDLNA
strom" then only includes movies from the checked libraries in the genre tree
/ Tipy dne / Nově přidané, and only mirrors checked series/homevideo
libraries — the dialog warns up front that unchecking one removes its output.
A movie library's genre-tree links are swept as obsolete the normal way; a
mirrored (series/homevideo) library or a mixed-library series has **no**
obsolete-link mechanism of its own, so deselecting one is handled explicitly:
`FileManager.minidlna_prune_targets` works out what fell out of scope (the
whole-library output folder, or — since a mixed-library series and a
same-named real library can share one output bucket like `Seriály` — just the
stale entries in it) and `HardlinkManager.remove_mirror` deletes it.
`FileManager.minidlna_libraries` / `set_minidlna_libraries` / `minidlna_scope`.
- **External subtitle management for movies** (`src/core/subtitles.py`). Jellyfin
plays an external `.srt`/`.ass`/`.ssa`/`.sub`/`.vtt` natively when it shares the
video's filename stem (`Title (rok) [tmdbid].cs.srt`, optionally `.forced`/
`.sdh`/`.default`) — no muxing needed. *Upravit film* lists a movie's attached
subtitles and can **"➕ Přidat titulky…"** (pick a file, tag its language,
mark forced; copied in and named to match) or remove one. Subtitles now
**follow the movie through every rename/move** (`rename_movie`,
`apply_movie_edits`, `move_to_library`, `rename_to_canonical`, and
`rename_episodes_to_canonical` for series) instead of silently detaching —
`subtitles.rename_subtitles_with`. `FileManager.list_subtitles` /
`attach_subtitle` / `remove_subtitle`. Presence is read live from disk (like
NFO), nothing is cached in the pool index.
- **Movies and series in one list.** The main table lists both as rows with a
**Typ** column (Film / Seriál); size is the file size for a movie and the sum of
the episodes for a series. Double-click (or the context menu) opens the right
editor by type — a movie → the movie parameters dialog, a series → the series
editor with the per-episode SxxEyy tree. The right detail panel handles both.
Library filter + search cover both; a tag filter narrows movies (series carry
no tags).
- **Detail panel on the right** of the table — the selected item's filename (or
series folder), Czech title, library and size (`_update_details`).
- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click) opens
one dialog for every Curator-owned movie parameter — title, year, TMDb ID (with
"Dohledat z TMDb"), library, date, attributes and a ČSFD link — applied at once
(rename `Title (rok) [tmdbid].ext`, move between libraries, set metadata) via
`FileManager.apply_movie_edits`. A series opens an editor (in *Přehled seriálů*
or from the list): edit its title/year/TMDb ID/library **and each episode's
season/episode number**, renaming the folder and laying episodes out canonically
(`FileManager.apply_series_edits`).
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed /
homevideo** — stored in a unified ``libraries`` setting in the pool index
(`FileManager.libraries` / `set_libraries`; legacy `movie_libraries` /
`copyasis_folders` are derived from it and synthesised for old pools). Setting
the pool creates a folder for every library. Replaces the separate "Knihovny
(rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and **homevideo** can be
defined now; homevideo scanning lands in a later phase.
- **Series import into `mixed` libraries.** `series_libraries` (import target +
`load_series` scan) covers kind `series` **and** `mixed`, so episodes can be
imported into a mixed library and show up in the series overview/editor.
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
import (a column in the movie import dialog) and editable in *Upravit film*.
- **ČSFD → TMDb title bridge** (`src/core/csfd.py`, lean revival of the removed
scraper). A direct TMDb search on a Czech distribution title is unreliable
(remakes, localized names); ČSFD indexes Czech release names well **and** lists
each film's *original* title, so the flow is: filename → ČSFD search → ČSFD
film page → original title + year → TMDb search → tmdbid.
- Import dialog: a second button **"🎬 Dohledat přes ČSFD"** next to "Dohledat
z TMDb". Per row it uses the filled ČSFD odkaz, else searches ČSFD by the
cleaned filename; fills ČSFD odkaz / TMDb ID / Název / Rok (blue = suggested).
- Migration: a movie carrying a `csfd_url` is resolved through ČSFD first, so
`match_pool_to_tmdb` searches on the original title + year (the proposal is
tinted blue with a *"přes ČSFD: …"* tooltip). `FileManager.resolve_via_csfd`.
- Search is scoped to the *Filmy* section and rejects two-segment episode
hrefs (a bare-title search otherwise returned a series episode); a yearful
query that misses is retried without the year. The original title is only
taken from a `.film-names` entry whose flag matches the country of origin —
a Czech film (which lists only foreign alt-titles there) keeps its `<h1>`
title instead of a random Slovenian/English one.
- Scope: a title→tmdbid bridge only — **no ČSFD tags/genres return** (those
stay TMDb-via-Jellyfin/NFO). ČSFD's Anubis proof-of-work wall is solved
(SHA-256, stdlib) and replayed over a shared session so a batch pays it once.
- **Import rows pre-filled from metadata next to the source file.** When a picked
video sits next to metadata from an earlier Curator/Tagger setup, the import
row is seeded on open (no network) — **ČSFD odkaz**, Název, Rok (blue =
suggested, verify):
- `src/core/legacy_meta.py` — reads Curator's **pre-index** layouts: a per-file
`.{filename}.!tag` JSON sidecar (Tagger / early Curator; key `csfd_link`),
else a `.Curator.!index` found walking up the tree (matched by relative key,
then by unique basename). These win for the ČSFD link.
- `nfo.read_nfo` also parses a ČSFD id/URL from `<uniqueid type="csfd">` (bare
id or full URL) / legacy `<csfdid>` / `<csfd>` (`nfo.NfoData.csfd_url`), and
fills what the sidecar/index don't carry (notably the TMDb id).
- `ImportMoviesDialog._prefill_row`.
- **Import: "Vynechat řádky bez TMDb ID".** A checkbox in the import dialog that
drops every row without a TMDb ID on OK — they are skipped (and no longer need
a year). Rows that would be skipped are shown struck-through/italic as the box
is ticked; the import summary reports how many were skipped
(`ImportMoviesDialog.skipped_no_tmdb`).
- **Episode import: "📁 Přidat složku…".** The episode-import dialog can now take
a whole series folder and scans it **recursively** (all subfolders — `Season
01`, `Season 02`, …), adding every video file it finds (sidecars skipped);
season/episode still parsed per file. A canonical folder name
(`Title (rok) [tmdbid-N]`) also pre-fills the series title / year / TMDb ID.
The dialog now opens empty (add files or a folder from inside it).
`series.collect_episode_files`, `ImportEpisodesDialog._add_folder`.
- **Episode import: editable SxxEyy per row.** The `SxxEyy` column is now an
editable field, pre-filled from the filename and colour-coded (green valid /
red unparsable / plain empty). Fill in an unrecognised episode (or fix a
mis-parsed one) right in the import dialog — `S01E05` / `1x05` both work; OK
warns before importing if any row still has no valid code.
`ImportEpisodesDialog.episode_codes` feeds a new `codes=` override on
`FileManager.import_episodes`.
- **Remove a series from the pool.** *Seriály → "Odebrat z poolu…"*, the table
context menu (series-only selection, one or more), and a button in *Přehled
seriálů* delete a whole series folder — every season and episode — after a
confirm. `FileManager.remove_series` (refuses a folder outside the pool).
- **Bulk-fix series episode filenames.** *Seriály → "Napravit názvy souborů…"*
goes over every series in the pool and renames each episode to
**`SxxEyy - {název seriálu}.ext`** in the right `Season NN` / `Specials`
folder, then removes the season folders emptied by the moves. Episodes with no
recognisable `SxxEyy` are left in place and listed (fix them in the series
editor). `FileManager.rename_all_series_to_canonical`.
- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool
s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by
title + year and proposes an id, filling a missing year from TMDb. Conservative
classification — **jisté** (exact diacritics-insensitive title + matching year,
unique), **nejednoznačné** (pick from candidates), **bez shody**. A review
dialog applies the confident ones or the ones you pick → renames to
`Title (rok) [tmdbid].ext`. Already-migrated files are skipped (re-runnable).
`FileManager.match_pool_to_tmdb` / `assign_tmdb_id`.
### Fixed
- **Adding/removing a movie version could silently drop its 3D formát.**
Promoting a flat, 3D-only movie to a group (adding a second version)
always renamed the pre-existing file to `" - Výchozí"`, discarding
whatever `.3D.<FORMAT>` tag it had — same the other way round: demoting
back to flat when the sole survivor's own popisek *was* a 3D formát always
produced a plain filename. Both now read the file's own current formát
(`naming.parse_3d_format` / `movie.normalize_3d_label`) and carry it over
automatically, so a movie can freely gain and lose versions — 3D-only,
2D-only, several formats at once — without ever losing a formát that
wasn't touched in that particular step. Affects `add_movie_version` (its
`existing_label` param, and `merge_as_version`'s `keep_label` which now
derives from it too) and `remove_movie_version`.
- **A version's popisek can be left empty** — for a plain "2D" file with
nothing else to call it — instead of being rejected; it becomes
`movie.DEFAULT_VERSION_LABEL` ("Výchozí") the same way the pre-existing
file already did on promotion. Applies to `add_movie_version`,
`rename_movie_version`, `merge_as_version`, and everywhere they're driven
from the GUI (*Upravit film → Verze*, *Kontrola duplicit*, *Importovat
filmy…*'s "Přiřadit jako verzi…") — none of them require typing "Výchozí"
by hand anymore. `VersionLabelDialog`'s popisek field says so directly.
- **Renaming or demoting a version could wipe the film's tags.** Every version
holds its own in-memory copy of the shared metadata; one loaded before a tag
was added wrote its stale copy back when it was renamed, relabelled or left
as the sole survivor. Those paths now use the new `File.save_location`, which
re-reads the movie record first so only the file's own path and popisek
change.
- **A movie's version couldn't be removed when the edit dialog was open on
it.** *Upravit film* opens on the movie's primary version, and removing that
very file was blocked ("Nelze odebrat verzi, kterou právě upravuješ…") — so
in a folder whose first file alphabetically was the one to go, it couldn't go
at all. It can now: the dialog carries on with a sibling (all versions share
one movie record) and *Upravit film* applies its edits to that file.
- **Parts and versions could be mixed in one folder**, e.g. `-part1`, `-part2`
and a version labelled `díl 1` — and the version table showed both the part
and the version as "díl 1". `add_movie_version` and `rename_movie_version`
now refuse a folder holding both kinds, and the version table has separate
**Typ** (díl N / verze / Výchozí) and **Popisek** (the exact label) columns.
- **The default version of a grouped movie is named bare.** In a folder of
two or more versions the plain one is now `Folder/Folder.ext`, not
`Folder/Folder - Výchozí.ext`: "Výchozí" is only the table's name for it.
Other versions stay `Folder - <popisek>.ext`. The version table and index
label read it as the default. Pool files already named `- Výchozí` are
renamed with **Nástroje → "Sjednotit názvy výchozích verzí…"**
(`FileManager.normalize_default_version_names`; subtitles and metadata follow,
a folder whose bare name is already taken is left alone).
- **Importing a film already in the pool added a root-level duplicate instead
of a version.** `pooled_with_stem` compared only the filename stem, so the
files of a grouped movie (`Folder - 3D HSBS.mkv`) never matched the film's
identity and the import wrote `Title (rok) [tmdbid].mkv` beside the folder.
Matching is now by movie identity (flat file, any version of a group, or a
3D-tagged copy). When the batch import finds such a film it asks **"Přidat
jako verzi" / "Nahradit celý film novým" / "Přeskočit"** (no "keep both as
separate films" — same TMDb id means one movie record, so that's just a
duplicate) and, for "Přidat jako verzi", asks for each file's popisek
(pre-filled from its 3D formát). `import_movie(on_conflict="version",
version_label=…)` does the adding; `"replace"` removes a grouped movie as a
whole rather than one of its files, and "suffix" no longer lands beside a
folder of the same identity.
- Repair for an already-created root duplicate: **Nástroje → Kontrola duplicit**
flags it (same TMDb id) and **"Sloučit jako verzi do…"** folds it into the
group.
### Changed
- **Tags come from TMDb, not NFO.** *Filmy → "Načíst žánry z TMDb"*
(`FileManager.refresh_tags_from_tmdb`, `tmdb.movie_details`) fetches
genres/year/country/rating by the movie's tmdbid and regenerates its tags;
generating the miniDLNA tree refreshes from TMDb first. Genres come localized
(cs-CZ); production countries stay TMDb's English names. Removes the wait for
Jellyfin to write NFO before the genre tree is populated.
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**
(genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
- **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/
Season NN/`. The release string is deliberately dropped — identity is the
`SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb
(matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title
collapses to `SxxEyy.ext`. `episode_filename` takes the series title,
`is_canonical_episode(path, series_title)`, and import / rename / edit all
follow (`series.sanitize_title`).
### Fixed
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
library filter offered only `movie`-kind libraries (falling back to a default
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**),
and `load_pool_movies` scans a mixed library's **top-level** files as movies
(subfolders are series). Movies can be imported into / moved between mixed
libraries.
- **Cleanup of leftover library folders.** After editing libraries, Curator
offers to delete **empty** pool folders that are no longer a library, and lists
non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
`remove_empty_pool_folder`.
- **Adding a movie version in a `mixed` library silently broke the movie.**
Its version folder and a series folder are both `Title (rok) [tmdbid]/` —
indistinguishable by name — and a `mixed` library's subfolders used to be
read as series unconditionally (`load_pool_movies` only scanned that
library's **top-level** files as movies). So the movie vanished from the
Film list and reappeared, mis-parsed, as a "series" — no longer reachable
via *Upravit film*. Told apart by **shape** now instead: a series always
nests its episodes in a `Season NN`/`Specials` folder, a movie's version
folder never does (`series.looks_like_series_folder`). A `mixed` library's
canonical-shaped subfolder without one is read as a movie by
`load_pool_movies`/`is_movie_group_member`, and skipped by `scan_series`
(`skip_movie_folders=True` there, so a `movie`-and-`series` pair with the
same identity can sit side by side without either one leaking into the
other's listing) — no manual per-item type switch needed.
### Dependencies
- **Poetry and pyenv are replaced by uv.** `pyproject.toml` was recreated with
`uv init` / `uv add` (no `[tool.poetry]`, no `[build-system]`), `poetry.lock`
is replaced by `uv.lock`, and `.venv` is built on a uv-managed Python 3.14.
Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md`
§11). Dependency lower bounds moved up to the versions uv resolved; Poetry's
upper bounds (`<7.0.0`, …) are gone.
- **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3,
python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the
whole codebase was brought in line with it — see *Changed* below.
### Changed
- **Codebase cleaned up for ruff 0.17's default rules** (`uv run ruff check`
passes). Mostly mechanical: PEP 604/585 annotations (`X | None`, `list[...]`
instead of `Optional`/`List`), sorted imports, stale `# noqa` removed, unused
unpacked variables prefixed with `_`, silent `try/except/pass` blocks
rewritten as `contextlib.suppress`, dialog class-level tables in
`src/ui/qt_app.py` annotated `ClassVar`. `scripts/tipy_dne.py` is now
executable (it has a shebang).
- **Three blanket `except Exception` handlers are narrowed** — anything outside
the listed types now propagates instead of being swallowed:
`config.load_global_config` (`OSError`/`ValueError`/`TypeError`),
the ffprobe resolution lookup in `media_utils`
(`OSError`/`SubprocessError`/`ValueError`), and the window-geometry restore
in `qt_app` (`ValueError`/`AttributeError`).
- User-facing "missing packages" hints (ČSFD) now say `uv sync` instead of
`poetry install`.
## 1.12.0 — 2026-08-31
### Added
- **Series support** (Jellyfin TV layout, phase 5; `src/core/series.py`, menu
**Seriály**). Episodes are matched by `SxxEyy` (also `1x02`) and laid out as
`Seriály/Series (rok) [tmdbid]/Season NN/Series SxxEyy.ext` (season 0 →
`Specials`). *"Importovat epizody…"* places episode files (series identified
via TMDb TV lookup, per-file SxxEyy parsed from the filename); *"Přehled
seriálů…"* shows a Series → Season → Episode tree with a naming status and a
per-series **"Přejmenovat epizody"** that renames mis-named episodes into the
right Season folder. `FileManager` gained `load_series`, `import_episodes`,
`rename_episodes_to_canonical`; `tmdb.lookup_tv` / `search_tv` resolve series.
- **NFO-driven genres, ČSFD removed** (phase 4). Curator's metadata source is now
the `.nfo` Jellyfin writes into the media folders (`src/core/nfo.py`, read-only).
*Filmy → "Načíst žánry z NFO"* (`FileManager.refresh_tags_from_nfo`) reads
genre/year/country/rating from each movie's NFO and (re)generates its tags
(user-added tags kept); generating the miniDLNA tree refreshes from NFO first,
so the genre-split tree reflects what Jellyfin knows. The **Filmotéka** menu is
now **miniDLNA** ("Vygenerovat miniDLNA strom").
- **Jellyfin integration** (thin/hybrid, phase 3; `src/core/jellyfin.py`, menu
**Jellyfin**). NFO/artwork on disk stay authoritative; the client only
triggers a scan and reads back what Jellyfin sees. **"Skenovat knihovnu"**
(`POST /Library/Refresh`), **"Ověřit proti poolu…"** (matches pooled movies to
Jellyfin items by TMDb id, then title+year, and reports what is out of sync —
`FileManager.verify_against_jellyfin`), **"Zkontrolovat připojení"**. The
status bar shows the Jellyfin state (nenastaveno / nezkontrolováno / ● verze).
Config via `.env` — `JELLYFIN_URL`, `JELLYFIN_API_KEY`.
- **Movie libraries as physical partitions** (Jellyfin-first, phase 2). The pool
can now hold several top-level movie libraries (`movie_libraries` in the pool
index — default `Filmy`; add Kids/Dokumenty/Anime via *Pool → "Knihovny
(rozdělení)…"*). Import lands in a chosen library, and *Filmy → "Přesunout do
knihovny…"* (context menu too) moves a movie between them. The movie table
gained a **Knihovna** column and a **library filter** (Vše / per library) in
the toolbar. `FileManager` gained `movie_libraries` / `set_movie_libraries`,
`import_movie(library=…)`, `move_to_library`, and `library_of`; `load_pool_movies`
and the consistency check now scan every movie library and skip the sidecars
Jellyfin writes (`.nfo`, artwork, subtitles).
- **TMDb identity source** (`src/core/tmdb.py`) — start of the Jellyfin-first
pivot. Resolves a **TMDb id** (or a title/year search) into a canonical
**title + year**; the rich metadata/artwork are left to Jellyfin. Needs a free
`TMDB_API_KEY` in `.env`; degrades gracefully without it.
- **`[tmdbid-N]` in the pool naming convention** (`naming.py`): the pooled file
is now `Title (YYYY) [tmdbid-N].ext` when the id is known — the exact form
Jellyfin matches on. The id is optional (plain `Title (YYYY)` still valid),
parsed back by `parse_tmdb_id`, preserved across `rename_to_canonical`, and
stripped from the miniDLNA hardlink names (they stay clean `Title.ext`).
`FileManager.import_movie` takes a `tmdb_id`.
### Changed
- **Import reworked around TMDb, ČSFD removed from the import flow.** The import
dialog's "ČSFD odkaz" column is now **TMDb ID**, and **"Dohledat z TMDb"**
fills Title/Year/id (by id, or by searching the filename) — a suggested value
is tinted blue, a name colliding with the pool red. Import no longer fetches
ČSFD tags. (The legacy ČSFD tag actions remain until the tag tree is replaced
by NFO-driven miniDLNA generation.)
### Removed
- **ČSFD entirely.** Deleted `src/core/csfd.py` (scraping, Anubis PoW solver) and
its tests; dropped `File.apply_csfd_tags` / `set_csfd_link` / `get_cached_movie`,
the `csfd_link` field and the ČSFD menu/context actions. `File.csfd_cache` →
`File.meta` and `csfd_tag_paths` → `auto_tag_paths` (both read the old index
keys for back-compat); the folder-name transforms moved to `src/core/transforms.py`.
## 1.11.0 — 2026-07-27
### Added
- **Pool naming convention `Title (YYYY).ext`** (`src/core/naming.py`). Pooled
movies now carry a four-digit year in parentheses, so two same-named films
coexist and the filename is self-describing.
- **Import requires a year**: the import dialog has a per-row **Rok** field
(pre-filled from the source filename), and *"Najít ČSFD odkazy"* now also
fills the year from the top ČSFD hit — a **suggested** year is tinted **blue**
(verify it), a name **colliding** with the pool stays **red**. The import is
blocked until every row has a four-digit year, and the file lands in the pool
as `Title (rok).ext`. Same title + different year no longer collide.
- **Rename to the convention**: *Filmy → "Přejmenovat dle ČSFD (rok)…"*
(`FileManager.rename_all_to_canonical`) renames selected movies (or the whole
pool) to `Title (rok).ext` using the year from their ČSFD metadata, with a
cancelable progress dialog; files with no known year are skipped and name
collisions reported. `File.title` keeps the clean title (without the year);
`File.name_context` recovers the year from a canonical filename when no
ČSFD/tag year exists.
### Changed
- **Filmotéka hardlinks are named by the clean title (no year).** In the tag
tree a movie now links as `Title.ext` instead of the pooled `Title (YYYY).ext`.
When two *different* films would collide on that name in the **same folder**,
the colliding ones keep the year (`Title (YYYY).ext`) to tell them apart; a
residual clash (same title *and* year) gets a stable numeric suffix. Copy-as-is
mirrors (Seriály) are untouched — they stay 1:1. A per-category
`filename_template` (Tag schéma dialog) still overrides the default inside its
folders. Link creation and obsolete-detection now share one collision-resolved
plan (`HardlinkManager._plan_links`), so they can't disagree on a name — the
first generation after upgrading re-lays the affected links once, then it is
stable.
- `File.name_context`'s `year` field is now always a string (was an int when it
came straight from the ČSFD cache) — uniform for filename templates.
## 1.10.0 — 2026-07-07
### Added
- **Video integrity check** (`src/core/integrity.py`, *Testy → "Kontrola
integrity videa…"*): scans movies for corrupted/unreadable video data via
FFmpeg. Two depths — **Důkladná** (full `ffmpeg` decode to `-f null`, catches
mid-file data corruption, slow) and **Rychlá** (`ffprobe` container/stream
probe, catches truncated/unreadable files, fast). Runs on the selected movies
or the whole pool, with a cancelable progress dialog, and reports the bad
files (with the first decoder error) separately from those that couldn't be
checked. `FileManager.scan_video_integrity` drives the scan;
`check_video_integrity` checks one file and degrades gracefully when FFmpeg
isn't installed.
## 1.9.0 — 2026-07-07
### Fixed
- **Endless Filmotéka churn (`vytvořeno: N, odebráno: N` every run).** When a movie
was re-imported/replaced (new inode), the old hardlink kept the correct name
but pointed at the dead inode. Obsolete detection matched links **by name
only**, so it spared that orphan and instead forced the freshly created,
correct link to a `_1` suffix — which, having an unexpected name, was swept on
the next run and recreated again, forever. `HardlinkManager.find_obsolete_links`
now matches **both the path and the inode**: an orphan squatting the expected
name is removed and the base name is restored, so generation is stable after
one cleanup run.
### Changed
- **Status bar** now shows the current **Pool** and **Filmotéka** paths as
permanent widgets (right-aligned), so they stay visible while the transient
area shows the movie count or the current selection — previously the paths
were part of the transient message and were wiped as soon as a selection
updated the status.
## 1.8.0 — 2026-07-03
### Changed
- **Split config responsibilities by scope.** The global config (`.Curator.!gtag`)
now carries **application data only** — window state, MRU folders, and the
`pool_dir` / `filmoteka_dir` pointers. Everything describing a particular
library — **`tag_schema`** and **`copyasis_folders`** — moved into the pool
index (`.Curator.!index`, under a new `settings` section), so it travels with
the pool. `FileManager` reads/writes those via the index (`PoolIndex.get_setting`
/ `set_setting`) and **migrates** any values left in an old global config into
the index once, on first open (`FileManager._migrate_global_settings`).
### Removed
- **Per-folder config (`.Curator.!ftag`)** and the whole legacy folder-scan path
(`FileManager.append` / `get_folder_config` / `save_folder_config` /
`set_ignore_patterns` / `get_ignore_patterns`, `config.load_folder_config` etc.)
— inherited from Tagger and unused by the Filmotéka workflow.
- **Per-file metadata sidecars (`.{filename}.!tag`).** `File` metadata is now
stored only in the pool index; an index-less `File` is an in-memory object and
writes nothing to disk.
- **Old tkinter GUI** (`src/ui/gui.py`), orphaned since the PySide6 rewrite and
the only remaining consumer of the folder-config/sidecar code.
## 1.7.0 — 2026-07-02
### Added
- **Nastavení → "Statistiky…"**: a library overview dialog — total movies, total
size, average ČSFD rating, with/without ČSFD link, untagged count, and a
per-category tag breakdown (counts sorted by frequency).
`FileManager.statistics()` aggregates it.
- **Testy menu → "Kontrola konzistence dat…"**: compares the pool index against
the actual files in pool/Filmy and reports **metadata without a file** (movie
deleted directly) and **files without metadata** (dropped into the pool without
importing). Read-only diagnosis (`FileManager.check_data_consistency`).
- **"- Nově přidané"** special folder in the Filmotéka: the **10** most recently
added movies as hardlinks (while **"- Tipy dne"** holds **15** random ones).
Import now records an **`added`** timestamp on each movie (`File.added`);
ordering falls back to the file's **mtime** for older items — not ctime, which
the hardlink generation itself bumps (`File.added_timestamp`,
`HardlinkManager.generate_recently_added`).
- Standalone **`scripts/tipy_dne.py`** to regenerate the "Tipy dne" folder
independently of the app (deployable on the server / cron): reads the pool
index, picks N random movies (default 15), clears the tips folder and recreates
the hardlinks. Stdlib only; paths via `--pool`/`--output` or the global config.
Ships with a systemd oneshot `tipy-dne.service` + daily `tipy-dne.timer`.
### Changed
- **Special folders are prefixed with "- "** so DLNA/TV browsers sort them ahead
of the genre folders at the output root: the grouping roots (now `- Dle roku`,
`- Dle země původu`, `- Dle hodnocení`, …) plus `- Tipy dne` and `- Nově
přidané`. Genre folders stay at the root, listed after the special ones.
### Fixed
- Obsolete-link cleanup now also removes **orphan links** in the tag tree —
hardlinks pointing at an inode no longer in the pool (movie re-imported /
replaced) — not just links whose current pool inode moved. Within a managed
folder, any file that isn't an expected link is swept, so leftovers from a
renamed layout (e.g. an old unprefixed `Dle roku`) are cleaned up on the next
generation instead of lingering forever.
## 1.6.0 — 2026-06-16
### Added
- Filmotéka generation now also creates a **"Tipy dne"** folder with **10 random
pool movies** (hardlinks; copy-as-is folders like Seriály are excluded). The
folder is refreshed each run (`HardlinkManager.generate_random_tips`).
### Fixed
- Stale **root-level genre folders** were left in the Filmotéka output when the
last movie carrying a genre dropped that tag: obsolete-link cleanup only
scanned genre folders still present on current movies, so an emptied one was
never visited. Cleanup now also sweeps other root-level folders (excluding the
grouping roots and `reserved_subfolders` — copy-as-is mirrors, "Tipy dne") and
removes the now-empty folder. Grouping folders (Dle roku/…) were already fine.
## 1.5.0 — 2026-06-16
### Added
- **Free-form per-movie attributes** (`attributes` in the index, set via *Filmy →
Nastavit atribut…* / context menu): arbitrary `key → value` pairs (e.g.
`collection_sort`) that are exposed to Filmotéka filename templates through
`File.name_context`. So a Kolekce template `"{collection_sort} - {title}{ext}"`
orders the folder as `01 - Dr. No.mkv`, `02 - …`. Empty value removes the attr.
- **Per-category filename template** in the tag schema (`filename_template`,
editable in *Nastavení → Tag schéma…*): the hardlink inside that category's
folders is named from the template (fields `title`/`year`/`rating`/`ext`/
`stem`/`filename`), e.g. a *Kolekce* with `"{year} - {title}{ext}"` yields
`Dle kolekce/James Bond/1962 - Dr. No.mkv` while every other folder (and the
pool) keeps the plain filename. `HardlinkManager` takes
`category_filename_templates`; `File.name_context()` supplies the fields.
- The "Přiřadit štítky" dialog now lists **all tag-schema categories** (including
empty user ones like *Kolekce*) and has a **"➕ Nový štítek…"** button to create
a tag value (e.g. *Kolekce/James Bond*) on the spot and assign it. Tag values
are created on first assignment — the schema editor defines categories + rules,
this dialog defines/assigns the individual values.
### Changed
- Tag schema `transform` now shapes only the **Filmotéka folder name**, not the
tag value. ČSFD tags keep the **exact value** (rating → `Hodnocení/90`) while
the grouping transform is applied when building folders (→ `Dle hodnocení/
90–100 %`). `csfd_field_values` no longer transforms; `HardlinkManager` takes a
`category_transforms` map (`FileManager.filmoteka_category_transforms`) and
`apply_transform`/`decade_band` run at folder-generation time.
### Fixed
- Sidebar filter tree no longer re-expands every category on each check/uncheck:
the expanded/collapsed state is preserved across the rebuild (new categories
still default to expanded).
## 1.3.0 — 2026-06-16
### Added
- **Configurable tag schema** (`tag_schema` in the global config, edited via
*Nastavení → Tag schéma…*): a single source of truth for which tag categories
exist, which ČSFD field feeds each (with an optional value transform, e.g.
`decade_band` for the rating), and how each maps into the Filmotéka tree
(`""` = output root, `"Dle …"` = grouping folder, none = filter-only, no
folders). Replaces the hard-coded `FILMOTEKA_CATEGORY_ROOTS` and the fixed
category list in `apply_csfd_tags`; `HardlinkManager` roots are now derived
from the schema (`FileManager.filmoteka_category_roots`).
- **Tag provenance (ČSFD vs user)**: each file tracks which tags came from ČSFD
(`csfd_tags` in the index). `apply_csfd_tags` now **regenerates only the ČSFD
tags** from the schema and leaves user-added tags untouched — so changing a
movie's ČSFD link refreshes its ČSFD tags without wiping your own.
## 1.2.0 — 2026-06-16
### Added
- Fork of the former **Tagger** project as **Curator**, a movie-library manager.
- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and
a configurable **Filmotéka** output folder, stored in the global config.
- **Multi-file "Import movies" flow**: pick several videos at once and give each
its own Title + ČSFD link (one row per file, more addable in the dialog); a
copy/move toggle chooses whether sources are copied (default, non-destructive)
or moved into `pool/Filmy` as `Title.ext`. Imported movies are indexed and, if
a ČSFD link is set, enriched with tags right away.
- **Auto-find ČSFD links** in the import dialog ("🔎 Najít ČSFD odkazy"): for
every row without a link it cleans the filename into a query
(`clean_filename_to_query` strips resolution/codec/source/group, keeps the
year) and fills in the first ČSFD search hit (`find_csfd_url` →
`search_movies`, reusing one Anubis session). Existing links are never
overwritten; results are a suggestion the user can review before importing.
- Import dialog now **highlights in red any title that already exists in the
pool** (live as you type) and lets you **remove a single row** (✕ per row), so
you can drop one file without discarding the whole batch and re-picking.
- **Conflict resolution on import**: when a row is left red (name already in the
pool), import asks whether to **replace** the existing movie(s) with the new
file, **keep both** (numeric suffix), or **cancel**. `FileManager.import_movie`
gained an `on_conflict` policy (`suffix` / `replace` / `skip`); `replace`
evicts the existing same-named movie (file + index metadata) first.
- `File` now stores `title` and `csfd_link`.
- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import,
tag filter sidebar, movie table, one-click Filmotéka generation), replacing the
tkinter GUI as the entry point.
- Seriály **copy-as-is mirror**: `pool/Seriály` is cloned 1:1 into the Filmotéka
output as hardlinks (`HardlinkManager.mirror_as_is`), generated alongside the
movie tree.
- **Unified pool metadata index** (`pool_index.py`): the whole pool's metadata
lives in a single `<pool>/.Curator.!index` JSON keyed by pool-relative path.
`File` reads/writes the index when one is injected and otherwise keeps using
per-file `.!tag` sidecars; `FileManager` uses the index for the pool.
- **Configurable copy-as-is folders**: `copyasis_folders` in the global config
(editable from the GUI) lists pool subfolders mirrored 1:1 during generation;
`Seriály` is the default. Generalizes the previously hardcoded Seriály mirror.
- Project `README.md` (overview, concepts, workflow, run/build instructions).
- **ČSFD scraping** (`csfd.py`, ported from the Tagger devel branch): fetches
movie data from a ČSFD link (JSON-LD + HTML parsing). `File.apply_csfd_tags`
assigns **Žánr / Rok / Země původu / Hodnocení** tags and caches the fetched
data (incl. directors and the first 10 actors) in the metadata. The rating is
bucketed into ten-point bands (`rating_band`, e.g. `80–89 %`, `90–100 %`).
**Directors and actors are collected but intentionally not turned into tags or
Filmotéka folders** — there would be far too many. The GUI auto-fetches on
import when a link is given and offers "Načíst tagy z ČSFD" for selected movies.
- **Rename a pooled movie** from the app ("Přejmenovat…" in the Movie menu /
context menu, F2): `FileManager.rename_movie` renames the physical file in
pool/Filmy to `<new name>.<ext>` (extension preserved), moves its metadata to
the new index key, and syncs `title`/`filename`. Refuses empty names, names
with path separators, and collisions with an existing pooled file.
- App startup injects `truststore` so HTTPS uses the OS certificate store —
ČSFD fetching works behind corporate SSL inspection (where certifi's bundle
lacks the proxy root CA).
### Fixed
- `media_utils.add_video_resolution_tag` referenced `subprocess` without importing it.
- ČSFD parsing updated for the current site HTML: year is read from JSON-LD
`dateCreated`, and the origin line (now bullet-separated, no commas) is
tokenized so country / year / duration are extracted correctly.
- **ČSFD anti-bot wall (Anubis):** ČSFD now serves a proof-of-work challenge
page instead of the movie, so fetches returned a film with no genres/year
("načteno 0 tagů"). `csfd.py` now detects the Anubis challenge, solves the
SHA-256 proof-of-work the way the bundled worker JS does, and replays the
request through a `requests.Session` (reused across a batch so only the first
fetch pays the PoW cost). Žánr / Rok / Země původu tags load again.
- "Assign tags" dialog crashed on PySide6/Qt6 — `Qt.ItemIsTristate` was renamed
to `Qt.ItemIsAutoTristate`.
- Sidebar tag-filter checkboxes never appeared checked: every toggle triggered a
table refresh that rebuilt the tree from scratch (all unchecked), wiping the
click. The active filter is now kept in a separate model (`_active_filter`) and
restored on rebuild. The count after each tag is also now filter-aware — it
shows how many of the currently filtered movies carry that tag (i.e. how many
would remain if it were checked), instead of always the pool-wide total. The
refresh is deferred via `QTimer.singleShot` so the tree is not rebuilt inside
its own `itemChanged` signal (which deleted the item Qt was still processing
and crashed the app with SIGSEGV on a real click).
### Changed
- ČSFD country tag category renamed **Země → Země původu**. Added
`scripts/migrate_tag_category.py` to rewrite the category in an existing pool
index (backs up `.Curator.!index` first); run against the live pool.
- Filmotéka tree **relaid out**: genre folders now sit **directly at the output
root** (next to the copy-as-is Seriály mirror), with year tags grouped under a
**`Dle roku`** folder and country tags under **`Dle země původu`**.
`HardlinkManager` gained a category → root-folder map (`category_roots`,
empty root = tag folders at the output root) and now restricts obsolete-link
cleanup to the tag-tree's own top-level folders, so copy-as-is mirrors are
never touched. The tree also groups the ČSFD rating under `Dle hodnocení`.
- ČSFD origin is now parsed as **multiple countries**: a co-production like
"USA / Velká Británie" becomes a separate **Země původu** tag per country
(so the film is filed under each), instead of one combined tag. `CSFDMovie`
gained `countries: list[str]` (replacing the single `country`); the csfd cache
schema bumped to v2 (legacy single-country caches are split on read).
- Movie table trimmed to **Název / Štítky / Velikost** — the Datum and ČSFD
columns were dropped (a ČSFD link is a prerequisite, so its indicator was
always the same).
- All references to "Tagger" renamed to "Curator" (code, spec, config filenames
`.Curator.!gtag` / `.Curator.!ftag`, tests).
- `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility).
### Removed
- Duplicate `src/core/constants.py` (hard-coded stale `VERSION = "v1.0.3"`). The
GUI window title imported from it, so it showed the wrong version. All imports
now use `src/constants.py` (version derived from `pyproject.toml`), which also
absorbed `APP_VIEWPORT`; the window title uses `APP_TITLE` → "Curator vX.Y.Z".
- Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
and **Barva** (color) categories in `TagManager`, and the automatic
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
empty; the pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu).
### Dependencies
- Added `pyside6` (GUI), `requests` + `beautifulsoup4` (ČSFD scraping),
`truststore` (OS cert store for HTTPS). Declared `python-dotenv`, `pillow`,
`loguru` (already imported by the inherited code).
+35
View File
@@ -0,0 +1,35 @@
# Imports
import contextlib
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.ui.qt_app import run
def _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.
"""
with contextlib.suppress(Exception):
import truststore
truststore.inject_into_ssl()
class State:
def __init__(self) -> None:
self.tagmanager = TagManager()
self.filehandler = FileManager(self.tagmanager)
def main() -> None:
_use_system_certificates()
state = State()
run(state.filehandler, state.tagmanager)
if __name__ == "__main__":
main()
+38
View File
@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['Curator.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Curator',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+235
View File
@@ -0,0 +1,235 @@
# Python Development Guidelines
**Document Version:** v12
> **Note on Versioning:**
> - This document version is independent — reused across projects
> - **Project version** source of truth: `pyproject.toml`
> - Version propagates: `pyproject.toml` → `constants.py` → code
> - `CHANGELOG.md` uses project version from `pyproject.toml`
## Related Documents
- **README.md** — Project overview, tool descriptions, build instructions
- **AGENTS.md** — Rules for AI assistants
- **PROJECT.md** — Project goals and current state
- **CHANGELOG.md** — Version history
### Documentation Organization
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **PEP8** with 120-character lines (Ruff)
- **4 spaces** indentation
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
- **Type hints** required on all functions
- **Import order**: stdlib → third-party → local
---
## 2. SOLID Principles
- **SRP** — One class = one responsibility
- **OCP** — Open for extension, closed for modification
- **LSP** — Subclasses substitutable for parents
- **ISP** — Small interfaces over large ones
- **DIP** — Depend on abstractions
---
## 3. Dependency Injection
Pass dependencies via constructor. Never instantiate dependencies inside a class.
---
## 4. Protocols Over Inheritance
Prefer `typing.Protocol` and composition over class inheritance.
---
## 5. Data Classes
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
---
## 6. Logging and Console Output
### Logging — loguru
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
#### Log sinks
| Sink | Level | Format |
|------|-------|--------|
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
| stdout | `INFO` | full |
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
Additional sinks (e.g. GUI log panels) may be added per project as needed.
#### Log levels
| Level | When to use |
|-------|-------------|
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
### Console output — print()
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
---
## 7. Environment and Secrets
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
- Load via `python-dotenv` and `os.getenv()`
- Never commit `.env`
---
## 8. Error Handling
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
---
## 9. Testing
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
- Arrange-Act-Assert pattern
- Test naming: `test_<action>_<context>`
---
## 10. Tooling
| Tool | Purpose |
|------|---------|
| **Ruff** | Formatting and linting |
| **mypy** | Static type checking |
| **pytest** | Testing |
Run before every commit:
```bash
uv run ruff check
uv run mypy
uv run pytest
```
---
## 11. uv
```bash
uv sync # Install all dependencies
uv add <pkg> # Add runtime dependency
uv add --dev <pkg> # Add dev dependency
uv remove <pkg> # Remove dependency
uv run <cmd> # Run command in virtualenv
uv python install <version> # Install a uv-managed Python
```
Never edit `pyproject.toml` directly to add or remove dependencies.
The interpreter is a uv-managed Python — no pyenv, no system Python.
### uv.lock
`uv.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
---
## 12. Project Structure
```
project/
├── entry_point.py # Entry point(s) — named by purpose or tool name
├── src/ # All application modules
├── tests/ # Tests
├── docs/ # Detailed documentation
├── .venv/ # Virtual environment (managed by uv)
└── pyproject.toml # Project config and dependencies
```
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
---
## 13. Distribution and Deployment
When a project is distributed as a standalone executable (no Python required on target machine):
- Use **PyInstaller** to compile each entry point into a single `.exe`
- Each tool has its own `.spec` file in the project root
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
### prebuild.py
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
| Variable | Default | Effect |
|----------|---------|--------|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
Build commands:
```bash
uv run python prebuild.py
uv run pyinstaller ToolName.spec
```
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
---
## 14. Versioning
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
- Version is defined in `pyproject.toml` under `[project]`
- Always ask before bumping the version — never increment automatically
- Update `CHANGELOG.md` before bumping the version
---
## 15. Documentation and Task Management
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
- Document architectural changes in this file or in `docs/`
### Task notation
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```python
# TODO: extract this into a separate loader
# FIXME: crashes on an empty file
# BUG: rounding is off by one cent on negative amounts
# HACK: temporary workaround until the API adds paging
# NOTE: order matters here, the parser is stateful
```
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+478
View File
@@ -0,0 +1,478 @@
# PROJECT.md
This file is project-specific. Only include information directly related to the concrete project — goals, current state, architecture decisions, known issues, and tasks.
## Origin
Curator is a fork of the former **Tagger** project. The tagging, filtering and
hardlink-tree parts are inherited and keep working as before. On top of that,
Curator becomes a full **movie library manager (Filmotéka)**.
## Core idea
Curator manages a personal movie library based on two folders:
- **Pool** — the managed repository of video files. This is the **single source
of truth**. Curator manages the pool itself (insert/remove file), so files are
never moved by hand. The pool has exactly two top-level folders: **Filmy**
(movies — tag-based tree) and **Seriály** (series — a "copy-as-is" folder
mirrored 1:1 into the output; see Design decisions). Every file lives here
exactly once.
- **Filmotéka (output)** — a generated, browsable directory tree made only of
**hardlinks** into the pool (the same mechanism as today's hardlink manager).
It is fully disposable: deleting the Filmotéka folder loses nothing, because
it can always be regenerated from the pool.
### Workflow
1. The user configures two folders: the **pool** and the **Filmotéka output**.
2. The user picks a video file via "Open file".
3. Curator opens a dialog to fill in basic info — at minimum the **title/name**
and a **ČSFD link**.
4. Curator **renames** the file and **moves** it into the managed pool, and
writes a **metadata file** describing it.
5. From the pool, Curator **generates the Filmotéka** — a complex tree of
hardlinks built from each file's tags/metadata (like the current hardlink
manager, but driven by the pool).
6. Deleting the Filmotéka has no effect on the pool; the tree is regenerated on
demand.
## Current state
- Inherited from Tagger: `Tag`, `TagManager`, `File` (sidecar metadata),
`FileManager` (folder scan, filtering, ignore patterns), 3-level config,
`HardlinkManager` (create/sync/cleanup), pytest suite.
- Rename Tagger → Curator done across code, spec, config filenames
(`.Curator.!gtag` / `.Curator.!ftag`) and tests.
- **PySide6 GUI** (`src/ui/qt_app.py`) reframed around the Filmotéka workflow is
the entry point; the old tkinter `src/ui/gui.py` is retained for reference.
- **Pool + Filmotéka wired up:** global config holds `pool_dir` / `filmoteka_dir`;
`FileManager` creates `Filmy`/`Seriály`, imports movies (copy → `Title.ext`),
loads the pool, and the GUI generates the Filmotéka tree via `HardlinkManager`.
- `File` carries `title` + `csfd_link`. **Pool metadata lives in a unified index**
(`<pool>/.Curator.!index`, see `pool_index.py`); `File` writes there when an
index is injected. Without an index a `File` is an in-memory object only — the
old per-file `.!tag` sidecars and the per-folder `.!ftag` config have been
removed.
### GUI decision
The GUI was **reframed around the Filmotéka** (not kept as a generic tagger) and
**rewritten in PySide6**: Pool/Filmotéka setup, Import movie, tag-filter sidebar,
movie table, and one-click Filmotéka generation.
## Design decisions
- **Metadata storage:** one **unified metadata file** for the whole pool (a
central index), not per-file sidecars. Justified because Curator owns the pool
and files are never moved manually, so it is not exposed to path drift.
- **The record is the movie, files are its children** (index **v2**,
`movie.py`). Keyed by identity (`tmdb-<id>`, else a `name-<slug>` fallback),
each record holds the metadata *and* a `media` list of its files with a free
`label` per version. Chosen after the v1 path-keyed shape made multi-version
movies unworkable: metadata rode on one file, so it had to be rescued when
that file went, and retitling a grouped movie had to be forbidden. With
identity as the key, `Movie.desired_layout()` is a pure function of the
record — **one video → flat, two or more → own folder** — so promote, demote
and rename are the same operation, and an import can recognise "this tmdbid
is already in the pool" instead of silently creating a duplicate.
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
pointers (which must stay global: the index is found *through* them). Everything
that describes a specific library — `tag_schema` and `copyasis_folders` — lives
in the pool index under a `settings` section, so it travels with the pool.
`FileManager` migrates any pre-existing global values into the index on first
open. The old per-folder `.!ftag` config and per-file `.!tag` sidecars were
removed (they were unused Tagger leftovers), and the orphaned tkinter
`src/ui/gui.py` went with them.
- **Import dialog:** **multi-file** — pick several videos at once and give each
its own **Title** + **ČSFD link** (one row per file, more can be added from the
dialog), or auto-filled with **"Najít ČSFD odkazy"** (cleans each filename into
a query and fills the first ČSFD search hit; existing links are kept). A single
**copy/move** toggle decides whether the sources are copied (default) or moved
into the pool. Each file is renamed to `Title.ext`. When a
ČSFD link is given, Curator fetches the movie and assigns Žánr / Rok / Země
původu / Hodnocení (ten-point band) tags automatically; further tags can be
added via the UI. Directors and the first 10 actors are fetched and cached too,
but **deliberately not turned into tags/folders** (there would be too many).
- **Genres / countries:** a movie can have **multiple genres** and, for a
co-production, **multiple countries of origin** (ČSFD writes them
slash-separated, e.g. "USA / Velká Británie"). Each becomes its own tag, so the
film appears under every matching genre and country branch in the Filmotéka
(multiple hardlinks).
- **Pool layout:** two top-level folders — **Filmy** and **Seriály**. Movies are
the first target; the Seriály branch follows the "copy-as-is" rule below.
- **Copy-as-is folders (Seriály):** a subfolder inside the pool can be marked as
**copy / as-is**. For such a folder Curator does **not** build a tag-based tree;
instead it **mirrors the exact directory hierarchy** from the pool into the
Filmotéka output, with the files materialized as **hardlinks** into the pool.
So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure,
hardlinked files). This is how Seriály work.
- **File naming:** pooled movies follow the convention **`Title (YYYY).ext`** — a
clean title plus a four-digit year in parentheses (`naming.py`:
`canonical_pool_stem` / `parse_pool_stem`). The year lets two same-named films
coexist and makes the filename self-describing (`File.name_context` recovers
the year from the filename when no ČSFD/tag year is present). The GUI **import
requires a four-digit year** (per-row *Rok* field, pre-filled from the source
filename and fillable from ČSFD — a ČSFD-suggested year is tinted blue to be
verified, a name colliding with the pool is tinted red). *Filmy → "Přejmenovat
dle ČSFD (rok)…"* (`FileManager.rename_all_to_canonical`) brings older
`Title.ext` files up to the convention from their ČSFD metadata; files without
a known year are skipped. `File.title` always stays the clean title (no year).
- **Import copy vs move:** by default the original file is **copied** into the
pool (non-destructive); the import dialog also offers a **move** option that
relocates the source into the pool instead.
- **Filmotéka tree layout:** driven by a category → root-folder map
(`FILMOTEKA_CATEGORY_ROOTS`). At the output root sit the **genre folders
directly** (`output/Akční/film`, …), next to the copy-as-is mirrors
(**Seriály**), plus two grouping folders: **`Dle roku`** (`output/Dle
roku/<rok>/film`) and **`Dle země původu`** (`output/Dle země
původu/<země>/film`), plus `Dle hodnocení`. Each is a hardlink.
`HardlinkManager` supports an empty root (tag folders placed directly at the
output root) and restricts obsolete cleanup to the tag-tree's own top-level
folders so mirrors are never touched.
- **Tag schema (config-driven, not hard-coded):** the categories, their ČSFD
source field + transform, and their Filmotéka folder mapping all live in
`tag_schema` in the global config (default `config.DEFAULT_TAG_SCHEMA`, edited
via *Nastavení → Tag schéma…*). Both `apply_csfd_tags` (which fields → tags)
and the Filmotéka layout (`FileManager.filmoteka_category_roots`) read from it,
so adding a category or changing a folder rule needs no code change. A category
can be made filter-only (no folders) by setting its `filmoteka_root` to null.
The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`);
it is applied at Filmotéka generation via `filmoteka_category_transforms`.
- **Filmotéka link naming (clean title, year on collision):** the default
hardlink name in the tag tree is the movie's **clean title** — `Title.ext`,
*not* the pooled `Title (YYYY).ext`. When two different films would collide on
that name **within one folder**, the colliding ones keep the disambiguating
**year** (`Title (YYYY).ext`); a residual same-title-and-year clash gets a
stable numeric suffix. `HardlinkManager._plan_links` resolves the whole tag
tree to a collision-free `(file, path)` plan that **both** link creation and
obsolete-detection consume, so they can never disagree on a name (the previous
churn source). Copy-as-is mirrors (Seriály) are unaffected — they mirror 1:1.
- **Per-category filename template** (`filename_template` in a schema entry):
overrides the default naming **inside that category's folders only**, rendered
from the movie's metadata (`File.name_context`: title/year/rating/ext/stem/
filename plus any free-form attributes), e.g. a Kolekce with
`"{collection_sort} - {title}{ext}"`. Applied via
`filmoteka_category_filename_templates`.
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary
`key → value` metadata stored in the index and merged into `name_context`, so
custom fields like `collection_sort` can drive filename templates.
- **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD
(`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so
changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones.
- **Special folders + "- " prefix:** besides the tag tree, generation produces
`- Tipy dne` (15 random) and `- Nově přidané` (10 newest by `File.added`, mtime
fallback — ctime is bumped by hardlinking). All grouping folders and these
specials are prefixed with **"- "** so
DLNA/TV browsers sort them before the genre folders at the output root. Specials
are `reserved_subfolders` so cleanup never touches them; renaming old unprefixed
folders is handled automatically (they become stale root-level folders and are
swept on the next generation).
- **Date added:** `import_movie` stamps `File.added` (ISO); older items without it
fall back to the file's mtime for the "recently added" ordering (ctime is
unreliable — creating the Filmotéka hardlinks bumps it).
## Direction — Jellyfin pivot (in progress)
Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first
library manager**. Agreed shape:
- **Pool = the Jellyfin library set.** Files are named `Title (rok) [tmdbid-N].ext`
— the form Jellyfin matches on. Libraries are physical top-level pool folders,
each with a **kind** (`libraries` setting; managed in *Pool → Knihovny…*):
- `movie` — single-file movies, TMDb naming (done)
- `series` — series/season/episode tree, mirrored 1:1 (done)
- `mixed` — movies **and** series in one folder (Jellyfin "Mixed" type;
supported-but-discouraged by Jellyfin, but low-risk here thanks to the strict
structure, and fully reversible — see decisions). **Scanning TBD (phase 9).**
- `homevideo` — files with no TMDb (Jellyfin "Home videos" type). **TBD (ph. 8).**
- **Target library layout (the user's plan):** `Hlavní` (mixed, incl. kids),
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
(homevideo). Kids are **not** a separate folder — see decisions.
- **Naming convention:**
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D
movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's
own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the
edit dialog and preserved across every future rename like the tmdbid is.
- **Multiple movie versions** (2D+3D, 4K+1080p, …): identity is
`(title, rok, tmdbid)`, stays a **flat file** at one version. A second
version promotes it to Jellyfin's required per-movie folder — every file
prefixed with the exact folder name — `Title (rok) [tmdbid]/Title (rok)
[tmdbid] - {popisek}.ext`; a 3D version's popisek doubles as its 3D tag
(`3D HSBS`, scanned by Jellyfin the same as the flat `.3D.HSBS` form).
Curator does the promotion/demotion itself (*Upravit film → Verze*), and a
version's popisek is renamable too (`rename_movie_version`) — a flat movie
always shows one placeholder row ("Výchozí") in the list even though it
has no on-disk label yet, but renaming only applies once it's grouped. A
popisek left empty (a plain "2D" file with nothing else to call it) is
`DEFAULT_VERSION_LABEL` ("Výchozí"), not an error — everywhere a popisek is
entered (`add_movie_version`, `rename_movie_version`, `merge_as_version`,
the import dialog's version link). Promoting/demoting always carries the
file's *own* current 3D formát over automatically when no popisek is
given for it (`existing_label`/`keep_label` default to `None` → derived
via `naming.parse_3d_format`) — a movie can freely gain and lose versions
in any order (3D-only → +2D → -3D → 3D-only again, …) without a formát
ever getting silently dropped along the way.
The default (plain) version of a grouped movie carries **no label**:
`Folder/Folder.ext` (`file_manager._version_filename`); "Výchozí" is the
UI's name for it. Identity — what decides "is this film already in the
pool" — is the folder name for a grouped movie and the stem minus any
`.3D.<FORMAT>` for a flat one (`FileManager._identity_stem`,
`pooled_with_stem`). Importing a film that's already there defaults to
adding a version, never a root-level duplicate, and "keep both as separate
films" is not offered for same-TMDb-id films (they'd share one index record).
**Parts** (one film split across files, Jellyfin "stacking") are versions
with a `partN` popisek, written as `Folder-partN.ext` instead of
`Folder - popisek.ext` (`file_manager._version_filename`,
`movie.normalize_part_label`); a film with parts always lives in its own
folder. Parts and versions in one folder are not combined yet.
*Upravit film* presents this as one "Média filmu" section: flat
("Samostatný film") until a second medium arrives, then a folder whose
mode is switched between versions and parts (`movie_group_mode`,
`set_movie_group_mode`); parts are reordered with ↑/↓ (`move_movie_part`).
Renames after a relabel go through `File.save_location`, never a plain
`save_metadata`, so a stale sibling can't overwrite shared metadata.
Custom per-movie attributes are no longer editable in the GUI; stored ones
still feed the miniDLNA filename templates.
The table always shows one row per movie (`visible_movies`), marked `▣` /
`Film · N verze` when it has several; shared metadata lives on the movie
record (index v2), so every version reads the same. Accidental duplicates
(same tmdbid / title+year / file size as separate movies) are found by
*Nástroje → Kontrola duplicit* and folded in via `merge_as_version`.
**Not yet supported:** renaming/moving/retitling a grouped movie (remove
the version or edit files directly).
Works in `mixed` libraries too, disambiguated from a series by **shape**:
a movie's version folder and a series folder are both
`Title (rok) [tmdbid]/`, but a series always nests episodes in a
`Season NN`/`Specials` folder and a movie's version folder never does
(`series.looks_like_series_folder`) — `load_pool_movies`/
`is_movie_group_member` read the shape and `scan_series`
(`skip_movie_folders=True` for `mixed`) skips what it recognises as a
movie, so both kinds can share one identity-named folder pair without
either leaking into the other's listing. A `series`-kind library (which
never holds movies) keeps reading every subfolder as a series regardless
of shape, so a raw not-yet-renamed import can still have loose episode
files at the series root before `Season NN` exists.
Assignable at **import** time too, not just after the fact: picking
several files for the same film in one batch (*Importovat filmy…*), a
row's "🔗 Přiřadit jako verzi…" marks it a version of another row instead
of importing it as its own movie — the earlier attempt at this (a 3D
formát combo per row) was rejected because a lone row has nothing to
attach the version *to* and just created a duplicate; picking the target
row explicitly is what that was missing. The popisek is pre-filled from
the filename (`naming.three_d_format_from_filename`) the same way
`year_from_filename` pre-fills the year. Import runs the target rows
first, then folds each linked row onto its target via
`add_movie_version`. The reverse case — a film whose only file *is* 3D,
with nothing to link it to — gets its own **3D formát combo per row**
instead (`import_movie(..., format_3d=…)`, same as *Upravit film*);
disabled the moment the row is linked as a version or gains one as a
follower, since a grouped movie's 3D-ness lives on the version's popisek,
not on the row that started the group.
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {série}.ext`
(season 0 → `Specials`). The episode filename is the code + the series title
only — the release string is dropped (identity is the code; Jellyfin fetches
episode metadata from TMDb via the code + `[tmdbid]`). Episode numbers are
recognised from the filename and editable at import / in the series GUI;
*Seriály → "Napravit názvy souborů…"* bulk-fixes the whole pool.
- **Episodes split into physical parts** (an old recording cut into
several files) get Jellyfin's own `.ptN` stacking suffix —
`SxxEyy - {série}.ptN.ext` — recognised from *cd/dvd/part/pt/disc/disk* +
a number in the source filename (`Show.S01E02.pt2.mkv`,
`Show S01E01-part-1.mkv`, …) or typed by hand at import as `S01E02P3`
(`src.core.series.parse_episode_code`/`parse_episode_part`). Distinct from
a multi-episode file (`E01-E02`, one file = several episodes) — a part is
the opposite: several files = one episode.
- **Removing a series is scoped to the tree selection**: *Přehled seriálů →
"Odebrat z poolu…"* deletes the series, a single season, or a single
episode, whichever is selected (`FileManager.remove_series`/
`remove_season`/`remove_episodes`) — series still carry no index metadata,
so this is always just deleting files/folders from disk.
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
`tmdbid` (yearless title search picks the wrong film, hence the earlier
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
TMDb has no cs title); last fallback = the original filename. The filename is
always editable in the GUI.
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
kept **only** as a title source (not for tags/matching — it exposes no
TMDb/IMDb id, verified): it feeds the **ČSFD → TMDb bridge** (`csfd.py`,
`FileManager.resolve_via_csfd`) — filename → ČSFD search → ČSFD *original*
title + year → TMDb search → tmdbid, more reliable than a raw Czech-title
TMDb search. Used by the import dialog ("Dohledat přes ČSFD") and by migration
for any movie with a stored `csfd_url`.
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
(drives the ČSFD→TMDb bridge, and stored as a pointer); filename hand-editable
before/after import. The import row is pre-filled on open (no network) from
metadata already next to the source file: Curator's **pre-index** layouts —
a `.{filename}.!tag` sidecar or an old `.Curator.!index` up the tree
(`legacy_meta.read_legacy_meta`, authoritative for the ČSFD link) — then a
Kodi/Jellyfin `.nfo` (`nfo.read_nfo`, also reads a `csfd` `uniqueid` /
`<csfdid>`, and supplies the TMDb id).
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
`naming.py`), not from ČSFD tags. **Scoped per library** (*miniDLNA →
"Knihovny pro generování…"*, `minidlna_libraries` in the pool index, default
= all) — lets a library (e.g. Kids) be excluded from the dumb-DLNA output
entirely; a movie library's genre-tree links are swept as obsolete the normal
way when it's excluded. A `series` **or** `homevideo`-kind library has no
genre tags to build a tree from (homevideo has no TMDb id either), so both
hardcopy whole into `output/<jejich vlastní název>` (`mirror_as_is_libraries`)
— this works today independent of phase 8 (homevideo needs no GUI
import/browse support to be mirrored). Mirrors have **no obsolete-link
mechanism of their own**, so excluding one is pruned explicitly
(`FileManager.minidlna_prune_targets` + `HardlinkManager.remove_mirror`) —
including the shared `output/Seriály` bucket a mixed-library series and a
same-named real library can both write into, reconciled per-entry so pruning
one never erases the other's still-valid content.
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
key + URL in `.env`. NFO on disk stays authoritative.
- **Subtitles stay external, never muxed** (`subtitles.py`): a `.srt`/`.ass`/
`.ssa`/`.sub`/`.vtt` sharing the video's stem (+ optional `.lang`/`.forced`/
`.sdh`/`.default`) plays natively in Jellyfin — no re-encode, multiple
languages side by side, easy to replace. Presence is read live from disk
(like NFO), not cached in the index. The one thing Curator owns: a subtitle
**follows its video through every rename/move** so it never silently
detaches (every rename/move path calls `subtitles.rename_subtitles_with`).
### Decisions (locked)
- **Kids = Jellyfin parental rating, not a folder** (variant B). Kids content
stays in the normal libraries; a child Jellyfin account gets a max-rating limit
with **"block unrated" ON**. Ratings come from TMDb via Jellyfin. Caveat: Czech
certifications are sparse — set the library metadata country to one with fuller
ratings (US) and fix odd cases in Jellyfin (Identify + lock).
- **Mixed libraries are OK and reversible.** Jellyfin discourages them (unreliable
metadata) but our disciplined structure (series = `Season/SxxEyy`, movie =
single file + `[tmdbid]`) gives the scanner clear signals. Because metadata is
**NFO on disk** + **tmdbid in the name**, switching mixed↔separate later is just
moving folders (Jellyfin re-reads NFO, no re-fetch, no loss).
- **Home videos** (Chajda) use Jellyfin's **"Home videos"** content type — no
online lookup, just serves the files.
- **Always match by tmdbid, not by yearless title.** TMDb cs-CZ titles are good
when the right film is matched (lookup by id, or search with a year); a bare
title search trips on remakes/same-name films (verified: Home Alone → 2021
reboot, Vejška → 2006). Import supplies the id; migration searches with a year.
### Phasing
(1) TMDb identity + tmdbid naming + import rework [done]; (2) movie libraries as
physical partitions + import-into / move-between [done]; (3) Jellyfin API — scan +
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library
scanning + generation (movies + series in one folder) [done].
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs).
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
[tmdbid].ext`); per-movie folders are a later refinement.
## Tasks
### Phase 8 — import without TMDb / without year (homevideo, no-TMDb docs)
- Relax the import dialog: allow a row with **no year and no tmdbid** (currently
a 4-digit year is mandatory). Pool name then falls back to `Title.ext`
(movies) / `Series/Season NN/SxxEyy.ext` (series), no `[tmdbid]`.
- Drive this by **library kind**: `homevideo` (and no-TMDb rows in `mixed`)
skip the TMDb requirement; `movie` keeps requiring a year (as today).
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
**miniDLNA generation for `homevideo` needs none of this** — it hardcopies the
whole library folder as-is (`mirror_as_is_libraries` **[done]**); this task is
only about GUI import/browsing.
- Tests: import with no year/id lands correctly; homevideo library loads.
### Phase 9 — mixed-library scanning + generation
- Scanning **[done]**: a `mixed` library's top-level files load as movies
(`load_pool_movies`), its subfolders as series (`load_series` /
`series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`) —
*except* a canonical-shaped subfolder with no `Season NN`/`Specials` inside
it, which is a movie's version folder instead and loads as a movie too (see
"Multiple movie versions" above). Both are kind-aware over the unified
`libraries` list. The library filter / table already surfaces mixed
libraries.
- miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as
usual; series in its subfolders have no genre tags, so they're hardcopied
straight into `output/Seriály/<název>/…` instead
(`FileManager.minidlna_scope` → the mixed-library series list; the mirror
loop in `generate_filmoteka`). NFO refresh needs no separate handling — it
only ever touches movies (`self.filelist`), series never go through NFO/TMDb
tagging, mixed or not.
- Tests: a mixed folder with both a movie and a series loads/generates
correctly **[done]** (`TestMinidlnaScope` in `test_file_manager.py`).
### Later / optional
- **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was
matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW
solver, film-page + search parsing, `original_title` extraction — the entry in
`.film-names` whose flag matches the country of origin). `resolve_via_csfd`
drives it from the import dialog and from migration. **Not** a return of ČSFD
tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch
"look up ČSFD links for the whole pool" as a migration pre-pass (today the
`csfd_url` is per-movie, set at import / in the edit dialog).
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat
files — **partially done**: a movie only gets its own folder once it has a
second playable version (see "Multiple movie versions" below); a single-file
movie stays flat as before.
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
path-aware `library_of` (currently single-segment).
## Done
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
- Phase 9 — mixed-library scanning + generation: a `mixed` library's top-level
movies genre-tag as usual, series in its subfolders hardcopy into
`output/Seriály/<název>/…` (no genre tags apply to a series)
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
- Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
quick (ffprobe), over selected movies or the whole pool
- Pool-root and Filmotéka-output folder settings in the global config
- Filmy / Seriály top-level folder handling in the pool
- "Import movie" dialog (Title + ČSFD link), copy into pool/Filmy as Title.ext
- Rename a pooled movie from the app (`FileManager.rename_movie`): renames the
file in pool/Filmy and moves its metadata to the new index key
- Remove-from-pool (delete file + its metadata)
- Generate the Filmotéka hardlink tree from the pool (Rok / Žánr / Země původu /
Hodnocení)
- Filmotéka fully regenerable from the pool alone (delete output = no loss)
- GUI reframed around the Filmotéka and rewritten in PySide6
- Seriály "copy-as-is" mirror: pool/Seriály cloned 1:1 into the output as
hardlinks (`HardlinkManager.mirror_as_is`), wired into Filmotéka generation
- Fixed `media_utils` missing `subprocess` import
- Unified pool metadata index (`pool_index.py`): one `.Curator.!index` per pool;
`File` reads/writes it when injected, `FileManager` uses it for the pool
- Configurable copy-as-is folders (`copyasis_folders` in global config, editable
from the GUI); each is mirrored 1:1 during Filmotéka generation (Seriály default)
- README.md written (overview, concepts, workflow, run/build instructions)
- ČSFD scraping (`csfd.py`, ported from Tagger devel): `File.apply_csfd_tags`
fetches a movie and assigns Žánr / Rok / Země původu tags (cached in metadata); wired
into the GUI (auto-fetch on import with a ČSFD link, plus "Načíst tagy z ČSFD").
Parsing updated for current ČSFD HTML and verified live against Matrix
(film/9499); HTTPS uses the OS cert store via `truststore` (corporate SSL)
- ČSFD Anubis anti-bot wall handled: `csfd.py` detects the proof-of-work
challenge page, solves it (SHA-256 PoW matching the bundled worker JS) and
replays via a shared `requests.Session`, so Žánr / Rok / Země původu tags load again
(the "nalezeno 1 film, načteno 0 tagů" symptom). Verified live (Matrix 1999)
- Removed the inherited Tagger predefined tags: `DEFAULT_TAGS` is now empty
(no Hodnocení ⭐ / Barva categories) and new files no longer get an automatic
`Stav/Nové` tag. Tags now come from ČSFD (Žánr / Rok / Země původu) and manual edits.
Note: `Hodnocení` is still listed in `FILMOTEKA_CATEGORIES`, so that branch is
simply empty until something assigns a Hodnocení tag again
- Fixed template cruft: `src/constants.py` made consistent (Curator values,
`get_version`/`get_debug_mode` API) and `test_constants.py` aligned; removed
the imported `tagger/` devel dump
+60
View File
@@ -0,0 +1,60 @@
# Curator
Curator is a desktop **movie-library manager (Filmotéka)**. You import video
files into a managed **pool**, tag them, and Curator generates a browsable
**Filmotéka** — a directory tree of hardlinks into the pool — without ever
duplicating the actual video data on disk.
Curator is a fork of the former *Tagger* project; the tagging, filtering and
hardlink-tree machinery is inherited and extended into a full library workflow.
## Concepts
- **Pool** — the managed repository of files and the **single source of truth**.
Curator owns it: it imports and removes files itself, so nothing moves behind
its back. The pool has two top-level folders:
- `Filmy` — movies, organized by tags into the generated tree.
- `Seriály` — series (and any other **copy-as-is** folder), mirrored verbatim.
- **Filmotéka (output)** — a generated tree of **hardlinks** into the pool. It is
fully disposable: delete it and nothing is lost, because it is regenerated from
the pool on demand.
- **Pool index** — all pool metadata lives in a single `.Curator.!index` JSON
file at the pool root (title, ČSFD link, tags, date per file).
## Workflow
1. Configure the **pool** and the **Filmotéka output** folder.
2. **Import a movie**: pick a video, enter its **Title** and a **ČSFD link**. The
file is copied (non-destructively) into `pool/Filmy` as `Title.ext` and
recorded in the index. If a ČSFD link is given, Curator fetches the movie from
[ČSFD.cz](https://www.csfd.cz) and assigns **Žánr / Rok / Země původu** tags
automatically (use "Načíst tagy z ČSFD" to (re)fetch later).
3. **Tag** movies (Rok, Žánr, Hodnocení, …) and filter them in the UI.
4. **Generate the Filmotéka**: movies become a `Category/Tag/film` hardlink tree
(from the `Rok` / `Žánr` / `Hodnocení` categories); copy-as-is folders such as
`Seriály` are mirrored 1:1 as hardlinks.
## Running
```bash
uv sync
uv run python Curator.py
```
The GUI is built with **PySide6**.
## Development
```bash
uv run pytest # tests
uv run ruff check # lint / format
uv run mypy # type checking
```
## Building a standalone executable
```bash
uv run pyinstaller Curator.spec
```
The resulting `dist/Curator` is the distribution artifact.
Vendored Executable
BIN
View File
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
# 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.
- [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.
## Module reference
| 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/media_utils.py`](modules/core.media_utils.md) | Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag. |
| [`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. |
+196
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
# `src/_version.py`
Auto-generated — do not edit manually.
Source: [`src/_version.py`](../../src/_version.py)
+34
View File
@@ -0,0 +1,34 @@
# `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
@@ -0,0 +1,57 @@
# `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
@@ -0,0 +1,251 @@
# `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
@@ -0,0 +1,227 @@
# `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
@@ -0,0 +1,440 @@
# `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
@@ -0,0 +1,77 @@
# `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
@@ -0,0 +1,105 @@
# `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
@@ -0,0 +1,83 @@
# `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
@@ -0,0 +1,47 @@
# `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.
+27
View File
@@ -0,0 +1,27 @@
# `src/core/media_utils.py`
Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag.
Source: [`src/core/media_utils.py`](../../src/core/media_utils.py)
## Functions
### `load_icon`
*function · line 14*
```python
load_icon(path) -> ImageTk.PhotoImage
```
Load an image file and scale it to 16×16 px as a Qt-compatible icon.
### `add_video_resolution_tag`
*function · line 19*
```python
add_video_resolution_tag(file_obj: File, tagmanager: TagManager)
```
Read the vertical resolution of a video with `ffprobe` and tag the file with `Rozlišení/<height>p`. Needs FFmpeg on the PATH.
+62
View File
@@ -0,0 +1,62 @@
# `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
@@ -0,0 +1,207 @@
# `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
@@ -0,0 +1,119 @@
# `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
@@ -0,0 +1,103 @@
# `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
@@ -0,0 +1,191 @@
# `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
@@ -0,0 +1,227 @@
# `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
@@ -0,0 +1,102 @@
# `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
@@ -0,0 +1,37 @@
# `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
@@ -0,0 +1,107 @@
# `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
@@ -0,0 +1,160 @@
# `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
@@ -0,0 +1,43 @@
# `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
@@ -0,0 +1,17 @@
# `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
@@ -0,0 +1,53 @@
# `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
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=HTTP server pro HLS složku Makov webkamery (pro Jellyfin M3U tuner)
After=network-online.target makov-cam.service
Wants=network-online.target
BindsTo=makov-cam.service
[Service]
Type=simple
# WorkingDirectory je rodič hls/, aby šel přes HTTP servírovat i makov.m3u
# (playlist pro Jellyfin), ne jen samotné HLS segmenty.
WorkingDirectory=/var/lib/jellyfin-iptv
# Bind na IP brány docker sítě, na které běží Jellyfin kontejner (ne na
# 127.0.0.1 hostitele — to by z kontejneru bylo jeho vlastní loopback, ne
# hostitelův). Konkrétní hodnota je specifická pro tenhle server, viz
# setup.md.
ExecStart=/usr/bin/python3 -m http.server 8888 --bind 172.19.0.1
Restart=always
RestartSec=5
User=makov-cam
Group=makov-cam
[Install]
WantedBy=multi-user.target
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Restream Makov webkamery (Krmítko) do lokálního HLS pro Jellyfin
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/makov-cam.sh
Restart=always
RestartSec=5
# Necháme běžet pod samostatným účtem, ne pod root.
User=makov-cam
Group=makov-cam
# Pokud je yt-dlp nainstalované přes snap, ignoruje $HOME nastavené tady —
# zjišťuje domovský adresář přímo z /etc/passwd. Účet je --no-create-home,
# takže je potřeba /home/makov-cam vytvořit a vlastnit ručně (viz setup.md).
# Skript si sám zakládá /var/lib/jellyfin-iptv/hls — účet tam musí mít zápis
# (viz setup.md, sekce "Uživatel a oprávnění").
[Install]
WantedBy=multi-user.target
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Restreams the Makov webcam (YouTube live "Krmítko") into a local HLS
# playlist that never goes stale for the viewer — Jellyfin always points at
# this fixed local path, not at YouTube's own signed/expiring URL.
#
# When ffmpeg dies (the YouTube URL expired, network hiccup, stream briefly
# ended, ...) this script simply exits; systemd (Restart=always) starts it
# again, which resolves a *fresh* URL via yt-dlp. Nothing needs to be done
# by hand.
set -euo pipefail
YOUTUBE_URL="https://www.youtube.com/watch?v=F0fsV1GGZ3M"
OUT_DIR="/var/lib/jellyfin-iptv/hls"
OUT_FILE="${OUT_DIR}/makov.m3u8"
mkdir -p "$OUT_DIR"
# This live stream only serves separate video-only/audio-only HLS renditions
# (no combined progressive format), so we resolve both and let ffmpeg mux
# them. "bestvideo+bestaudio" makes yt-dlp -g print exactly two URLs, video
# then audio; fall back to "best" in case a progressive format reappears.
mapfile -t STREAM_URLS < <(yt-dlp -g -f 'bestvideo+bestaudio/best' "$YOUTUBE_URL")
VIDEO_URL="${STREAM_URLS[0]:-}"
AUDIO_URL="${STREAM_URLS[1]:-}"
if [ -z "$VIDEO_URL" ]; then
echo "yt-dlp nevrátilo žádnou URL (stream offline, nebo se YouTube brání) — končím, systemd to za chvíli zkusí znovu." >&2
exit 1
fi
if [ -z "$AUDIO_URL" ]; then
# Progressive fallback matched: single URL carries both video and audio.
exec ffmpeg -nostdin -loglevel warning \
-re -i "$VIDEO_URL" \
-c copy \
-f hls -hls_time 4 -hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
"$OUT_FILE"
fi
exec ffmpeg -nostdin -loglevel warning \
-re -i "$VIDEO_URL" -re -i "$AUDIO_URL" \
-map 0:v -map 1:a \
-c copy \
-f hls -hls_time 4 -hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
"$OUT_FILE"
+175
View File
@@ -0,0 +1,175 @@
# Makov webkamera → Jellyfin (Live TV)
Restreamuje YouTube live přenos ("Krmítko", https://www.youtube.com/watch?v=F0fsV1GGZ3M)
do lokálního HLS, na který se Jellyfin dívá přes stabilní adresu. Když YouTube
odkaz vyprší nebo spadne, systemd proces sám restartuje a natáhne novou URL —
divák/Jellyfin si ničeho nevšimne.
## 0) Kam skript patří
Toto **musí běžet na stejném stroji (resp. za stejnou veřejnou IP), jako
Jellyfin** — YouTube vrací streamovací URL svázanou s IP adresou, ze které
byla vyžádána (`yt-dlp -g`). Pokud poběží jinde než Jellyfin, `ffmpeg` dostane
403 a nikdy se nerozjede.
## 1) Závislosti
```bash
sudo apt install -y ffmpeg python3
sudo pip install -U yt-dlp # nebo pipx install yt-dlp — apt verze bývá stará
# a YouTube ji rád rozbije
```
Ověř, že `yt-dlp` a `ffmpeg` jedou z shellu bez chyby:
```bash
yt-dlp --version
ffmpeg -version
```
## 2) Uživatel a oprávnění
Vlastní, neprivilegovaný systémový účet — obě služby pod ním běží:
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin makov-cam
sudo mkdir -p /var/lib/jellyfin-iptv/hls
sudo chown -R makov-cam:makov-cam /var/lib/jellyfin-iptv
```
Pokud je `yt-dlp` nainstalované přes **snap** (`which yt-dlp` ukazuje
`/snap/bin/yt-dlp`), potřebuje při startu zapisovatelný domovský adresář —
a to i přes `--no-create-home`, protože domov si zjišťuje přímo z
`/etc/passwd`, ne z proměnné `$HOME` (tu přenastavenou v service souboru
ignoruje). Bez toho skončí na `Permission denied: mkdir /home/makov-cam`.
Vytvoř mu ho ručně:
```bash
sudo mkdir -p /home/makov-cam
sudo chown makov-cam:makov-cam /home/makov-cam
sudo chmod 750 /home/makov-cam
```
(Pokud `yt-dlp` instaluješ přes `pip`/`pipx` dle kroku 1, tenhle problém
nenastává a krok lze přeskočit.)
## 3) Nasazení souborů
```bash
sudo install -m 755 makov-cam.sh /usr/local/bin/makov-cam.sh
sudo install -m 644 makov-cam.service /etc/systemd/system/
sudo install -m 644 makov-cam-http.service /etc/systemd/system/
sudo systemctl daemon-reload
```
## 4) Spuštění
```bash
sudo systemctl enable --now makov-cam.service
sudo systemctl enable --now makov-cam-http.service
```
Ověření:
```bash
systemctl status makov-cam.service makov-cam-http.service
journalctl -u makov-cam.service -f # živě sleduj log ffmpeg/yt-dlp
# do pár vteřin by se měl objevit playlist a segmenty:
ls -la /var/lib/jellyfin-iptv/hls/
# a mělo by jít stáhnout přes HTTP:
curl -sI http://127.0.0.1:8888/hls/makov.m3u8
```
`makov-cam-http.service` je svázaná (`BindsTo=`) na `makov-cam.service` — když
restream spadne/restartuje, HTTP server padne/naskočí s ním, ať nikdy
neservíruje mrtvý/prázdný playlist. Pozor: `BindsTo=` jen zajišťuje, že HTTP
server *spadne*, když spadne hlavní služba — nezajišťuje, že se s ní i sám
*nastartuje*. Po `enable --now` obou služeb to není problém (obě se spustí),
ale pokud jen restartuješ `makov-cam.service` ručně, zkontroluj/restartuj i
`makov-cam-http.service`.
HTTP server poslouchá jen na jedné IP (`--bind`), ne na `0.0.0.0` — nemá cenu
ho vystavovat celé síti, stačí odkud se na něj bude dívat Jellyfin:
- **Jellyfin běží přímo na hostu** (ne v kontejneru): `--bind 127.0.0.1`
stačí.
- **Jellyfin běží v Docker kontejneru na tomtéž hostu** (běžný případ) —
`127.0.0.1` uvnitř kontejneru je jeho *vlastní* loopback, ne hostitelův,
takže na hostitelův HTTP server se tak nedostane. Zjisti gateway IP té
docker sítě, na které kontejner běží, a naviaž se na ni:
```bash
docker inspect <jellyfin-container> --format '{{.HostConfig.NetworkMode}}'
docker inspect <jellyfin-container> --format '{{json .NetworkSettings.Networks}}'
# z výstupu vezmi "Gateway", např. 172.19.0.1 — tam se z kontejneru dostaneš
```
a použij `--bind <ta-gateway-IP>` (viz `makov-cam-http.service`). Pokud by
Jellyfin běžel v jiné VM, změň `--bind` na IP, na které je pro ni
dosažitelný, a případně otevři firewall jen pro tenhle port a jen z její
IP.
## 5) Playlist pro Jellyfin
`WorkingDirectory` HTTP služby je `/var/lib/jellyfin-iptv` (rodič `hls/`), aby
šel přes stejný server servírovat i samotný `.m3u` playlist — to se hodí,
pokud Jellyfin (typicky v kontejneru) nevidí souborový systém hostitele a
tuner tak musí načíst playlist přes HTTP URL, ne lokální cestou.
Vytvoř `/var/lib/jellyfin-iptv/makov.m3u`:
```
#EXTM3U
#EXTINF:-1 tvg-id="makov-krmitko" tvg-name="Makov - Krmítko",Makov - Krmítko
http://<BIND_IP>:8888/hls/makov.m3u8
```
kde `<BIND_IP>` je stejná adresa, na kterou je navázaný `makov-cam-http.service`
(viz krok 4).
## 6) Nastavení v Jellyfinu
1. **Dashboard → Live TV → Tuner Devices → +** (Add)
2. Typ: **M3U Tuner**
3. File or URL:
- Jellyfin na hostu s přístupem k souborovému systému: lokální cesta
`/var/lib/jellyfin-iptv/makov.m3u`.
- Jellyfin v kontejneru (nevidí hostitelův FS): HTTP URL
`http://<BIND_IP>:8888/makov.m3u` (viz krok 5).
4. **Save** → Jellyfin načte kanál "Makov - Krmítko" do Live TV
5. Doporučeno: **Dashboard → Live TV → Guide Providers** — pro tenhle kanál
žádného není třeba (žádný EPG), Jellyfin ho ukáže jako kanál bez programu,
což nevadí.
6. Zkontroluj přehrávání v **Live TV** sekci hlavního rozhraní.
Protože kanál pořád ukazuje na stejnou lokální adresu, odpadá starost o
obnovování guide dat nebo expiraci URL na Jellyfin straně — o to se stará jen
`makov-cam.service` na pozadí.
## 7) Autostart po restartu serveru
`enable` v kroku 4 už zajistil start při bootu. Rychlá kontrola:
```bash
systemctl is-enabled makov-cam.service makov-cam-http.service
```
## Řešení problémů
- **`ffmpeg` pořád padá / v logu 403 od googlevideo.com** — nejčastěji IP
mismatch (viz bod 0): stroj, co spouští `yt-dlp -g`/`ffmpeg`, musí mít
stejnou veřejnou IP jako to, odkud by se stream normálně přehrával.
Zkontroluj `journalctl -u makov-cam.service -n 50`.
- **`yt-dlp: command not found` v service logu** — systemd nepoužívá tvůj
interaktivní `$PATH`; ověř `which yt-dlp` a případně uprav `ExecStart` v
`makov-cam.sh`/skriptu na plnou cestu (`/usr/local/bin/yt-dlp` apod.), nebo
nainstaluj přes `sudo pip install`, aby šel do `/usr/local/bin`.
- **Playlist v Jellyfinu jede, ale sekundy/minuty výpadku při refreshi** —
normální, `hls_list_size 6` × `hls_time 4s` drží ~24s okno; při restartu
streamu je pár vteřin díra, než naběhnou nové segmenty. Jde zkrátit/prodloužit
úpravou těch dvou hodnot ve skriptu.
- **YouTube někdy dočasně rozbije `yt-dlp` novou verzí přehrávače** — drž
`yt-dlp` aktuální (`sudo pip install -U yt-dlp`, klidně jako týdenní cron).
- **`ERROR: Requested format is not available`** — stream v tu chvíli nenabízí
žádný kombinovaný (progresivní) formát, jen oddělené video-only/audio-only
HLS řady (ověř přes `yt-dlp -F <url>`). Skript proto primárně používá
`-f 'bestvideo+bestaudio/best'` a při dvou vrácených URL pustí `ffmpeg` se
dvěma `-i` vstupy (`-map 0:v -map 1:a`); jedna URL = fallback na progresivní
formát, pokud se zase objeví.
+67
View File
@@ -0,0 +1,67 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
print("=" * 50)
print("PREBUILD CONFIGURATION")
print("=" * 50)
# Check if running in virtual environment
project_root = Path(__file__).parent
expected_venv_path = project_root / ".venv"
current_executable = Path(sys.executable)
print(f"\nPython executable: {sys.executable}")
is_correct_venv = False
try:
current_executable.relative_to(expected_venv_path)
is_correct_venv = True
except ValueError:
is_correct_venv = False
if is_correct_venv:
print("✓ Correct environment selected for building")
else:
print("✗ Wrong environment selected")
print(f" Expected: {expected_venv_path}")
print(f" Current: {current_executable.parent.parent}")
print(f"✓ Version: {VERSION}")
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
console_mode = env_debug
default_spec = Path(__file__).parent.name + ".spec"
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
print(f"\n{'-' * 50}")
print("BUILD SETTINGS")
print(f"{'-' * 50}")
print(f"ENV_DEBUG: {env_debug}")
print(f"Console mode: {console_mode}")
print(f"Spec file: {spec_filename}")
spec_path = Path(__file__).parent / spec_filename
if spec_path.exists():
with open(spec_path, "r", encoding="utf-8") as f:
spec_content = f.read()
if f"console={not console_mode}" in spec_content:
new_spec_content = spec_content.replace(
f"console={not console_mode}", f"console={console_mode}"
)
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_spec_content)
print(f"✓ Updated {spec_filename}: console={console_mode}")
else:
print(f"✓ {spec_filename} already configured: console={console_mode}")
else:
print(f"✗ {spec_filename} not found!")
print(f"{'-' * 50}\n")
+24
View File
@@ -0,0 +1,24 @@
[project]
name = "curator"
version = "1.13.0"
requires-python = ">=3.14"
dependencies = [
"beautifulsoup4>=4.15.0",
"loguru>=0.7.3",
"pillow>=12.3.0",
"pyside6>=6.12.0",
"python-dotenv>=1.2.4",
"requests>=2.34.2",
"truststore>=0.10.4",
]
[dependency-groups]
dev = [
"mypy>=2.4.0",
"pyinstaller>=6.22.3",
"pytest>=9.1.1",
"ruff>=0.17.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
+107
View File
@@ -0,0 +1,107 @@
"""One-off migration: rename a tag category inside a pool's metadata index.
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
This rewrites every tag whose category matches ``--old`` to use ``--new``,
leaving the tag name untouched. A timestamped backup of the index is written
before saving.
Usage:
uv run python scripts/migrate_tag_category.py <pool_dir> \
--old "Země" --new "Země původu"
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of tags changed) for one record."""
prefix = f"{old}/"
changed = 0
result: list[str] = []
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix):
result.append(f"{new}/{tag[len(prefix) :]}")
changed += 1
else:
result.append(tag)
return result, changed
def migrate(index_path: Path, old: str, new: str) -> int:
"""Rewrite the category in place and return the number of tags changed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_changed = 0
affected_records = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, changed = _rename_in_tags(tags, old, new)
if changed:
record["tags"] = new_tags
total_changed += changed
affected_records += 1
logger.debug(f"{key}: {changed} tag(s) renamed")
if total_changed == 0:
logger.info(f"No '{old}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Migrated '{old}' → '{new}': {total_changed} tag(s) "
f"across {affected_records} record(s)"
)
return total_changed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--old", default="Země", help="Category to rename from")
parser.add_argument("--new", default="Země původu", help="Category to rename to")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.old, args.new)
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
"""One-off migration: split combined country tags in a pool's metadata index.
Before multi-country support, a co-production fetched from ČSFD was stored as a
single ``"Země původu/USA / Velká Británie"`` tag. This rewrites each such tag
into one tag per country (``"Země původu/USA"`` + ``"Země původu/Velká
Británie"``), de-duplicating within each record. A timestamped backup of the
index is written before saving.
Usage:
uv run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of combined tags split) for one record.
Order is preserved; duplicates produced by the split are dropped.
"""
prefix = f"{category}/"
result: list[str] = []
seen: set[str] = set()
split_count = 0
def _add(tag: str) -> None:
if tag not in seen:
seen.add(tag)
result.append(tag)
for tag in tags:
if (
isinstance(tag, str)
and tag.startswith(prefix)
and "/" in tag[len(prefix) :]
):
value = tag[len(prefix) :]
countries = [c.strip() for c in value.split("/") if c.strip()]
for country in countries:
_add(f"{prefix}{country}")
split_count += 1
else:
_add(tag)
return result, split_count
def migrate(index_path: Path, category: str) -> int:
"""Split combined ``category`` tags in place; return number of tags split."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_split = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, split_count = _split_record_tags(tags, category)
if split_count:
record["tags"] = new_tags
total_split += split_count
affected += 1
logger.debug(f"{key}: {split_count} combined tag(s) split")
if total_split == 0:
logger.info(f"No combined '{category}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Split {total_split} combined '{category}' tag(s) across {affected} record(s)"
)
return total_split
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--category", default="Země původu", help="Tag category to split"
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""One-off migration: drop the old decade-band rating tags from a pool index.
Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching from
ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped
backup of the index is written first.
Usage:
uv run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%")
def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (kept tags, removed count), dropping ``category`` band tags."""
prefix = f"{category}/"
kept = [
t
for t in tags
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, category: str) -> int:
"""Remove band rating tags in place; return number of tags removed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip_bands(tags, category)
if removed:
record["tags"] = kept
# also drop them from the ČSFD provenance set, if present
if isinstance(record.get("csfd_tags"), list):
record["csfd_tags"] = [
t
for t in record["csfd_tags"]
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
]
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} band tag(s)")
if total_removed == 0:
logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--category", default="Hodnocení", help="Rating category")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
"""One-off migration: drop all tags of given categories from a pool's index.
Used to remove tag categories that turned out to be a bad idea (e.g. Režie /
Herec produced far too many folders). Cached ČSFD data is left intact — only the
``tags`` lists are pruned. A timestamped backup of the index is written first.
Usage:
uv run python scripts/strip_tag_categories.py [<pool_dir>] \
--categories "Režie" "Herec"
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
"""Return (kept tags, number removed) dropping tags under any prefix."""
kept = [t for t in tags if not (isinstance(t, str) and t.startswith(prefixes))]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, categories: list[str]) -> int:
"""Remove all tags of ``categories`` in place; return number of tags removed."""
prefixes = tuple(f"{c}/" for c in categories)
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip(tags, prefixes)
if removed:
record["tags"] = kept
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} tag(s)")
if total_removed == 0:
logger.info(f"No tags in {categories} found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} tag(s) of {categories} across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--categories",
nargs="+",
default=["Režie", "Herec"],
help="Tag categories to strip",
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.categories)
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Generate the "Tipy dne" Filmoteka folder
After=local-fs.target remote-fs.target
RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
[Service]
Type=oneshot
User=honza
Group=honza
ExecStart=/usr/bin/python3 /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy_dne.py --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
Nice=10
NoNewPrivileges=true
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Daily regeneration of the "Tipy dne" Filmoteka folder
[Timer]
# Kazdy den o pulnoci (mistni cas serveru = CEST/CET)
OnCalendar=*-*-* 00:00:00
Persistent=true
[Install]
WantedBy=timers.target
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Standalone "Tipy dne" generator — deployable next to a Filmotéka (e.g. cron).
On each run it reads the pool index (``<pool>/.Curator.!index``), picks N random
pool movies, clears the tips folder in the output and recreates it as hardlinks.
It does not import Curator — only the Python standard library — so it can live on
the server with the Filmotéka, independent of the app.
Paths come from CLI args, or fall back to the global config (``.Curator.!gtag``,
keys ``pool_dir`` / ``filmoteka_dir``) when ``--config`` is given or one is found.
Examples:
python scripts/tipy_dne.py --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy/Filmoteka_v2
python scripts/tipy_dne.py --count 15 # uses the repo's global config
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers
DEFAULT_COUNT = 15
def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]:
"""Read (pool_dir, filmoteka_dir) from a global config file, if present."""
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except OSError, json.JSONDecodeError:
return None, None
pool = cfg.get("pool_dir")
out = cfg.get("filmoteka_dir")
return (Path(pool) if pool else None, Path(out) if out else None)
def _pool_movies(pool_dir: Path) -> list[Path]:
"""Existing pool movie files listed in the index (copy-as-is folders aren't
indexed, so this is the pool/Filmy set only)."""
index_path = pool_dir / INDEX_FILENAME
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: list[Path] = []
for rel in data.get("movies", {}):
path = pool_dir / rel
if path.is_file():
movies.append(path)
return movies
def _clear_folder(folder: Path) -> int:
"""Remove the files in ``folder`` (the old tips). Returns how many removed."""
removed = 0
if folder.exists():
for child in folder.iterdir():
if child.is_file() or child.is_symlink():
try:
child.unlink()
removed += 1
except OSError as exc:
print(f" ! nelze smazat {child}: {exc}", file=sys.stderr)
return removed
def generate_tips(pool_dir: Path, output_dir: Path, count: int, subfolder: str) -> int:
"""Refresh the tips folder with ``count`` random pool movies as hardlinks."""
movies = _pool_movies(pool_dir)
if not movies:
print("Index neobsahuje žádné filmy.", file=sys.stderr)
return 0
tips_dir = output_dir / subfolder
cleared = _clear_folder(tips_dir)
tips_dir.mkdir(parents=True, exist_ok=True)
chosen = random.sample(movies, min(count, len(movies)))
created = 0
for src in chosen:
target = tips_dir / src.name
try:
if target.exists():
target.unlink()
os.link(src, target)
created += 1
print(f" + {src.name}")
except OSError as exc:
print(f" ! {src.name}: {exc}", file=sys.stderr)
print(f"\nTipy dne: smazáno {cleared}, vytvořeno {created} (z {len(movies)} filmů)")
return created
def main() -> None:
parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.")
parser.add_argument(
"--pool", type=Path, help="Pool root (obsahuje .Curator.!index)"
)
parser.add_argument("--output", type=Path, help="Výstupní složka Filmotéky")
parser.add_argument(
"--count",
type=int,
default=DEFAULT_COUNT,
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})",
)
parser.add_argument(
"--subfolder",
default=DEFAULT_SUBFOLDER,
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")',
)
parser.add_argument(
"--config",
type=Path,
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest",
)
args = parser.parse_args()
pool, output = args.pool, args.output
if pool is None or output is None:
config_path = args.config or (
Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME
)
cfg_pool, cfg_out = _load_config_paths(config_path)
pool = pool or cfg_pool
output = output or cfg_out
if pool is None or output is None:
parser.error("Chybí --pool a/nebo --output (a nenalezeny v global configu).")
if not (pool / INDEX_FILENAME).is_file():
parser.error(f"Index nenalezen: {pool / INDEX_FILENAME}")
created = generate_tips(pool, output, args.count, args.subfolder)
sys.exit(0 if created else 1)
if __name__ == "__main__":
main()
View File
+2
View File
@@ -0,0 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.13.0"
+69
View File
@@ -0,0 +1,69 @@
"""
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`).
"""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
_PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml"
_VERSION_FILE = Path(__file__).parent / "_version.py"
APP_NAME: str = "Curator"
def get_version() -> str:
"""Return the project version (pyproject → _version.py → unknown)."""
# 1. pyproject.toml (preferred)
try:
with open(_PYPROJECT_PATH, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Cache a fallback for frozen/PyInstaller builds (best-effort).
try:
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
except OSError:
pass
return version
except FileNotFoundError, KeyError:
pass
# 2. generated _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
except ImportError:
pass
# 3. last resort
return "0.0.0-unknown"
def get_debug_mode() -> bool:
"""Return True when ENV_DEBUG is set to a truthy value in the environment."""
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
APP_VERSION: str = get_version()
ENV_DEBUG: bool = get_debug_mode()
APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "")
# Backwards-compatible alias used by prebuild.py
VERSION: str = APP_VERSION
# Default main-window geometry (used when no saved geometry exists)
APP_VIEWPORT: str = "1000x700"
View File
+121
View File
@@ -0,0 +1,121 @@
"""
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.
"""
import json
from pathlib import Path
# Global config file (next to the main script)
GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# =============================================================================
# TAG SCHEMA - default library settings (stored per-pool in the index)
# =============================================================================
# Tag schema: the single source of truth for which tag categories exist, how
# they are derived from ČSFD, and how they map to the Filmotéka folder tree.
# Each entry:
# category — tag category name (e.g. "Žánr")
# csfd_field — NFO source field feeding it (genres / year / countries /
# rating), or None for a user-only category. (Key name kept for
# index compatibility; the data now comes from the NFO.)
# transform — named value transform: None (str) or "decade_band" (rating)
# filmoteka_root — folder under the output: "" = at the root (e.g. genres),
# "Dle X" = grouping folder, None = filterable but no folders
# filename_template — optional per-category hardlink name used only inside this
# category's folders (e.g. "{year} - {title}{ext}"); fields:
# title / year / rating / ext / stem / filename. Absent/None =
# keep the pool filename. Pool files are never renamed by this.
# Grouping folders are prefixed with "- " so DLNA/TV browsers sort the special
# folders (Dle …, Tipy dne, Nově přidané) before the genre folders at the root.
# This lives in the code as the default; the effective schema is stored per-pool
# in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA = [
{
"category": "Žánr",
"csfd_field": "genres",
"transform": None,
"filmoteka_root": "",
},
{
"category": "Rok",
"csfd_field": "year",
"transform": None,
"filmoteka_root": "- Dle roku",
},
{
"category": "Země původu",
"csfd_field": "countries",
"transform": None,
"filmoteka_root": "- Dle země původu",
},
{
"category": "Hodnocení",
"csfd_field": "rating",
"transform": "decade_band",
"filmoteka_root": "- Dle hodnocení",
},
]
# =============================================================================
# GLOBAL CONFIG - Application settings only
# =============================================================================
DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800",
"window_maximized": False,
"last_folder": None,
"sidebar_width": 250,
"recent_folders": [],
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
}
def load_global_config() -> dict:
"""Load global application config"""
if GLOBAL_CONFIG_FILE.exists():
try:
with open(GLOBAL_CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Merge with defaults for any missing keys
for key, value in DEFAULT_GLOBAL_CONFIG.items():
if key not in config:
config[key] = value
return config
except OSError, ValueError, TypeError:
return DEFAULT_GLOBAL_CONFIG.copy()
return DEFAULT_GLOBAL_CONFIG.copy()
def save_global_config(cfg: dict):
"""Save global application config"""
with open(GLOBAL_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
# =============================================================================
# BACKWARDS COMPATIBILITY
# =============================================================================
def load_config():
"""Legacy function - returns global config"""
return load_global_config()
def save_config(cfg: dict):
"""Legacy function - saves global config"""
save_global_config(cfg)
+471
View File
@@ -0,0 +1,471 @@
"""Č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
:mod:`src.core.file_manager` (:meth:`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 :class:`requests.Session` (so a batch pays the PoW
cost only once).
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlencode, urljoin
from loguru import logger
try:
import requests
from bs4 import BeautifulSoup
_HAS_DEPS = True
except ImportError: # pragma: no cover - exercised only without the extras
_HAS_DEPS = False
requests = None # type: ignore
BeautifulSoup = None # type: ignore
CSFD_BASE_URL = "https://www.csfd.cz"
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept-Language": "cs,en;q=0.9",
}
# Anubis anti-bot wall: a plain GET returns a 200 challenge page, not the movie.
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
ANUBIS_MAX_NONCE = 50_000_000
class CsfdError(RuntimeError):
"""A ČSFD lookup could not be completed (deps missing, network, anti-bot)."""
@dataclass
class CsfdFilm:
"""The slice of a ČSFD film page Curator needs to reach a TMDb id."""
url: str
csfd_id: int | None = None
czech_title: str = ""
original_title: str = ""
year: str | None = None # four-digit string, or None
@property
def tmdb_query(self) -> tuple[str, str | None]:
"""``(title, year)`` best suited for a TMDb search — original title first."""
return (self.original_title or self.czech_title, self.year)
def dependencies_available() -> bool:
"""True when ``requests`` + ``beautifulsoup4`` are importable."""
return _HAS_DEPS
def _check_dependencies() -> None:
if not _HAS_DEPS:
raise CsfdError(
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' (uv sync je doplní)."
)
def _extract_csfd_id(url: str) -> int | None:
"""Pull the numeric film id out of a ČSFD URL, or ``None``."""
match = re.search(r"/film/(\d+)", url)
return int(match.group(1)) if match else None
# ---------------------------------------------------------------------------
# Anubis proof-of-work (ported verbatim from the pre-Jellyfin csfd.py)
# ---------------------------------------------------------------------------
def _extract_json_blob(html: str, element_id: str):
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
match = re.search(
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
html,
re.DOTALL,
)
if not match:
return None
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
return None
def _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)``.
"""
full_zero_bytes = difficulty // 2
needs_half_byte = difficulty % 2 != 0
start = time.monotonic()
for nonce in range(ANUBIS_MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
if any(digest[i] != 0 for i in range(full_zero_bytes)):
continue
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
continue
elapsed_ms = int((time.monotonic() - start) * 1000)
return digest.hex(), nonce, elapsed_ms
raise CsfdError(
f"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
)
def _solve_anubis_challenge(session, html: str, url: str):
"""Solve the Anubis challenge in ``html`` and return the real page response."""
payload = _extract_json_blob(html, "anubis_challenge")
if not payload:
raise CsfdError("ČSFD anti-bot stránka bez čitelné Anubis challenge.")
rules = payload.get("rules", {})
challenge = payload.get("challenge", {})
random_data = challenge.get("randomData")
difficulty = int(rules.get("difficulty", 1))
if not random_data:
raise CsfdError("Anubis challenge neobsahuje randomData.")
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
response = session.get(
pass_url,
params={
"id": challenge.get("id"),
"response": hash_hex,
"nonce": nonce,
"redir": url,
"elapsedTime": elapsed_ms,
},
headers=HEADERS,
timeout=10,
)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
raise CsfdError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).")
return response
def _get_page(session, url: str):
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
response = session.get(url, headers=HEADERS, timeout=10)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
response = _solve_anubis_challenge(session, response.text, url)
return response
# ---------------------------------------------------------------------------
# Page parsing
# ---------------------------------------------------------------------------
def _parse_json_ld(soup) -> tuple[str, str | None]:
"""``(name, year)`` from the film page's JSON-LD (name is the Czech title)."""
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string or "")
except json.JSONDecodeError, TypeError:
continue
if isinstance(data, list):
data = next((d for d in data if d.get("@type") == "Movie"), None)
if not isinstance(data, dict) or data.get("@type") != "Movie":
continue
name = (data.get("name") or "").strip()
year: str | None = None
for key in ("dateCreated", "datePublished"):
match = re.search(r"(19|20)\d{2}", str(data.get(key) or ""))
if match:
year = match.group(0)
break
return name, year
return "", None
def _parse_origin(soup) -> tuple[str | None, str | None]:
"""``(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.
"""
elem = soup.select_one(".origin")
if not elem:
return None, None
raw = elem.get_text(separator="|", strip=True)
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
country: str | None = None
year: str | None = None
for token in tokens:
if not token:
continue
if year is None and re.fullmatch(r"(19|20)\d{2}", token):
year = token
continue
if country is None and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
country = token.split("/")[0].strip()
return country, year
# ČSFD flags older Czech films "Československo" while ``.origin`` may read "Česko".
_CZ_ORIGIN_ALIASES = {"česko", "československo"}
def _parse_original_title(soup, origin_country: str | None) -> str | None:
"""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.
"""
names: list[tuple[str, str]] = []
for li in soup.select(".film-names li"):
flag = li.select_one("img.flag")
country = str(flag.get("title") or "").strip() if flag else ""
for junk in li.select("span, img"):
junk.extract()
text = " ".join(li.get_text(" ").split()).strip(" ·")
if text:
names.append((country, text))
if not names or not origin_country:
return None
wanted = origin_country.casefold()
for country, text in names:
here = country.casefold()
if here == wanted or (
wanted in _CZ_ORIGIN_ALIASES and here in _CZ_ORIGIN_ALIASES
):
return text
return None
def fetch_film(url: str, session=None) -> CsfdFilm:
"""Fetch a ČSFD film page → :class:`CsfdFilm` (Czech + original title, year).
Pass a shared ``requests.Session`` to reuse the Anubis auth cookie across a
batch. Raises :class:`CsfdError` on a network / anti-bot / parse failure.
"""
_check_dependencies()
own_session = session is None
if own_session:
session = requests.Session()
try:
html = _get_page(session, url).text
except requests.RequestException as exc:
raise CsfdError(f"Nelze načíst ČSFD stránku: {exc}") from exc
finally:
if own_session:
session.close()
soup = BeautifulSoup(html, "html.parser")
czech_title, year = _parse_json_ld(soup)
origin_country, origin_year = _parse_origin(soup)
year = year or origin_year
if not czech_title:
header = soup.select_one(".film-header-name h1")
czech_title = header.get_text(strip=True) if header else ""
original_title = _parse_original_title(soup, origin_country) or czech_title
return CsfdFilm(
url=url,
csfd_id=_extract_csfd_id(url),
czech_title=czech_title,
original_title=original_title,
year=str(year) if year else None,
)
def _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.
"""
for section in soup.find_all("section"):
header = section.select_one(".box-header h2") or section.find("h2")
if header and header.get_text(strip=True).casefold() == "filmy":
return section
return soup
def _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."""
parts = [p for p in href.split("/") if p and p != "prehled"]
return len(parts) == 2 and parts[0] == "film"
def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]:
"""Search ČSFD for ``query``; film hits only (no series/episodes), best first."""
_check_dependencies()
if not query.strip():
return []
search_url = f"{CSFD_SEARCH_URL}?{urlencode({'q': query.strip()})}"
own_session = session is None
if own_session:
session = requests.Session()
try:
html = _get_page(session, search_url).text
except requests.RequestException as exc:
raise CsfdError(f"ČSFD hledání selhalo: {exc}") from exc
finally:
if own_session:
session.close()
soup = BeautifulSoup(html, "html.parser")
films: list[CsfdFilm] = []
for anchor in _films_section(soup).select("a.film-title-name"):
href = str(anchor.get("href") or "")
if not _is_film_href(href):
continue
films.append(
CsfdFilm(
url=urljoin(CSFD_BASE_URL, href),
csfd_id=_extract_csfd_id(href),
czech_title=anchor.get_text(strip=True),
)
)
if len(films) >= limit:
break
return films
def find_csfd_url(query: str, session=None) -> str | None:
"""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.
"""
if not query or not query.strip():
return None
query = query.strip()
films = search_films(query, limit=1, session=session)
if not films:
without_year = re.sub(r"\s+(?:19|20)\d{2}$", "", query)
if without_year != query:
films = search_films(without_year, limit=1, session=session)
return films[0].url if films else None
# Release-name tokens that mark the end of the actual title in a filename.
_RELEASE_MARKERS = {
"bluray",
"blu-ray",
"brrip",
"bdrip",
"bdremux",
"remux",
"webrip",
"web",
"web-dl",
"webdl",
"hdtv",
"dvdrip",
"dvd",
"dvd5",
"dvd9",
"hdrip",
"cam",
"ts",
"tc",
"x264",
"x265",
"h264",
"h265",
"hevc",
"avc",
"xvid",
"divx",
"aac",
"ac3",
"eac3",
"dts",
"dd5",
"ddp5",
"truehd",
"atmos",
"flac",
"10bit",
"8bit",
"hdr",
"hdr10",
"dolby",
"sdr",
"proper",
"repack",
"extended",
"unrated",
"remastered",
"imax",
"multi",
"dual",
"complete",
"internal",
"limited",
"uncut",
}
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
def 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"
"""
stem = Path(filename).stem
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
title_words: list[str] = []
year: str | None = None
for token in tokens:
bare = token.strip("()[]{}")
if _YEAR_RE.match(bare):
year = bare
break
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
break
title_words.append(token)
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
return f"{title} {year}".strip() if year else title
+422
View File
@@ -0,0 +1,422 @@
import re
from pathlib import Path
from .tag import Tag
class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
self.file_path = file_path
self.filename = file_path.name
# Unified pool index; when set, metadata is persisted there (see
# PoolIndex). Without an index a File is an in-memory object only — no
# metadata is written to disk (there are no per-file sidecars anymore).
self.index = index
self.new = True
self.ignored = False
self.tags: list[Tag] = []
self.tagmanager = tagmanager
# new: optional date string "YYYY-MM-DD" (assigned manually)
self.date: str | None = None
# movie-library fields
self.title: str | None = None
# Optional ČSFD film URL — a second database pointer kept alongside the
# TMDb id (which lives in the filename). Stored only, not fetched.
self.csfd_url: str | None = None
# ISO timestamp of when the movie was added to the pool (set on import);
# None for older/unknown → falls back to the file's ctime.
self.added: str | None = None
# Cached metadata used for folder grouping (year, rating) — filled from
# the movie's NFO (which Jellyfin writes).
self.meta: dict | None = None
# full_paths of tags derived automatically from the NFO (vs. user-added);
# only these are regenerated on refresh — user tags are preserved.
self.auto_tag_paths: set[str] = set()
# free-form per-movie attributes (e.g. "collection_sort"); usable in
# miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {}
# Identity key of the movie record this file belongs to (set on load /
# first save); ``None`` only for an index-less, in-memory File.
self._movie_key: str | None = None
self.get_metadata()
def _identity_from_path(self) -> 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.
"""
from .movie import identity_key
from .naming import (
is_canonical_pool_stem,
parse_3d_format,
parse_pool_stem,
parse_tmdb_id,
)
parent = self.file_path.parent.name
grouped = is_canonical_pool_stem(parent)
identity_stem = parent if grouped else self.file_path.stem
parsed = parse_pool_stem(identity_stem)
year = str(parsed[1]) if parsed else None
tmdb_id = parse_tmdb_id(identity_stem)
title = self.title or (parsed[0] if parsed else self.file_path.stem)
label = ""
part = (
re.fullmatch(
rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})",
self.file_path.stem,
re.IGNORECASE,
)
if grouped
else None
)
if part:
# one part of a film split across files: "<folder>-part2"
label = f"part{int(part.group(1))}"
elif grouped and self.file_path.stem.startswith(f"{parent} - "):
label = self.file_path.stem[len(parent) + 3 :]
elif parse_3d_format(self.file_path.stem):
label = parse_3d_format(self.file_path.stem) or ""
return identity_key(tmdb_id, title, year), title, year, tmdb_id, label
def get_metadata(self) -> None:
"""Load this file's metadata from the movie record that owns it.
Since the pool index became identity-keyed (see :mod:`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.
"""
if self.index is None:
self._init_new_metadata()
return
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
self._movie_key, record = found
self._apply_record(record)
return
self._init_new_metadata()
key, *_ = self._identity_from_path()
existing = self.index.get_movie(key)
if existing is not None:
self._movie_key = key
self._apply_record(existing)
self.save_metadata()
def _init_new_metadata(self) -> None:
self.new = True
self.ignored = False
self.tags = []
self.date = None
self.title = None
self.csfd_url = None
self.meta = None
self.auto_tag_paths = set()
self.attributes = {}
self.added = None
def _build_record(self) -> dict:
data = {
"new": self.new,
"ignored": self.ignored,
# ukládáme full_path tagů
"tags": [
tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags
],
# which of the tags were derived automatically (from the NFO)
"auto_tags": sorted(self.auto_tag_paths),
# date může být None
"date": self.date,
"title": self.title,
"csfd_url": self.csfd_url,
"attributes": self.attributes,
"added": self.added,
}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
def _apply_record(self, data: dict) -> None:
self.new = data.get("new", True)
self.ignored = data.get("ignored", False)
self.tags = []
self.date = data.get("date", None)
self.title = data.get("title", None)
self.csfd_url = data.get("csfd_url", None)
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
self.attributes = dict(data.get("attributes", {}))
self.added = data.get("added", None)
raw_meta = data.get("meta")
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
old = data.get("csfd_cache") or {}
raw_meta = (
{"year": old.get("year"), "rating": old.get("rating")} if old else None
)
self.meta = dict(raw_meta) if raw_meta else None
if not self.tagmanager:
return
for tag_str in data.get("tags", []):
if "/" in tag_str:
category, name = tag_str.split("/", 1)
tag = self.tagmanager.add_tag(category, name)
self.tags.append(tag)
def save_metadata(self):
"""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.
"""
if self.index is None:
return
from .movie import ROLE_VIDEO
derived_key, title, year, tmdb_id, label = self._identity_from_path()
key = self._movie_key or derived_key
record = self.index.get_movie(key) or {}
record.update(self._build_record())
# The file's actual location is the truth — a move must update this.
record["library"] = self._library_name()
record["title"] = title
record["year"] = year
record["tmdb_id"] = tmdb_id
# Keep this file listed exactly once, with the label its name implies.
rel = self.index._key(self.file_path)
media = [m for m in record.get("media", []) if m.get("file") != rel]
media.append({"role": ROLE_VIDEO, "label": label, "file": rel})
record["media"] = media
self.index.set_movie(key, record)
self._movie_key = key
def _library_name(self) -> str:
"""Top-level pool folder this file sits in (its library), or ``""``."""
if self.index is None:
return ""
rel = self.index._key(self.file_path)
parts = Path(rel).parts
return parts[0] if len(parts) > 1 else ""
def added_timestamp(self) -> 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.
"""
if self.added:
try:
from datetime import datetime
return datetime.fromisoformat(self.added).timestamp()
except ValueError:
pass
try:
return self.file_path.stat().st_mtime
except OSError:
return 0.0
def name_context(self) -> 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.
"""
cache = self.meta or {}
year = cache.get("year")
if year is None:
for t in self.tags:
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
year = t.name
break
if year is None:
# Last resort: recover it from a canonical "Title (YYYY)" filename.
from .naming import parse_pool_stem
parsed = parse_pool_stem(self.file_path.stem)
if parsed is not None:
year = parsed[1]
rating = cache.get("rating")
# user attributes first; core fields take precedence over same-named keys
context = dict(self.attributes)
context.update(
{
"title": self.title or self.file_path.stem,
"year": "" if year is None else str(year),
"rating": "" if rating is None else rating,
"ext": self.file_path.suffix,
"stem": self.file_path.stem,
"filename": self.filename,
}
)
return context
def set_attribute(self, key: str, value: str | None) -> None:
"""Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it."""
key = key.strip()
if not key:
return
if value:
self.attributes[key] = value
else:
self.attributes.pop(key, None)
self.save_metadata()
def save_location(self) -> None:
"""Persist a rename/move of this file without touching shared metadata.
Every version of a film holds its own in-memory copy of the movie
record's shared fields (title, tags, ČSFD, …). A sibling loaded before
someone changed those fields would write its stale copy back on a plain
:meth:`save_metadata`. Re-reading the record first means only this
file's own media entry — path and popisek — actually changes.
"""
if self.index is not None and self.index.find_movie_by_media(self.file_path):
self.get_metadata()
self.save_metadata()
def delete_metadata(self) -> 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.
"""
if self.index is None:
return
found = self.index.find_movie_by_media(self.file_path)
if found is None:
return
key, record = found
rel = self.index._key(self.file_path)
record["media"] = [m for m in record.get("media", []) if m.get("file") != rel]
if record["media"]:
self.index.set_movie(key, record)
else:
self.index.delete_movie(key)
def relocate(self, 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.
"""
if self.index is not None:
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
key, record = found
old_rel = self.index._key(self.file_path)
new_rel = self.index._key(Path(new_path))
for media in record.get("media", []):
if media.get("file") == old_rel:
media["file"] = new_rel
self.index.set_movie(key, record)
self._movie_key = key
self.file_path = Path(new_path)
self.filename = self.file_path.name
def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
if date_str is None or date_str == "":
self.date = None
else:
# neprvádíme složitou validaci zde; očekáváme 'YYYY-MM-DD'
self.date = date_str
self.save_metadata()
def apply_nfo(self, 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'.
"""
if nfo is None:
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
if schema is None:
from .config import DEFAULT_TAG_SCHEMA
schema = DEFAULT_TAG_SCHEMA
self.meta = {"year": nfo.year, "rating": nfo.rating}
# Drop previous auto (NFO) tags; keep user-added ones.
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
self.auto_tag_paths = set()
tags_added: list[str] = []
for entry in schema:
field = entry.get("csfd_field") # schema key = source field name
if not field:
continue # user-only category
category = entry["category"]
for value in nfo.field_values(field):
tag_obj = (
self.tagmanager.add_tag(category, value)
if self.tagmanager
else Tag(category, value)
)
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(tag_obj.full_path)
self.auto_tag_paths.add(tag_obj.full_path)
if nfo.title and not self.title:
self.title = nfo.title
self.save_metadata()
return {"success": True, "tags_added": tags_added}
def add_tag(self, tag):
# tag může být Tag nebo string
from .tag import Tag as TagClass
if isinstance(tag, str):
if "/" in tag and self.tagmanager:
category, name = tag.split("/", 1)
tag_obj = self.tagmanager.add_tag(category, name)
else:
tag_obj = TagClass("default", tag)
elif isinstance(tag, TagClass):
tag_obj = tag
else:
return
if tag_obj not in self.tags:
self.tags.append(tag_obj)
self.save_metadata()
def remove_tag(self, tag):
# tag může být Tag nebo string (full_path)
if isinstance(tag, str):
if "/" in tag:
category, name = tag.split("/", 1)
tag_obj = Tag(category, name)
else:
tag_obj = Tag("default", tag)
elif isinstance(tag, Tag):
tag_obj = tag
else:
return
if tag_obj in self.tags:
self.tags.remove(tag_obj)
self.save_metadata()
File diff suppressed because it is too large Load Diff
+758
View File
@@ -0,0 +1,758 @@
"""
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)
"""
import os
import random
import shutil
from collections import defaultdict
from pathlib import Path
from .file import File
from .naming import is_valid_year, parse_pool_stem
class _SafeDict(dict):
"""dict for str.format_map that leaves unknown fields as an empty string."""
def __missing__(self, key):
return ""
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}``.
"""
def __init__(self, output_dir: Path):
"""
Initialize HardlinkManager.
Args:
output_dir: Base directory where the tag-based structure will be created
"""
self.output_dir = Path(output_dir)
self.created_links: list[Path] = []
self.errors: list[tuple[Path, str]] = []
def _resolve_roots(
self,
categories: list[str] | None,
category_roots: dict[str, str] | None,
) -> dict[str, str] | None:
"""Normalize the two filter styles into a category → root-folder map.
``None`` means "all categories", folder == category name.
"""
if category_roots is not None:
return dict(category_roots)
if categories is not None:
return {cat: cat for cat in categories}
return None
def _clean_title(self, file_obj: File) -> str:
"""The movie's clean title (no trailing ``(YYYY)``), path-safe."""
ctx = file_obj.name_context()
title = str(ctx.get("title") or file_obj.file_path.stem or "").strip()
parsed = parse_pool_stem(title)
if parsed is not None:
title = parsed[0]
title = title.replace("/", "-").replace("\\", "-").strip()
return title or file_obj.file_path.stem
def _default_link_name(self, file_obj: File) -> str:
"""Filmotéka hardlink name: the clean title + extension (no year)."""
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
def _year_link_name(self, file_obj: File) -> str | None:
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
year = str(file_obj.name_context().get("year") or "").strip()
if not is_valid_year(year):
return None
return f"{self._clean_title(file_obj)} ({year}){file_obj.file_path.suffix}"
def _render_template(self, file_obj: File, template: str) -> str:
"""Render a per-category filename template; fall back to the clean name."""
try:
rendered = template.format_map(_SafeDict(file_obj.name_context()))
except ValueError, KeyError, IndexError, AttributeError:
return self._default_link_name(file_obj)
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
return rendered or self._default_link_name(file_obj)
def _plan_links(
self,
files: list[File],
roots: dict[str, str] | None,
transforms: dict[str, str] | None,
templates: dict[str, str] | None,
) -> 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).
"""
# (file_obj, target_dir, name, is_templated), one per file+folder
entries: list[list] = []
seen_fd: set[tuple[str, str]] = set()
for file_obj in files:
for tag in file_obj.tags:
target_dir = self._target_dir(tag, roots, transforms)
if target_dir is None:
continue
key = (str(file_obj.file_path), str(target_dir))
if key in seen_fd: # one link per movie per folder
continue
seen_fd.add(key)
template = templates.get(tag.category) if templates else None
if template:
entries.append(
[
file_obj,
target_dir,
self._render_template(file_obj, template),
True,
]
)
else:
entries.append(
[file_obj, target_dir, self._default_link_name(file_obj), False]
)
# Disambiguate default-named collisions (different movies) by their year.
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
for file_obj, target_dir, name, templated in entries:
if not templated:
groups[(target_dir, name)].add(str(file_obj.file_path))
for entry in entries:
file_obj, target_dir, name, templated = entry
if not templated and len(groups[(target_dir, name)]) > 1:
year_name = self._year_link_name(file_obj)
if year_name:
entry[2] = year_name
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
by_path: dict[tuple[Path, str], list[list]] = defaultdict(list)
for entry in entries:
by_path[(entry[1], entry[2])].append(entry)
plan: list[tuple[File, Path]] = []
for (target_dir, name), group in by_path.items():
if len(group) == 1:
plan.append((group[0][0], target_dir / name))
continue
ordered = sorted(group, key=lambda e: str(e[0].file_path))
stem, suffix = Path(name).stem, Path(name).suffix
for i, entry in enumerate(ordered):
fname = name if i == 0 else f"{stem}_{i + 1}{suffix}"
plan.append((entry[0], target_dir / fname))
return plan
def _folder_value(self, tag, transforms: dict[str, str] | None) -> 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.
"""
if transforms and tag.category in transforms:
from .transforms import apply_transform
return apply_transform(tag.name, transforms[tag.category])
return tag.name
def _target_dir(
self,
tag,
roots: dict[str, str] | None,
transforms: dict[str, str] | None = None,
) -> Path | None:
"""Output directory for a tag, or None if its category is excluded."""
if roots is None:
folder = tag.category
elif tag.category in roots:
folder = roots[tag.category]
else:
return None
base = self.output_dir / folder if folder else self.output_dir
return base / self._folder_value(tag, transforms)
def _managed_top_dirs(
self,
files: list[File],
roots: dict[str, str] | None,
transforms: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> set[str] | None:
"""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.
"""
if roots is None:
return None
grouping_roots = {folder for folder in roots.values() if folder}
has_root_level = any(not folder for folder in roots.values())
tops: set[str] = set(grouping_roots)
# genre folders currently present on the movies
for cat, folder in roots.items():
if not folder:
for file_obj in files:
for tag in file_obj.tags:
if tag.category == cat:
tops.add(self._folder_value(tag, transforms))
# plus any other root-level folder (catches genres that lost their last
# movie), minus grouping roots and reserved (mirrors / Tipy dne / …)
if has_root_level and self.output_dir.exists():
reserved = set(reserved_subfolders or set())
for entry in self.output_dir.iterdir():
if (
entry.is_dir()
and entry.name not in grouping_roots
and entry.name not in reserved
):
tops.add(entry.name)
return tops
def create_structure_for_files(
self,
files: list[File],
categories: list[str] | None = None,
dry_run: bool = False,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = 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)
"""
self.created_links = []
self.errors = []
roots = self._resolve_roots(categories, category_roots)
success_count = 0
fail_count = 0
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates
)
for file_obj, target_file in plan:
try:
if not dry_run:
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_file.exists():
# Already the right link → nothing to do (not re-counted).
if self._is_same_file(file_obj.file_path, target_file):
continue
# A wrong occupant sits on the planned path; the plan is
# authoritative, so replace it.
target_file.unlink()
os.link(file_obj.file_path, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail_count += 1
return success_count, fail_count
def mirror_as_is(
self, 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)
"""
source_dir = Path(source_dir)
if not source_dir.is_dir():
return 0, 0
base = self.output_dir / subfolder if subfolder else self.output_dir
success_count = 0
fail_count = 0
for src_file in source_dir.rglob("*"):
if not src_file.is_file():
continue
if src_file.name.endswith((".!tag", ".!ftag", ".!gtag", ".!index")):
continue
target_file = base / src_file.relative_to(source_dir)
try:
if not dry_run:
target_file.parent.mkdir(parents=True, exist_ok=True)
if target_file.exists():
if self._is_same_file(src_file, target_file):
success_count += 1
continue
target_file.unlink()
os.link(src_file, target_file)
self.created_links.append(target_file)
success_count += 1
except OSError as e:
self.errors.append((src_file, str(e)))
fail_count += 1
return success_count, fail_count
def remove_mirror(self, 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.
"""
target = self.output_dir / name
if not target.exists():
return False
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return True
def _fill_special_folder(
self, chosen: list[File], subfolder: str, dry_run: bool
) -> tuple[int, int]:
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
base = self.output_dir / subfolder
if not dry_run and base.exists():
for child in base.iterdir():
if child.is_file() or child.is_symlink():
try:
child.unlink()
except OSError:
pass
created = 0
fail = 0
for file_obj in chosen:
target = base / self._default_link_name(file_obj)
try:
if not dry_run:
base.mkdir(parents=True, exist_ok=True)
if target.exists():
if self._is_same_file(file_obj.file_path, target):
created += 1
continue
# Collision with another movie: prefer the year-qualified
# name, else a numeric suffix.
year_name = self._year_link_name(file_obj)
if year_name and not (base / year_name).exists():
target = base / year_name
else:
target = self._get_unique_name(target)
os.link(file_obj.file_path, target)
self.created_links.append(target)
created += 1
except OSError as e:
self.errors.append((file_obj.file_path, str(e)))
fail += 1
return created, fail
def generate_recently_added(
self,
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).
"""
chosen = sorted(files, key=lambda f: f.added_timestamp(), reverse=True)[:count]
return self._fill_special_folder(chosen, subfolder, dry_run)
def generate_random_tips(
self,
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)
"""
chosen = random.sample(files, min(count, len(files)))
return self._fill_special_folder(chosen, subfolder, dry_run)
def _is_same_file(self, path1: Path, path2: Path) -> bool:
"""Check if two paths point to the same file (same inode)."""
try:
return path1.stat().st_ino == path2.stat().st_ino
except OSError:
return False
def _get_unique_name(self, path: Path) -> Path:
"""Get a unique filename by adding a numeric suffix."""
stem = path.stem
suffix = path.suffix
parent = path.parent
counter = 1
while True:
new_name = f"{stem}_{counter}{suffix}"
new_path = parent / new_name
if not new_path.exists():
return new_path
counter += 1
def remove_created_links(self) -> int:
"""
Remove all hardlinks created by the last operation.
Returns:
Number of links removed
"""
removed = 0
for link_path in self.created_links:
try:
if link_path.exists() and link_path.is_file():
link_path.unlink()
removed += 1
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
self.created_links = []
return removed
def _remove_empty_parents(self, path: Path) -> None:
"""Remove empty parent directories up to output_dir."""
try:
while path != self.output_dir and path.is_dir():
if any(path.iterdir()):
break # Directory not empty
path.rmdir()
path = path.parent
except OSError:
pass
def get_preview(
self,
files: list[File],
categories: list[str] | None = None,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = 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)
"""
roots = self._resolve_roots(categories, category_roots)
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates
)
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
def find_obsolete_links(
self,
files: list[File],
categories: list[str] | None = None,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = 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
"""
obsolete: list[tuple[Path, Path]] = []
if not self.output_dir.exists():
return obsolete
roots = self._resolve_roots(categories, category_roots)
# Build a map of source file inodes to File objects
inode_to_file: dict[int, File] = {}
for file_obj in files:
try:
inode = file_obj.file_path.stat().st_ino
inode_to_file[inode] = file_obj
except OSError:
continue
# Every link the tag tree *should* contain, mapped to the inode(s) it may
# point at — taken from the very same plan that creates the links, so the
# two never disagree on a name. Matching by inode too — not just the path
# — is what catches an **orphan that squats the expected name**: a link
# whose name is right but which points to an old inode (movie
# re-imported/replaced) is still obsolete, while the correctly created
# link is *not* wrongly swept.
expected: dict[Path, set[int]] = {}
for file_obj, path in self._plan_links(
files, roots, category_transforms, category_filename_templates
):
try:
expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
except OSError:
continue
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
# Inside them, any file that isn't an expected link is obsolete — whether
# a tag was removed, the movie was replaced (orphan inode), or the folder
# itself is a leftover from a renamed layout.
top_dirs = self._managed_top_dirs(
files, roots, category_transforms, reserved_subfolders
)
for top in self.output_dir.iterdir():
if not top.is_dir():
continue
if top_dirs is not None and top.name not in top_dirs:
continue
for link_file in top.rglob("*"):
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
except OSError:
continue
# Valid only when both the path *and* the inode match a current
# movie; a name-only match (orphan squatting the name) is stale.
if link_inode in expected.get(link_file, ()):
continue
source = inode_to_file.get(link_inode)
obsolete.append((link_file, source.file_path if source else link_file))
return obsolete
def remove_obsolete_links(
self,
files: list[File],
categories: list[str] | None = None,
dry_run: bool = False,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = 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)
"""
obsolete = self.find_obsolete_links(
files,
categories,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
removed_paths = []
if dry_run:
return len(obsolete), [link for link, _ in obsolete]
for link_path, _ in obsolete:
try:
link_path.unlink()
removed_paths.append(link_path)
# Try to remove empty parent directories
self._remove_empty_parents(link_path.parent)
except OSError:
pass
return len(removed_paths), removed_paths
def sync_structure(
self,
files: list[File],
categories: list[str] | None = None,
dry_run: bool = False,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = 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)
"""
# First find how many obsolete links there are
obsolete_count = len(
self.find_obsolete_links(
files,
categories,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
)
# Remove obsolete links
removed, _removed_paths = self.remove_obsolete_links(
files,
categories,
dry_run,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links
created, create_failed = self.create_structure_for_files(
files,
categories,
dry_run,
category_roots,
category_transforms,
category_filename_templates,
)
return created, create_failed, removed, remove_failed
def create_hardlink_structure(
files: list[File], output_dir: Path, categories: list[str] | None = 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)
"""
manager = HardlinkManager(output_dir)
success, fail = manager.create_structure_for_files(files, categories)
return success, fail, manager.errors
+127
View File
@@ -0,0 +1,127 @@
"""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.
"""
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
# Cap a single stderr excerpt so a chatty decoder can't flood the report.
_MAX_ERROR_LEN = 300
@dataclass
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``.
"""
path: Path
ok: bool
error: str = ""
checked: bool = True
def ffmpeg_available(deep: bool = True) -> bool:
"""Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?"""
return shutil.which(FFMPEG if deep else FFPROBE) is not None
def check_video_integrity(
path: Path, deep: bool = True, timeout: int | None = 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 :class:`IntegrityResult`. ``checked=False`` when the check could not
be run at all (tool or file missing) — distinct from a real failure.
"""
path = Path(path)
if not path.is_file():
return IntegrityResult(path, ok=False, error="Soubor neexistuje", checked=False)
tool = FFMPEG if deep else FFPROBE
if shutil.which(tool) is None:
return IntegrityResult(
path, ok=False, error=f"{tool} není k dispozici", checked=False
)
if deep:
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
else:
cmd = [
FFPROBE,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
str(path),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
errors="replace",
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
except OSError as exc:
return IntegrityResult(path, ok=False, error=str(exc), checked=False)
stderr = (result.stderr or "").strip()
if deep:
ok = result.returncode == 0 and not stderr
else:
# Quick probe: a healthy file returns rc 0 and a "video" codec_type row.
ok = result.returncode == 0 and "video" in (result.stdout or "")
if not ok and not stderr:
stderr = "Žádný video stream / nečitelný kontejner"
if ok:
return IntegrityResult(path, ok=True)
return IntegrityResult(
path, ok=False, error=_first_error_line(stderr) or "Neznámá chyba dekódování"
)
def _first_error_line(stderr: str) -> str:
"""First non-empty stderr line, truncated — a compact reason for the report."""
for line in stderr.splitlines():
line = line.strip()
if line:
return line[:_MAX_ERROR_LEN]
return ""
+118
View File
@@ -0,0 +1,118 @@
"""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.
"""
import contextlib
import os
from dataclasses import dataclass
import requests
with contextlib.suppress(
Exception
): # the values can also come from the real environment
from dotenv import load_dotenv
load_dotenv()
_TIMEOUT = 30
@dataclass
class JellyfinMovie:
"""A movie as Jellyfin currently sees it (for verification)."""
jellyfin_id: str
title: str
year: int | None
tmdb_id: int | None
path: str | None
class JellyfinError(RuntimeError):
"""A Jellyfin request could not be completed (no config, network, status)."""
def base_url() -> str | None:
"""The configured Jellyfin base URL (no trailing slash), or None."""
url = os.getenv("JELLYFIN_URL")
return url.strip().rstrip("/") if url and url.strip() else None
def api_key() -> str | None:
"""The configured Jellyfin API key, or None."""
key = os.getenv("JELLYFIN_API_KEY")
return key.strip() if key and key.strip() else None
def api_available() -> bool:
"""True when both the Jellyfin URL and API key are configured."""
return bool(base_url() and api_key())
def _request(
method: str, path: str, params: dict | None = None, timeout: int | None = None
) -> requests.Response:
url, key = base_url(), api_key()
if not (url and key):
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
try:
resp = requests.request(
method,
f"{url}{path}",
headers={"X-Emby-Token": key},
params=params,
timeout=timeout or _TIMEOUT,
)
except requests.RequestException as exc:
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
if resp.status_code in (401, 403):
raise JellyfinError("Neplatný JELLYFIN_API_KEY.")
if not resp.ok:
raise JellyfinError(f"Jellyfin odpověděl HTTP {resp.status_code}.")
return resp
def server_version(timeout: int | None = None) -> str:
"""Return the Jellyfin server version (a quick connectivity check)."""
data = _request("GET", "/System/Info", timeout=timeout).json()
return str(data.get("Version") or "?")
def scan_all() -> None:
"""Trigger a scan of all Jellyfin libraries (picks up on-disk changes)."""
_request("POST", "/Library/Refresh")
def fetch_movies() -> list[JellyfinMovie]:
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
data = _request(
"GET",
"/Items",
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProviderIds,Path,ProductionYear",
"EnableTotalRecordCount": "false",
},
).json()
movies: list[JellyfinMovie] = []
for item in data.get("Items", []):
provider = item.get("ProviderIds") or {}
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
movies.append(
JellyfinMovie(
jellyfin_id=str(item.get("Id")),
title=item.get("Name") or "",
year=item.get("ProductionYear"),
tmdb_id=tmdb_id,
path=item.get("Path"),
)
)
return movies
+110
View File
@@ -0,0 +1,110 @@
"""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.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .pool_index import INDEX_FILENAME
SIDECAR_SUFFIX = ".!tag"
_MAX_INDEX_WALK_UP = 4
@dataclass
class LegacyMeta:
"""What an old sidecar / index record can still tell us about a movie."""
csfd_url: str | None = None
title: str | None = None
year: str | None = None # four-digit string
source: str = "" # "sidecar" | "index"
def _record_to_meta(record: object, source: str) -> LegacyMeta | None:
"""Pull the useful fields out of a stored movie record (any schema version)."""
if not isinstance(record, dict):
return None
csfd = record.get("csfd_url") or record.get("csfd_link") # new | Tagger-era key
title = record.get("title") or None
meta_raw = record.get("meta")
meta = meta_raw if isinstance(meta_raw, dict) else {}
cache_raw = record.get("csfd_cache")
cache = cache_raw if isinstance(cache_raw, dict) else {}
raw_year = meta.get("year") or cache.get("year")
year = (
str(raw_year).strip()
if raw_year is not None and str(raw_year).strip().isdigit()
else None
)
if not (csfd or title or year):
return None
return LegacyMeta(csfd_url=csfd or None, title=title, year=year, source=source)
def _from_sidecar(video_path: Path) -> LegacyMeta | None:
sidecar = video_path.parent / f".{video_path.name}{SIDECAR_SUFFIX}"
if not sidecar.is_file():
return None
try:
record = json.loads(sidecar.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
return _record_to_meta(record, "sidecar")
def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> dict | None:
"""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).
"""
try:
rel = video_path.relative_to(index_dir).as_posix()
if rel in movies:
return movies[rel]
except ValueError:
pass
name = video_path.name
matches = [v for k, v in movies.items() if PurePosixPath(k).name == name]
return matches[0] if len(matches) == 1 else None
def _from_legacy_index(video_path: Path) -> LegacyMeta | None:
directory = video_path.parent
for _ in range(_MAX_INDEX_WALK_UP + 1):
index_path = directory / INDEX_FILENAME
if index_path.is_file():
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
movies = data.get("movies", data) if isinstance(data, dict) else None
if not isinstance(movies, dict):
return None
record = _lookup_in_index(movies, video_path, directory)
return _record_to_meta(record, "index") if record is not None else None
if directory.parent == directory:
break
directory = directory.parent
return None
def read_legacy_meta(video_path: Path) -> LegacyMeta | None:
"""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.
"""
video_path = Path(video_path)
return _from_sidecar(video_path) or _from_legacy_index(video_path)
+21
View File
@@ -0,0 +1,21 @@
from .file import File
class ListManager:
def __init__(self):
# 'name' nebo 'date'
self.sort_mode = "name"
def set_sort(self, mode: str):
if mode in ("name", "date"):
self.sort_mode = mode
def sort_files(self, files: list[File]) -> list[File]:
if self.sort_mode == "name":
return sorted(files, key=lambda f: f.filename.lower())
else:
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
def date_key(f):
return (f.date is None, f.date or "")
return sorted(files, key=date_key)
+57
View File
@@ -0,0 +1,57 @@
# Module header
import sys
from .file import File
from .tag_manager import TagManager
if __name__ == "__main__":
sys.exit("This module is not intended to be executed as the main program.")
# Imports
import subprocess
from PIL import Image, ImageTk
# Functions
def load_icon(path) -> ImageTk.PhotoImage:
img = Image.open(path)
img = img.resize((16, 16), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img)
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
"""
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
Vyžaduje ffprobe (FFmpeg).
"""
path = str(file_obj.file_path)
try:
# ffprobe vrátí width a height ve formátu JSON
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
path,
],
capture_output=True,
text=True,
check=True,
)
res = result.stdout.strip() # např. "1920x1080"
if "x" not in res:
return
_width, height = map(int, res.split("x"))
tag_name = f"Rozlišení/{height}p"
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
file_obj.add_tag(tag_obj)
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
except (OSError, subprocess.SubprocessError, ValueError) as e:
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
+62
View File
@@ -0,0 +1,62 @@
"""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.
"""
import re
import unicodedata
from dataclasses import dataclass, field
@dataclass
class MatchProposal:
"""One movie's proposed TMDb match."""
file: object # core.file.File
current_name: str
title: str
year: str | None
status: str # "auto" | "ambiguous" | "no_match" | "error"
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
error: str = ""
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
def normalize(text: str) -> str:
"""Fold a title for comparison: strip diacritics/punctuation, lowercase."""
text = unicodedata.normalize("NFD", text or "")
text = "".join(c for c in text if not unicodedata.combining(c))
text = re.sub(r"[^0-9a-zA-Z ]+", " ", text.lower())
return re.sub(r"\s+", " ", text).strip()
def _year_close(a: str | None, b: str | None) -> bool:
if not (a and b and str(a).isdigit() and str(b).isdigit()):
return False
return abs(int(a) - int(b)) <= 1
def classify(title: str, year: str | None, 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.
"""
if not candidates:
return "no_match", None
nt = normalize(title)
exact_year = [
c for c in candidates if normalize(c.title) == nt and _year_close(c.year, year)
]
if len(exact_year) == 1:
return "auto", exact_year[0]
return "ambiguous", candidates[0]
+286
View File
@@ -0,0 +1,286 @@
"""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 :class:`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 —
:meth:`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 :mod:`src.core.subtitles`).
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
# Roles a media file can have. Only playable video for now — artwork stays
# Jellyfin's job (it writes poster/fanart into the folder itself).
ROLE_VIDEO = "video"
# A grouped movie needs a label on every file (all of Jellyfin's documented
# examples label every version); this is what an unlabelled one gets.
DEFAULT_VERSION_LABEL = "Výchozí"
# "HSBS" / "hsbs" / "3D HSBS" / "3d.hsbs" / "3D_HSBS" → "HSBS"
_LABEL_3D_RE = re.compile(
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE
)
def normalize_3d_label(label: object) -> str | None:
"""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.
"""
match = _LABEL_3D_RE.match(str(label or "").strip())
return match.group(1).upper() if match else None
# Jellyfin's multi-part ("stacking") keywords + a number: "part1", "CD 2", "pt-3".
_LABEL_PART_RE = re.compile(
r"^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$", re.IGNORECASE
)
def normalize_part_label(label: object) -> int | None:
"""The part number a version label denotes, or ``None``.
A *part* is one piece of a film split across several files (played back
to back as one movie), unlike a *version*, which is an alternative. Any of
Jellyfin's stacking spellings counts — ``part2``, ``CD2``, ``pt 2`` — and
Curator always writes it back as ``-part<N>`` (see :func:`part_label`).
"""
match = _LABEL_PART_RE.match(str(label or "").strip())
return int(match.group(1)) if match else None
def part_label(number: int) -> str:
"""Canonical version label of part ``number`` — ``"part<N>"``."""
return f"part{int(number)}"
def 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 :data:`DEFAULT_VERSION_LABEL`.
"""
label = (label or "").strip()
if grouped and not label:
label = DEFAULT_VERSION_LABEL
if not label:
return f"{stem}{ext}"
fmt = normalize_3d_label(label)
if fmt:
return f"{stem}.3D.{fmt}{ext}"
return f"{stem} - {label}{ext}"
def 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.
"""
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
if id_s.isdigit():
return f"tmdb-{int(id_s)}"
raw = f"{(title or '').strip()} {str(year or '').strip()}".strip().lower()
# Fold diacritics so "Pelíšky" and "Pelisky" can't become two movies.
raw = "".join(
c for c in unicodedata.normalize("NFD", raw) if not unicodedata.combining(c)
)
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
return f"name-{slug or 'bez-nazvu'}"
@dataclass
class MediaFile:
"""One file belonging to a movie (today always a playable video)."""
path: Path
label: str = ""
role: str = ROLE_VIDEO
@property
def format_3d(self) -> str | None:
"""The 3D format this version's label denotes, if any."""
return normalize_3d_label(self.label)
def to_record(self, pool_dir: Path) -> dict:
"""Serialise for the pool index (path stored pool-relative, POSIX)."""
try:
rel = Path(self.path).relative_to(pool_dir).as_posix()
except ValueError:
rel = Path(self.path).as_posix()
return {"role": self.role, "label": self.label, "file": rel}
@classmethod
def from_record(cls, data: dict, pool_dir: Path) -> MediaFile:
return cls(
path=pool_dir / str(data.get("file", "")),
label=str(data.get("label", "") or ""),
role=str(data.get("role") or ROLE_VIDEO),
)
@dataclass
class Movie:
"""A film in the pool: its identity, its metadata, and its media files."""
key: str
title: str = ""
year: str | None = None
tmdb_id: int | None = None
library: str = ""
media: list[MediaFile] = field(default_factory=list)
# --- metadata that used to live on the "primary" File ---
tags: list = field(default_factory=list)
auto_tag_paths: set[str] = field(default_factory=set)
csfd_url: str | None = None
date: str | None = None
added: str | None = None
attributes: dict[str, str] = field(default_factory=dict)
meta: dict | None = None
# ------------------------------------------------------------------
# Identity / shape
# ------------------------------------------------------------------
@property
def videos(self) -> list[MediaFile]:
"""Playable versions, in a stable order (labelled ones by label)."""
return sorted(
(m for m in self.media if m.role == ROLE_VIDEO),
key=lambda m: m.label.casefold(),
)
@property
def is_grouped(self) -> bool:
"""True when the movie needs its own folder (more than one version)."""
return len(self.videos) > 1
@property
def stem(self) -> 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).
"""
title = (self.title or "").replace("/", "-").replace("\\", "-").strip()
if not title:
title = self.key
if is_valid_year(self.year or ""):
return canonical_pool_stem(title, self.year, self.tmdb_id)
return title
@property
def primary(self) -> MediaFile | None:
"""The version that represents the movie (first by :attr:`videos`)."""
videos = self.videos
return videos[0] if videos else None
def desired_layout(self, 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.
"""
base = Path(pool_dir) / self.library
grouped = self.is_grouped
folder = base / self.stem if grouped else base
return [
(
m,
folder
/ media_filename(
self.stem, m.label, Path(m.path).suffix, grouped=grouped
),
)
for m in self.videos
]
# ------------------------------------------------------------------
# Index serialisation
# ------------------------------------------------------------------
def to_record(self, pool_dir: Path) -> dict:
data = {
"title": self.title,
"year": self.year,
"tmdb_id": self.tmdb_id,
"library": self.library,
"media": [m.to_record(pool_dir) for m in self.media],
"tags": [getattr(t, "full_path", t) for t in self.tags],
"auto_tags": sorted(self.auto_tag_paths),
"csfd_url": self.csfd_url,
"date": self.date,
"added": self.added,
"attributes": dict(self.attributes),
}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
@classmethod
def from_record(
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
) -> Movie:
movie = cls(
key=key,
title=str(data.get("title") or ""),
year=(str(data["year"]) if data.get("year") else None),
tmdb_id=(
int(data["tmdb_id"])
if str(data.get("tmdb_id") or "").isdigit()
else None
),
library=str(data.get("library") or ""),
media=[
MediaFile.from_record(m, pool_dir) for m in (data.get("media") or [])
],
auto_tag_paths=set(data.get("auto_tags") or []),
csfd_url=data.get("csfd_url") or None,
date=data.get("date") or None,
added=data.get("added") or None,
attributes=dict(data.get("attributes") or {}),
meta=(dict(data["meta"]) if data.get("meta") else None),
)
if tagmanager is not None:
for full_path in data.get("tags") or []:
if "/" in str(full_path):
category, name = str(full_path).split("/", 1)
movie.tags.append(tagmanager.add_tag(category, name))
return movie
+143
View File
@@ -0,0 +1,143 @@
"""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
:data:`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).
"""
import re
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
# half/full top-and-bottom, mvc = Multiview Video Coding.
THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and
# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that
# itself contains parentheses keeps them: "Kurz (special) (2019)" → title
# "Kurz (special)", year 2019.
_POOL_STEM_RE = re.compile(
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
re.IGNORECASE,
)
def is_valid_year(year: object) -> bool:
"""True when ``year`` is (or stringifies to) exactly four digits."""
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
def 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 :data:`THREE_D_FORMATS`.
"""
title = (title or "").strip()
year_s = str(year).strip()
if not title:
raise ValueError("Název filmu nesmí být prázdný.")
if not re.fullmatch(r"\d{4}", year_s):
raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.")
stem = f"{title} ({year_s})"
if tmdb_id is not None and str(tmdb_id).strip() != "":
id_s = str(tmdb_id).strip()
if not id_s.isdigit():
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
stem += f" [tmdbid-{id_s}]"
if format_3d is not None and str(format_3d).strip() != "":
fmt = str(format_3d).strip().upper()
if fmt not in THREE_D_FORMATS:
raise ValueError(
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
f"ne „{format_3d}“."
)
stem += f".3D.{fmt}"
return stem
def 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 :func:`parse_tmdb_id`
to read it), so the returned title is always the clean title.
"""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match:
return None
return match.group("title").strip(), int(match.group("year"))
def parse_tmdb_id(stem: str) -> int | None:
"""Read the ``[tmdbid-N]`` from a canonical stem, or ``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("id"):
return None
return int(match.group("id"))
def parse_3d_format(stem: str) -> str | None:
"""Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or
``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("format3d"):
return None
return match.group("format3d").upper()
def is_canonical_pool_stem(stem: str) -> bool:
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
return _POOL_STEM_RE.match((stem or "").strip()) is not None
def 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``.
"""
paren = re.search(r"\((19\d{2}|20\d{2})\)", name)
if paren:
return paren.group(1)
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
return loose[-1] if loose else None
# "3D" + one of THREE_D_FORMATS, anywhere in a raw filename, bordered by a
# non-alphanumeric character (or the string's edge) on each side so it can't
# match inside an unrelated word.
_THREE_D_ANYWHERE_RE = re.compile(
rf"(?<![A-Za-z0-9])3d[\s._-]*({'|'.join(THREE_D_FORMATS)})(?![A-Za-z0-9])",
re.IGNORECASE,
)
def 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 :func:`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.
"""
match = _THREE_D_ANYWHERE_RE.search(name or "")
return match.group(1).upper() if match else None
+126
View File
@@ -0,0 +1,126 @@
"""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).
"""
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class NfoData:
"""The bits of a movie ``.nfo`` Curator uses."""
title: str | None = None
year: str | None = None # four-digit string
genres: list[str] = field(default_factory=list)
countries: list[str] = field(default_factory=list)
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
tmdb_id: int | None = None
csfd_url: str | None = None # ČSFD film URL, if the NFO carries a csfd id
def field_values(self, name: str) -> list[str]:
"""Values for a tag-schema source field (genres/countries/year/rating)."""
if name == "genres":
return list(self.genres)
if name == "countries":
return list(self.countries)
if name == "year":
return [self.year] if self.year else []
if name == "rating":
return [str(self.rating)] if self.rating is not None else []
return []
def find_nfo(video_path: Path) -> Path | None:
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
video_path = Path(video_path)
sibling = video_path.with_suffix(".nfo")
if sibling.is_file():
return sibling
movie_nfo = video_path.parent / "movie.nfo"
if movie_nfo.is_file():
return movie_nfo
return None
def _texts(root: ET.Element, tag: str) -> list[str]:
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
def _parse_rating(root: ET.Element) -> int | None:
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
raw = root.findtext("rating")
if raw is None:
value = root.find("ratings/rating/value")
raw = value.text if value is not None else None
if not raw:
return None
try:
return round(float(raw.strip().replace(",", ".")) * 10)
except ValueError:
return None
def _parse_tmdb_id(root: ET.Element) -> int | None:
for uid in root.findall("uniqueid"):
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
return int(uid.text.strip())
raw = root.findtext("tmdbid")
return int(raw.strip()) if raw and raw.strip().isdigit() else None
def _parse_csfd_url(root: ET.Element) -> str | None:
"""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>/``.
"""
candidates: list[str] = []
for uid in root.findall("uniqueid"):
if uid.get("type") == "csfd" and uid.text and uid.text.strip():
candidates.append(uid.text.strip())
for tag in ("csfdid", "csfd"):
raw = root.findtext(tag)
if raw and raw.strip():
candidates.append(raw.strip())
for value in candidates:
if value.isdigit():
return f"https://www.csfd.cz/film/{value}/"
if "csfd.cz" in value:
return value if value.startswith("http") else f"https://{value.lstrip('/')}"
return None
def read_nfo(video_path: Path) -> NfoData | None:
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
path = find_nfo(video_path)
if path is None:
return None
try:
root = ET.parse(path).getroot()
except ET.ParseError, OSError:
return None
year = root.findtext("year")
year = year.strip() if year and year.strip().isdigit() else None
title = root.findtext("title")
return NfoData(
title=title.strip() if title and title.strip() else None,
year=year,
genres=_texts(root, "genre"),
countries=_texts(root, "country"),
rating=_parse_rating(root),
tmdb_id=_parse_tmdb_id(root),
csfd_url=_parse_csfd_url(root),
)
+211
View File
@@ -0,0 +1,211 @@
"""
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 :mod:`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; :meth:`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.
"""
import json
import shutil
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
INDEX_VERSION = 2
class PoolIndex:
"""Single-file JSON metadata store for all files in a pool."""
def __init__(self, pool_dir: Path) -> None:
self.pool_dir = Path(pool_dir)
self.index_path = self.pool_dir / INDEX_FILENAME
self.records: dict[str, dict] = {}
self.settings: dict = {}
self.load()
def _key(self, file_path: Path) -> str:
"""Pool-relative POSIX key for a file path."""
path = Path(file_path)
try:
return path.relative_to(self.pool_dir).as_posix()
except ValueError:
return path.as_posix()
def load(self) -> 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.
"""
if not self.index_path.exists():
self.records = {}
self.settings = {}
return
try:
with open(self.index_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except json.JSONDecodeError, OSError:
self.records = {}
self.settings = {}
return
if int(data.get("version", 1)) < INDEX_VERSION:
self._migrate_v1_to_v2()
def _migrate_v1_to_v2(self) -> 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.
"""
from .movie import ROLE_VIDEO, identity_key
from .naming import is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id
migrated: dict[str, dict] = {}
for rel_path, record in self.records.items():
if not isinstance(record, dict) or "media" in record:
continue # already v2-shaped, leave it alone
path = Path(rel_path)
parts = path.parts
library = parts[0] if len(parts) > 1 else ""
# A file inside its own "Title (rok) [tmdbid]" folder is one
# version of that movie; its own stem carries the " - label".
parent = path.parent.name
grouped = len(parts) > 2 and is_canonical_pool_stem(parent)
identity_stem = parent if grouped else path.stem
parsed = parse_pool_stem(identity_stem)
title = record.get("title") or (parsed[0] if parsed else path.stem)
year = str(parsed[1]) if parsed else None
tmdb_id = parse_tmdb_id(identity_stem)
label = ""
if grouped and path.stem.startswith(f"{parent} - "):
label = path.stem[len(parent) + 3 :]
elif grouped:
label = path.stem[len(parent) :].lstrip(". -") or ""
key = identity_key(tmdb_id, title, year)
entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path}
if key in migrated:
migrated[key]["media"].append(entry)
continue
migrated[key] = {
"title": title,
"year": year,
"tmdb_id": tmdb_id,
"library": library,
"media": [entry],
"tags": record.get("tags", []),
"auto_tags": record.get("auto_tags", record.get("csfd_tags", [])),
"csfd_url": record.get("csfd_url") or record.get("csfd_link"),
"date": record.get("date"),
"added": record.get("added"),
"attributes": record.get("attributes", {}),
}
if record.get("meta"):
migrated[key]["meta"] = record["meta"]
try:
shutil.copy2(
self.index_path,
self.index_path.with_name(self.index_path.name + ".v1.bak"),
)
except OSError:
pass
self.records = migrated
self.save()
def save(self) -> None:
"""Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
json.dump(
{
"version": INDEX_VERSION,
"settings": self.settings,
"movies": self.records,
},
f,
indent=2,
ensure_ascii=False,
)
def get_setting(self, key: str, default=None):
"""Return a library-level setting stored in the index."""
return self.settings.get(key, default)
def set_setting(self, key: str, value) -> None:
"""Upsert a library-level setting and persist the index."""
self.settings[key] = value
self.save()
# ------------------------------------------------------------------
# Movie records (v2 — keyed by identity, own their media files)
# ------------------------------------------------------------------
def get_movie(self, key: str) -> dict | None:
"""Return the movie record under ``key``, or None."""
return self.records.get(key)
def set_movie(self, key: str, data: dict) -> None:
"""Upsert a movie record and persist the index."""
self.records[key] = data
self.save()
def delete_movie(self, key: str) -> None:
"""Remove a movie record (if present) and persist the index."""
if self.records.pop(key, None) is not None:
self.save()
def rekey_movie(self, old_key: str, new_key: str) -> None:
"""Move a record to a new identity key (e.g. its TMDb id was set/fixed)."""
if old_key == new_key or old_key not in self.records:
return
self.records[new_key] = self.records.pop(old_key)
self.save()
def all_movies(self) -> dict[str, dict]:
"""Every movie record, keyed by identity."""
return dict(self.records)
def record_for(self, file_path: Path) -> dict | None:
"""The movie record that owns ``file_path``, or None if untracked."""
found = self.find_movie_by_media(file_path)
return found[1] if found is not None else None
def find_movie_by_media(self, 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.
"""
rel = self._key(file_path)
for key, record in self.records.items():
for media in record.get("media", []):
if media.get("file") == rel:
return key, record
return None
+310
View File
@@ -0,0 +1,310 @@
"""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.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from .naming import (
canonical_pool_stem,
is_canonical_pool_stem,
parse_pool_stem,
parse_tmdb_id,
)
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
_EPISODE_RE = re.compile(
r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))"
)
# Jellyfin's own "stacking" tokens for one episode split across several
# physical files (an old rip cut into CDs, a two-tape recording, …) — as
# opposed to E01-E02, which is *two different* episodes in one file.
_PART_TYPES = r"cd|dvd|part|pt|disc|disk"
# Scanning a real filename: the token must sit at its own boundary right
# before the end of the stem, so a title that happens to contain "cd" or
# "part" as part of a word is never misread as a stacking suffix.
_PART_RE = re.compile(
rf"(?<![A-Za-z0-9])(?:{_PART_TYPES})[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# The compact SxxEyy field a user types by hand (e.g. "S01E05P2") may also use
# a bare "P" and no separator at all — safe only because it is matched against
# just the tail *after* the recognised SxxEyy, never against a whole filename.
_PART_RE_LOOSE = re.compile(
rf"[\s._-]?(?:{_PART_TYPES}|p)[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# Files next to an episode that are not video (NFO / artwork / subtitles).
_SIDECARS = {
".nfo",
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
".tbn",
".srt",
".ass",
".ssa",
".sub",
".idx",
".vtt",
}
@dataclass
class Episode:
path: Path
season: int | None
episode: int | None
# Which physical part of the episode this file is (Jellyfin stacking,
# e.g. an old recording split into two files) — None for a whole episode.
part: int | None = None
@property
def code(self) -> str:
if self.season is None or self.episode is None:
return "?"
base = f"S{self.season:02d}E{self.episode:02d}"
return f"{base}.pt{self.part}" if self.part else base
@dataclass
class Series:
folder: Path
title: str
year: str | None
tmdb_id: int | None
library: str
episodes: list[Episode] = field(default_factory=list)
def seasons(self) -> dict[int, list[Episode]]:
"""Episodes grouped by season number (``-1`` = unrecognised)."""
grouped: dict[int, list[Episode]] = {}
for ep in self.episodes:
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(
ep
)
for eps in grouped.values():
eps.sort(key=lambda e: e.episode if e.episode is not None else 1_000_000)
return dict(sorted(grouped.items()))
def parse_episode(name: str) -> tuple[int, int] | None:
"""Extract ``(season, episode)`` from a filename, or ``None``."""
match = _EPISODE_RE.search(name)
if not match:
return None
if match.group(1) is not None:
return int(match.group(1)), int(match.group(2))
return int(match.group(3)), int(match.group(4))
def parse_episode_part(name: str) -> int | None:
"""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.
"""
match = _PART_RE.search(Path(name).stem)
return int(match.group(1)) if match else None
def parse_episode_code(text: str) -> tuple[int, int, int | None] | None:
"""Parse a compact user-typed code: ``SxxEyy``, optionally + a part.
Accepts everything :func:`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.
"""
text = (text or "").strip()
match = _EPISODE_RE.search(text)
if not match:
return None
if match.group(1) is not None:
season, episode = int(match.group(1)), int(match.group(2))
else:
season, episode = int(match.group(3)), int(match.group(4))
part_match = _PART_RE_LOOSE.match(text[match.end() :])
return season, episode, int(part_match.group(1)) if part_match else None
def 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 :func:`parse_episode`.
"""
folder = Path(folder)
if not folder.is_dir():
return []
return sorted(
f
for f in folder.rglob("*")
if f.is_file() and f.suffix.lower() not in _SIDECARS
)
def season_folder_name(season: int) -> str:
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
return "Specials" if season == 0 else f"Season {season:02d}"
def is_season_folder(name: str) -> bool:
"""True for a season folder name — ``Specials`` or ``Season <n>`` (any padding)."""
return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None
def 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: :meth:`FileManager.is_movie_group_member`
and :func:`scan_series` both check it before deciding which one a
canonical-shaped subfolder is.
"""
folder = Path(folder)
if not folder.is_dir():
return False
return any(p.is_dir() and is_season_folder(p.name) for p in folder.iterdir())
# Characters not allowed in a filename on the common filesystems.
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def sanitize_title(title: str) -> str:
"""A series/episode title made safe to sit in a filename (spaces collapsed)."""
cleaned = _FS_UNSAFE.sub("", title or "")
return re.sub(r"\s+", " ", cleaned).strip(" .")
def episode_filename(
season: int,
episode: int,
series_title: str,
ext: str,
*,
part: int | None = 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.
"""
code = f"S{season:02d}E{episode:02d}"
body = sanitize_title(series_title)
name = f"{code} - {body}" if body else code
if part:
name += f".pt{part}"
return f"{name}{ext}"
def 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.
"""
title = (title or "").strip()
if not title:
raise ValueError("Název seriálu nesmí být prázdný.")
year_s = str(year).strip() if year is not None else ""
if re.fullmatch(r"\d{4}", year_s):
return canonical_pool_stem(title, year_s, tmdb_id)
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
def is_canonical_episode(path: Path, series_title: str) -> bool:
"""True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
parsed = parse_episode(path.name)
if parsed is None:
return False
season, episode = parsed
part = parse_episode_part(path.name)
expected = episode_filename(season, episode, series_title, path.suffix, part=part)
if path.name != expected:
return False
return path.parent.name == season_folder_name(season)
def scan_series(
lib_dir: Path,
library: str,
*,
skip_movie_folders: bool = False,
) -> list[Series]:
"""Scan one series library into a list of :class:`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 :meth:`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.
"""
lib_dir = Path(lib_dir)
if not lib_dir.is_dir():
return []
series_list: list[Series] = []
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
if (
skip_movie_folders
and is_canonical_pool_stem(folder.name)
and not looks_like_series_folder(folder)
):
continue
parsed = parse_pool_stem(folder.name)
if parsed is not None:
title, year = parsed[0], str(parsed[1])
else:
title, year = folder.name, None
tmdb_id = parse_tmdb_id(folder.name)
episodes: list[Episode] = []
for f in sorted(folder.rglob("*")):
if not f.is_file() or f.suffix.lower() in _SIDECARS:
continue
code = parse_episode(f.name)
episodes.append(
Episode(
f,
code[0] if code else None,
code[1] if code else None,
parse_episode_part(f.name),
)
)
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
return series_list
+157
View File
@@ -0,0 +1,157 @@
"""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 (:func:`attach_subtitle`), and moving a subtitle along whenever its
video gets renamed so it never silently detaches (:func:`rename_subtitles_with`).
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
_FLAGS = {"forced", "sdh", "default"}
_LANG_RE = re.compile(r"[a-z]{2,3}")
@dataclass
class SubtitleFile:
"""One subtitle sidecar found next to a video."""
path: Path
language: str | None = None # ISO 639-1/2 code, e.g. "cs"
flag: str | None = None # "forced" | "sdh" | "default"
@property
def label(self) -> str:
"""Human-readable ``jazyk (flag)`` for display, e.g. ``cs (forced)``."""
text = self.language or "?"
return f"{text} ({self.flag})" if self.flag else text
def subtitle_filename(
video_stem: str,
ext: str,
language: str | None = None,
flag: str | None = None,
) -> str:
"""Canonical subtitle filename: ``{video_stem}[.language][.flag]{ext}``."""
parts = [video_stem]
if language:
parts.append(language.strip().lower())
if flag:
parts.append(flag.strip().lower())
return ".".join(parts) + ext.lower()
def _parse_subtitle_name(
video_stem: str, filename: str
) -> tuple[str | None, str | None] | None:
"""``(language, flag)`` if ``filename`` is a subtitle sidecar of ``video_stem``."""
candidate = Path(filename)
if candidate.suffix.lower() not in SUBTITLE_EXTENSIONS:
return None
stem = candidate.stem
if stem == video_stem:
return None, None
if not stem.startswith(f"{video_stem}."):
return None
tokens = [t for t in stem[len(video_stem) + 1 :].split(".") if t]
if not tokens:
return None, None
language: str | None = None
flag: str | None = None
for token in tokens:
low = token.lower()
if low in _FLAGS:
flag = low
elif language is None and _LANG_RE.fullmatch(low):
language = low
return language, flag
def find_subtitles(video_path: Path) -> list[SubtitleFile]:
"""Every external subtitle sidecar sitting next to ``video_path``, sorted."""
video_path = Path(video_path)
folder = video_path.parent
if not folder.is_dir():
return []
found: list[SubtitleFile] = []
for f in sorted(folder.iterdir()):
if not f.is_file():
continue
parsed = _parse_subtitle_name(video_path.stem, f.name)
if parsed is None:
continue
language, flag = parsed
found.append(SubtitleFile(f, language, flag))
return found
def attach_subtitle(
video_path: Path,
source: Path,
*,
language: str | None = None,
flag: str | None = 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.
"""
video_path = Path(video_path)
source = Path(source)
ext = source.suffix.lower()
if ext not in SUBTITLE_EXTENSIONS:
raise ValueError(f"Nepodporovaný formát titulků: {source.suffix}")
target = video_path.parent / subtitle_filename(video_path.stem, ext, language, flag)
if target.exists():
target.unlink()
if move:
shutil.move(str(source), str(target))
else:
shutil.copy2(source, target)
return target
def 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.
"""
old_video_path = Path(old_video_path)
new_video_path = Path(new_video_path)
if old_video_path == new_video_path:
return []
moved: list[Path] = []
for sub in find_subtitles(old_video_path):
target = new_video_path.parent / subtitle_filename(
new_video_path.stem, sub.path.suffix, sub.language, sub.flag
)
if sub.path == target:
continue
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
sub.path.rename(target)
moved.append(target)
return moved
+22
View File
@@ -0,0 +1,22 @@
class Tag:
def __init__(self, category: str, name: str):
self.category = category
self.name = name
@property
def full_path(self):
return f"{self.category}/{self.name}"
def __str__(self):
return self.full_path
def __repr__(self):
return f"Tag({self.full_path})"
def __eq__(self, other):
if isinstance(other, Tag):
return self.category == other.category and self.name == other.name
return False
def __hash__(self):
return hash((self.category, self.name))
+69
View File
@@ -0,0 +1,69 @@
from .tag import Tag
# Default tags that are always available (order in list = display order).
# The legacy Tagger presets (Hodnocení / Barva) were removed for Curator; the
# pool is driven by ČSFD-derived tags (Žánr / Rok / Země původu). Add entries here to
# reintroduce always-available predefined tags.
DEFAULT_TAGS: dict[str, list[str]] = {}
# Tag sort order for default categories (preserves display order)
DEFAULT_TAG_ORDER: dict[str, dict[str, int]] = {
category: {name: i for i, name in enumerate(names)}
for category, names in DEFAULT_TAGS.items()
}
class TagManager:
def __init__(self):
self.tags_by_category = {} # {category: set(Tag)}
self._init_default_tags()
def _init_default_tags(self):
"""Initialize default tags (ratings and colors)"""
for category, tags in DEFAULT_TAGS.items():
for tag_name in tags:
self.add_tag(category, tag_name)
def add_category(self, category: str):
if category not in self.tags_by_category:
self.tags_by_category[category] = set()
def remove_category(self, category: str):
if category in self.tags_by_category:
del self.tags_by_category[category]
def add_tag(self, category: str, name: str) -> Tag:
self.add_category(category)
tag = Tag(category, name)
self.tags_by_category[category].add(tag)
return tag
def remove_tag(self, category: str, name: str):
if category in self.tags_by_category:
tag = Tag(category, name)
self.tags_by_category[category].discard(tag)
if not self.tags_by_category[category]:
self.remove_category(category)
def get_all_tags(self):
"""Vrací list všech tagů full_path"""
return [
tag.full_path for tags in self.tags_by_category.values() for tag in tags
]
def get_categories(self):
return list(self.tags_by_category.keys())
def get_tags_in_category(self, category: str) -> list[Tag]:
"""Get tags in category, sorted by predefined order for default categories"""
tags = list(self.tags_by_category.get(category, []))
# Use predefined order for default categories
if category in DEFAULT_TAG_ORDER:
order = DEFAULT_TAG_ORDER[category]
tags.sort(key=lambda t: order.get(t.name, 999))
else:
# Sort alphabetically for custom categories
tags.sort(key=lambda t: t.name)
return tags
+192
View File
@@ -0,0 +1,192 @@
"""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: :func:`api_available`
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
"""
import contextlib
import os
from dataclasses import dataclass
import requests
# optional — the key can also come from the real environment; dotenv is a
# convenience, never required
with contextlib.suppress(Exception):
from dotenv import load_dotenv
load_dotenv()
_BASE = "https://api.themoviedb.org/3"
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
_TIMEOUT = 15
@dataclass
class TmdbResult:
"""A resolved TMDb entry — just enough to name the pool file."""
tmdb_id: int
title: str
year: str | None # four-digit string, or None when TMDb has no date
kind: str # "movie" | "tv"
@dataclass
class TmdbDetails:
"""Full TMDb data used to (re)generate a movie's tags (genres etc.).
Exposes :meth:`field_values` so it plugs into the tag schema the same way the
NFO reader does (fields: genres / countries / year / rating).
"""
tmdb_id: int
title: str
year: str | None
genres: list[str]
countries: list[str]
rating: int | None # 0–100 (TMDb vote_average 0–10 scaled up)
def field_values(self, name: str) -> list[str]:
if name == "genres":
return list(self.genres)
if name == "countries":
return list(self.countries)
if name == "year":
return [self.year] if self.year else []
if name == "rating":
return [str(self.rating)] if self.rating is not None else []
return []
class TmdbError(RuntimeError):
"""A TMDb request could not be completed (no key, network, bad status)."""
def api_key() -> str | None:
"""The configured TMDb API key, or None."""
key = os.getenv("TMDB_API_KEY")
return key.strip() if key and key.strip() else None
def api_available() -> bool:
"""True when a TMDb API key is configured."""
return api_key() is not None
def _get(path: str, params: dict) -> dict:
key = api_key()
if not key:
raise TmdbError("TMDB_API_KEY není nastaven (.env).")
query = {"api_key": key, "language": _LANG, **params}
try:
resp = requests.get(f"{_BASE}{path}", params=query, timeout=_TIMEOUT)
except requests.RequestException as exc:
raise TmdbError(f"Síťová chyba TMDb: {exc}") from exc
if resp.status_code == 401:
raise TmdbError("Neplatný TMDB_API_KEY.")
if resp.status_code == 404:
raise TmdbError("TMDb ID nenalezeno.")
if not resp.ok:
raise TmdbError(f"TMDb odpověděl HTTP {resp.status_code}.")
return resp.json()
def _year_of(date_str: str | None) -> str | None:
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
return date_str[:4]
return None
def lookup_movie(tmdb_id: int) -> TmdbResult | None:
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title")
if not title:
return None
return TmdbResult(
int(data["id"]), title, _year_of(data.get("release_date")), "movie"
)
def movie_details(tmdb_id: int) -> TmdbDetails:
"""Full movie data from TMDb (genres/countries/rating/year), localized to cs."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title") or ""
genres = [g["name"] for g in (data.get("genres") or []) if g.get("name")]
countries = [
c["name"] for c in (data.get("production_countries") or []) if c.get("name")
]
vote = data.get("vote_average")
rating = round(float(vote) * 10) if vote else None
return TmdbDetails(
tmdb_id=int(data["id"]),
title=title,
year=_year_of(data.get("release_date")),
genres=genres,
countries=countries,
rating=rating,
)
def lookup_tv(tmdb_id: int) -> TmdbResult | None:
"""Resolve a series by its TMDb id → name + first-air year, or None."""
data = _get(f"/tv/{int(tmdb_id)}", {})
title = data.get("name") or data.get("original_name")
if not title:
return None
return TmdbResult(
int(data["id"]), title, _year_of(data.get("first_air_date")), "tv"
)
def lookup(tmdb_id: int, kind: str = "movie") -> TmdbResult | None:
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
def search_movie(
query: str, year: str | None = None, limit: int = 5
) -> list[TmdbResult]:
"""Search movies by title (optionally narrowed by year); best matches first."""
if not query.strip():
return []
params: dict = {"query": query.strip()}
if year:
params["year"] = year
data = _get("/search/movie", params)
results: list[TmdbResult] = []
for item in (data.get("results") or [])[:limit]:
title = item.get("title") or item.get("original_title")
if title and item.get("id"):
results.append(
TmdbResult(
int(item["id"]), title, _year_of(item.get("release_date")), "movie"
)
)
return results
def search_tv(query: str, year: str | None = None, limit: int = 5) -> list[TmdbResult]:
"""Search series by name (optionally narrowed by first-air year)."""
if not query.strip():
return []
params: dict = {"query": query.strip()}
if year:
params["first_air_date_year"] = year
data = _get("/search/tv", params)
results: list[TmdbResult] = []
for item in (data.get("results") or [])[:limit]:
title = item.get("name") or item.get("original_name")
if title and item.get("id"):
results.append(
TmdbResult(
int(item["id"]), title, _year_of(item.get("first_air_date")), "tv"
)
)
return results
+41
View File
@@ -0,0 +1,41 @@
"""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``.
"""
import re
def 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.
"""
low = min((rating // 10) * 10, 90)
high = 100 if low == 90 else low + 9
return f"{low}–{high} %"
def _decade_band(value) -> str:
"""``decade_band`` transform: pull the leading integer and bucket it."""
match = re.search(r"\d+", str(value))
return rating_band(int(match.group())) if match else str(value)
# Named value transforms usable from the tag schema (config-driven, extensible).
TAG_TRANSFORMS = {
None: str,
"identity": str,
"decade_band": _decade_band,
}
def apply_transform(value: str, transform: str | None) -> str:
"""Apply a named tag-schema transform to a value, returning a string."""
fn = TAG_TRANSFORMS.get(transform, str)
try:
return str(fn(value))
except ValueError, TypeError, AttributeError:
return str(value)
+8
View File
@@ -0,0 +1,8 @@
from pathlib import Path
def list_files(folder_path: str | Path) -> list[Path]:
folder = Path(folder_path)
if not folder.is_dir():
raise NotADirectoryError(f"{folder} není platná složka.")
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File
+4367
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
# Tests package
+29
View File
@@ -0,0 +1,29 @@
"""
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
"""
import contextlib
import shutil
import tempfile
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def session_temp_dir():
"""Session-wide dočasný adresář"""
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture(autouse=True)
def cleanup_config_files():
"""Automaticky vyčistí config.json soubory po každém testu"""
yield
# Cleanup po testu
config_file = Path("config.json")
if config_file.exists():
with contextlib.suppress(Exception):
config_file.unlink()
+245
View File
@@ -0,0 +1,245 @@
import json
import pytest
from src.core.config import (
DEFAULT_GLOBAL_CONFIG,
load_config, # Legacy functions
load_global_config,
save_config,
save_global_config,
)
class TestGlobalConfig:
"""Testy pro globální config"""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_default_global_config_structure(self):
"""Test struktury defaultní globální konfigurace (jen data aplikace)"""
assert "window_geometry" in DEFAULT_GLOBAL_CONFIG
assert "window_maximized" in DEFAULT_GLOBAL_CONFIG
assert "last_folder" in DEFAULT_GLOBAL_CONFIG
assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG
assert "recent_folders" in DEFAULT_GLOBAL_CONFIG
assert "pool_dir" in DEFAULT_GLOBAL_CONFIG
assert "filmoteka_dir" in DEFAULT_GLOBAL_CONFIG
assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800"
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None
def test_global_config_holds_no_library_settings(self):
"""Library settings (tag_schema / copyasis_folders) žijí v indexu, ne tady."""
assert "tag_schema" not in DEFAULT_GLOBAL_CONFIG
assert "copyasis_folders" not in DEFAULT_GLOBAL_CONFIG
def test_load_global_config_nonexistent_file(self, temp_global_config):
"""Test načtení globální konfigurace když soubor neexistuje"""
config = load_global_config()
assert config == DEFAULT_GLOBAL_CONFIG
def test_save_global_config(self, temp_global_config):
"""Test uložení globální konfigurace"""
test_config = {
"window_geometry": "800x600",
"window_maximized": True,
"last_folder": "/home/user/documents",
"sidebar_width": 300,
"recent_folders": ["/path1", "/path2"],
}
save_global_config(test_config)
assert temp_global_config.exists()
with open(temp_global_config, "r", encoding="utf-8") as f:
saved_data = json.load(f)
assert saved_data == test_config
def test_load_global_config_existing_file(self, temp_global_config):
"""Test načtení existující globální konfigurace"""
test_config = {
"window_geometry": "1920x1080",
"window_maximized": False,
"last_folder": "/test/path",
"sidebar_width": 250,
"recent_folders": [],
"pool_dir": None,
"filmoteka_dir": None,
}
save_global_config(test_config)
loaded_config = load_global_config()
assert loaded_config == test_config
def test_load_global_config_merges_defaults(self, temp_global_config):
"""Test že chybějící klíče jsou doplněny z defaultů"""
partial_config = {"window_geometry": "800x600"}
with open(temp_global_config, "w", encoding="utf-8") as f:
json.dump(partial_config, f)
loaded = load_global_config()
assert loaded["window_geometry"] == "800x600"
assert loaded["window_maximized"] == DEFAULT_GLOBAL_CONFIG["window_maximized"]
assert loaded["sidebar_width"] == DEFAULT_GLOBAL_CONFIG["sidebar_width"]
def test_global_config_corrupted_file(self, temp_global_config):
"""Test načtení poškozeného global config souboru"""
with open(temp_global_config, "w") as f:
f.write("{ invalid json }")
config = load_global_config()
assert config == DEFAULT_GLOBAL_CONFIG
def test_global_config_utf8_encoding(self, temp_global_config):
"""Test UTF-8 encoding s českými znaky"""
test_config = {
**DEFAULT_GLOBAL_CONFIG,
"last_folder": "/cesta/s/českými/znaky",
"recent_folders": ["/složka/čeština"],
}
save_global_config(test_config)
loaded_config = load_global_config()
assert loaded_config["last_folder"] == "/cesta/s/českými/znaky"
assert loaded_config["recent_folders"] == ["/složka/čeština"]
def test_global_config_returns_new_dict(self, temp_global_config):
"""Test že load_global_config vrací nový dictionary"""
config1 = load_global_config()
config2 = load_global_config()
assert config1 is not config2
assert config1 == config2
def test_global_config_recent_folders(self, temp_global_config):
"""Test ukládání recent_folders"""
folders = ["/path/one", "/path/two", "/path/three"]
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
save_global_config(test_config)
loaded = load_global_config()
assert loaded["recent_folders"] == folders
assert len(loaded["recent_folders"]) == 3
class TestLegacyFunctions:
"""Testy pro zpětnou kompatibilitu"""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_load_config_legacy(self, temp_global_config):
"""Test že load_config funguje jako alias pro load_global_config"""
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"}
save_global_config(test_config)
loaded = load_config()
assert loaded["last_folder"] == "/test"
def test_save_config_legacy(self, temp_global_config):
"""Test že save_config funguje jako alias pro save_global_config"""
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"}
save_config(test_config)
loaded = load_global_config()
assert loaded["last_folder"] == "/legacy"
class TestConfigEdgeCases:
"""Testy pro edge cases"""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_config_path_with_spaces(self, temp_global_config):
"""Test s cestou obsahující mezery"""
test_config = {
**DEFAULT_GLOBAL_CONFIG,
"last_folder": "/path/with spaces/in name",
}
save_global_config(test_config)
loaded = load_global_config()
assert loaded["last_folder"] == "/path/with spaces/in name"
def test_config_long_path(self, temp_global_config):
"""Test s dlouhou cestou"""
long_path = "/very/long/path/" + "subdir/" * 50 + "final"
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path}
save_global_config(test_config)
loaded = load_global_config()
assert loaded["last_folder"] == long_path
def test_config_many_recent_folders(self, temp_global_config):
"""Test s velkým počtem recent folders"""
folders = [f"/path/folder{i}" for i in range(100)]
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
save_global_config(test_config)
loaded = load_global_config()
assert len(loaded["recent_folders"]) == 100
def test_config_json_formatting(self, temp_global_config):
"""Test že config je uložen ve správném JSON formátu s indentací"""
test_config = {**DEFAULT_GLOBAL_CONFIG}
save_global_config(test_config)
with open(temp_global_config, "r", encoding="utf-8") as f:
content = f.read()
# Mělo by být naformátováno s indentací
assert " " in content
def test_config_ensure_ascii_false(self, temp_global_config):
"""Test že ensure_ascii=False funguje správně"""
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/cesta/čeština"}
save_global_config(test_config)
with open(temp_global_config, "r", encoding="utf-8") as f:
content = f.read()
assert "čeština" in content
assert "\\u" not in content # Nemělo by být escapováno
def test_config_overwrite(self, temp_global_config):
"""Test přepsání existující konfigurace"""
config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"}
config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"}
save_global_config(config1)
save_global_config(config2)
loaded = load_global_config()
assert loaded["last_folder"] == "/path2"
+145
View File
@@ -0,0 +1,145 @@
"""Tests for constants module."""
import re
from pathlib import Path
from unittest.mock import patch
import pytest
from src.constants import (
APP_NAME,
APP_TITLE,
APP_VERSION,
ENV_DEBUG,
get_debug_mode,
get_version,
)
# ---------------------------------------------------------------------------
# get_version()
# ---------------------------------------------------------------------------
def test_get_version_returns_string() -> None:
"""get_version() should return a string."""
assert isinstance(get_version(), str)
def test_get_version_semver_format() -> None:
"""get_version() should return a semver-like string X.Y.Z."""
version = get_version()
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
missing = tmp_path / "nonexistent.toml"
with patch("src.constants._PYPROJECT_PATH", missing):
result = get_version()
# Either fallback _version.py exists (from previous run) or returns unknown
assert isinstance(result, str)
assert len(result) > 0
def test_get_version_unknown_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
missing = tmp_path / "nonexistent.toml"
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
# Patch _version import to also fail
with (
patch("src.constants.Path.write_text", side_effect=OSError),
patch.dict("sys.modules", {"src._version": None}),
):
result = get_version()
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# get_debug_mode()
# ---------------------------------------------------------------------------
def test_get_debug_mode_returns_bool() -> None:
"""get_debug_mode() should always return a bool."""
assert isinstance(get_debug_mode(), bool)
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns True when ENV_DEBUG=true."""
monkeypatch.setenv("ENV_DEBUG", "true")
assert get_debug_mode() is True
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
for value in ("1", "yes", "YES", "True", "TRUE"):
monkeypatch.setenv("ENV_DEBUG", value)
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns False when ENV_DEBUG=false."""
monkeypatch.setenv("ENV_DEBUG", "false")
assert get_debug_mode() is False
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
monkeypatch.delenv("ENV_DEBUG", raising=False)
assert get_debug_mode() is False
# ---------------------------------------------------------------------------
# Module-level constants
# ---------------------------------------------------------------------------
def test_env_debug_is_bool() -> None:
"""ENV_DEBUG should be a bool."""
assert isinstance(ENV_DEBUG, bool)
def test_app_version_is_string() -> None:
"""APP_VERSION should be a string."""
assert isinstance(APP_VERSION, str)
def test_app_version_semver_format() -> None:
"""APP_VERSION should follow semver format X.Y.Z."""
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
def test_app_name_value() -> None:
"""APP_NAME should be 'Curator'."""
assert APP_NAME == "Curator"
def test_app_title_contains_name_and_version() -> None:
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
assert APP_NAME in APP_TITLE
assert APP_VERSION in APP_TITLE
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
import src.constants as consts
monkeypatch.setenv("ENV_DEBUG", "true")
monkeypatch.setattr(consts, "ENV_DEBUG", True)
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
assert title.endswith("-DEV")
def test_app_title_no_dev_suffix_when_not_debug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
import src.constants as consts
monkeypatch.setattr(consts, "ENV_DEBUG", False)
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
assert not title.endswith("-DEV")
+279
View File
@@ -0,0 +1,279 @@
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
import hashlib
from unittest.mock import patch
import pytest
from src.core import csfd
from src.core.csfd import (
CsfdError,
CsfdFilm,
_extract_csfd_id,
_is_film_href,
_parse_origin,
_parse_original_title,
_solve_anubis_pow,
clean_filename_to_query,
fetch_film,
find_csfd_url,
search_films,
)
# A trimmed ČSFD film page: Czech title in JSON-LD, USA origin, and a
# ``.film-names`` list whose USA-flagged entry is the original title.
FILM_HTML = """
<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
"dateCreated":"1994","duration":"PT142M"}
</script>
</head><body>
<div class="film-header-name">
<h1>Vykoupení z věznice Shawshank</h1>
<ul class="film-names">
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
<span class="normal more-name-link"><a class="more">více</a></span></li>
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
Vykúpenie z väznice Shawshank</li>
</ul>
</div>
<div class="origin">USA <span class="bullet"></span>
<span>1994 <span class="bullet"></span> </span>142 min</div>
</body></html>
"""
# A Czech film: ČSFD omits the Czech title from ``.film-names`` (it equals the
# <h1>) and lists only a foreign festival title — the original title must fall
# back to the <h1>, not "Cosy Dens".
CZECH_FILM_HTML = """
<html><head>
<script type="application/ld+json">
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
</script>
</head><body>
<div class="film-header-name"><h1>Pelíšky</h1>
<ul class="film-names">
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
</ul></div>
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
</body></html>
"""
# The real search page groups hits into <section> blocks; a.film-title-name also
# matches series episodes, so search_films must scope to "Filmy" and drop the
# two-segment episode hrefs.
SEARCH_HTML = """
<html><body>
<section><div class="box-header"><h2>Filmy</h2></div>
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
</section>
<section><div class="box-header"><h2>Seriály</h2></div>
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
</section>
</body></html>
"""
class TestCleanFilenameToQuery:
def test_strips_release_tags_and_keeps_year(self):
assert (
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv")
== "Matrix 1999"
)
def test_handles_spaces_and_parens_year(self):
assert (
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv")
== "Forrest Gump 1994"
)
def test_no_year_no_markers(self):
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
def test_underscores_and_resolution(self):
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
def test_falls_back_to_stem_when_starting_with_marker(self):
assert clean_filename_to_query("1080p.mkv") == "1080p"
class TestExtractCsfdId:
def test_extract_from_various_urls(self):
assert (
_extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
)
assert _extract_csfd_id("/film/999/") == 999
def test_none_when_absent(self):
assert _extract_csfd_id("https://www.csfd.cz/") is None
class TestAnubisPoW:
def test_solve_difficulty_one_gives_one_leading_zero_nibble(self):
hash_hex, nonce, _ = _solve_anubis_pow("abc123", difficulty=1)
assert hash_hex[0] == "0"
assert hashlib.sha256(f"abc123{nonce}".encode()).hexdigest() == hash_hex
def test_solve_difficulty_two_gives_leading_zero_byte(self):
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
assert hash_hex[:2] == "00"
class TestParseOrigin:
def test_country_and_year_from_bullet_format(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup(FILM_HTML, "html.parser")
assert _parse_origin(soup) == ("USA", "1994")
def test_first_country_of_coproduction(self):
from bs4 import BeautifulSoup
html = (
'<div class="origin">Francie / Německo <span class="bullet"></span>'
"<span>2001</span> 120 min</div>"
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_origin(soup) == ("Francie", "2001")
class TestParseOriginalTitle:
def test_picks_name_matching_origin_country(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup(FILM_HTML, "html.parser")
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
def test_none_without_country_match_so_caller_uses_czech_title(self):
# A Czech film lists only foreign/festival alt-titles here — none match
# the origin country, so we must NOT return one of them.
from bs4 import BeautifulSoup
html = (
'<ul class="film-names">'
'<li><img class="flag" title="angličtina"/> Loners</li>'
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>'
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_original_title(soup, "Česko") is None
def test_cesko_matches_ceskoslovensko_flag(self):
from bs4 import BeautifulSoup
html = (
'<ul class="film-names">'
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
"</ul>"
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
def test_none_when_no_names(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup("<div>x</div>", "html.parser")
assert _parse_original_title(soup, "USA") is None
class TestFetchFilm:
def test_parses_czech_and_original_title_and_year(self):
class _Resp:
text = FILM_HTML
with patch("src.core.csfd._get_page", return_value=_Resp()):
film = fetch_film("https://www.csfd.cz/film/2294-x/")
assert film.czech_title == "Vykoupení z věznice Shawshank"
assert film.original_title == "The Shawshank Redemption"
assert film.year == "1994"
assert film.csfd_id == 2294
assert film.tmdb_query == ("The Shawshank Redemption", "1994")
def test_czech_film_original_title_falls_back_to_czech(self):
class _Resp:
text = CZECH_FILM_HTML
with patch("src.core.csfd._get_page", return_value=_Resp()):
film = fetch_film("https://www.csfd.cz/film/15387-pelisky/")
assert film.czech_title == "Pelíšky"
assert film.original_title == "Pelíšky"
assert film.year == "1999"
assert film.tmdb_query == ("Pelíšky", "1999")
def test_wraps_network_error_in_csfd_error(self):
import requests
with (
patch(
"src.core.csfd._get_page", side_effect=requests.RequestException("boom")
),
pytest.raises(CsfdError),
):
fetch_film("https://www.csfd.cz/film/1/")
class TestIsFilmHref:
def test_accepts_single_segment_film(self):
assert _is_film_href("/film/2294-vykoupeni/prehled/") is True
assert _is_film_href("/film/2294-vykoupeni/") is True
def test_rejects_series_episode(self):
assert _is_film_href("/film/318290-arrow/620181-vykoupeni/prehled/") is False
def test_rejects_non_film(self):
assert _is_film_href("/tvurce/123-nekdo/") is False
class TestSearchFilms:
def test_scopes_to_films_section_and_drops_episodes(self):
class _Resp:
text = SEARCH_HTML
with patch("src.core.csfd._get_page", return_value=_Resp()):
films = search_films("shawshank", limit=5)
# the Seriály-section episode link is excluded
assert [f.csfd_id for f in films] == [2294, 248803]
assert films[0].url == (
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/"
)
def test_empty_query_returns_empty(self):
assert search_films(" ") == []
class TestFindCsfdUrl:
def test_returns_first_hit_url(self):
hit = CsfdFilm(url="https://www.csfd.cz/film/2294-x/", csfd_id=2294)
with patch("src.core.csfd.search_films", return_value=[hit]):
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
def test_retries_without_trailing_year_when_first_query_misses(self):
hit = CsfdFilm(
url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006
)
calls: list[str] = []
def fake_search(query, limit=1, session=None):
calls.append(query)
return [] if query.endswith("2006") else [hit]
with patch("src.core.csfd.search_films", side_effect=fake_search):
assert find_csfd_url("Vratne lahve 2006") == hit.url
assert calls == ["Vratne lahve 2006", "Vratne lahve"]
def test_none_for_blank_query(self):
assert find_csfd_url(" ") is None
def test_none_when_no_results(self):
with patch("src.core.csfd.search_films", return_value=[]):
assert find_csfd_url("nic") is None
class TestDependencies:
def test_dependencies_available_in_test_env(self):
assert csfd.dependencies_available() is True
+343
View File
@@ -0,0 +1,343 @@
import pytest
from src.core.file import File
from src.core.pool_index import PoolIndex
from src.core.tag import Tag
from src.core.tag_manager import TagManager
class TestFile:
"""Testy pro třídu File (metadata žijí v pool indexu)"""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def index(self, tmp_path):
"""Pool index that backs the File's metadata."""
return PoolIndex(tmp_path)
@pytest.fixture
def test_file(self, tmp_path):
f = tmp_path / "test.txt"
f.write_text("test content")
return f
def test_file_creation(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
assert file_obj.file_path == test_file
assert file_obj.filename == "test.txt"
assert file_obj.new is True
def test_file_initial_tags(self, test_file, tag_manager, index):
"""Nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)."""
file_obj = File(test_file, tag_manager, index=index)
assert file_obj.tags == []
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
"""Metadata jsou zapsána do indexu už při vytvoření."""
file_obj = File(test_file, tag_manager, index=index)
assert index.record_for(file_obj.file_path) is not None
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
"""Žádný .!tag sidecar už nevzniká."""
File(test_file, tag_manager, index=index)
assert not (test_file.parent / ".test.txt.!tag").exists()
def test_index_less_file_is_in_memory_only(self, test_file, tag_manager):
"""Bez indexu je File jen v paměti — nic se nezapíše na disk."""
file_obj = File(test_file, tag_manager)
file_obj.add_tag("Video/HD")
file_obj.save_metadata() # no-op
assert not (test_file.parent / ".test.txt.!tag").exists()
def test_file_save_metadata(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.new = False
file_obj.ignored = True
file_obj.save_metadata()
data = index.record_for(file_obj.file_path)
assert data["new"] is False
assert data["ignored"] is True
def test_file_load_metadata(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = tag_manager.add_tag("Video", "HD")
file_obj.tags.append(tag)
file_obj.date = "2025-01-15"
file_obj.save_metadata()
# A fresh File over the same index reloads the metadata
file_obj2 = File(test_file, tag_manager, index=index)
assert len(file_obj2.tags) == 1 # Video/HD
assert file_obj2.date == "2025-01-15"
assert "Video/HD" in {t.full_path for t in file_obj2.tags}
def test_file_set_date(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
assert file_obj.date == "2025-12-25"
assert index.record_for(file_obj.file_path)["date"] == "2025-12-25"
def test_file_set_date_to_none(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
file_obj.set_date(None)
assert file_obj.date is None
def test_file_set_date_empty_string(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
file_obj.set_date("")
assert file_obj.date is None
def test_file_add_tag_object(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = Tag("Video", "4K")
file_obj.add_tag(tag)
assert tag in file_obj.tags
assert len(file_obj.tags) == 1
def test_file_add_tag_string(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Audio/MP3")
assert "Audio/MP3" in {t.full_path for t in file_obj.tags}
def test_file_add_tag_string_without_category(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("SimpleTag")
assert "default/SimpleTag" in {t.full_path for t in file_obj.tags}
def test_file_add_duplicate_tag(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = Tag("Video", "HD")
file_obj.add_tag(tag)
file_obj.add_tag(tag)
assert sum(1 for t in file_obj.tags if t == tag) == 1
def test_file_remove_tag_object(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
tag = Tag("Video", "HD")
file_obj.add_tag(tag)
file_obj.remove_tag(tag)
assert tag not in file_obj.tags
def test_file_remove_tag_string(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Video/HD")
file_obj.remove_tag("Video/HD")
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
def test_file_remove_tag_string_without_category(
self, test_file, tag_manager, index
):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("SimpleTag")
file_obj.remove_tag("SimpleTag")
assert "default/SimpleTag" not in {t.full_path for t in file_obj.tags}
def test_file_remove_nonexistent_tag(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
initial_count = len(file_obj.tags)
file_obj.remove_tag("Nonexistent/Tag")
assert len(file_obj.tags) == initial_count
def test_file_without_tagmanager(self, test_file):
file_obj = File(test_file, tagmanager=None)
assert file_obj.tagmanager is None
assert len(file_obj.tags) == 0
def test_file_metadata_persistence(self, test_file, tag_manager, index):
file_obj1 = File(test_file, tag_manager, index=index)
file_obj1.add_tag("Video/HD")
file_obj1.add_tag("Audio/Stereo")
file_obj1.set_date("2025-01-01")
file_obj1.new = False
file_obj1.ignored = True
file_obj1.save_metadata()
file_obj2 = File(test_file, tag_manager, index=index)
assert file_obj2.new is False
assert file_obj2.ignored is True
assert file_obj2.date == "2025-01-01"
tag_paths = {t.full_path for t in file_obj2.tags}
assert "Video/HD" in tag_paths
assert "Audio/Stereo" in tag_paths
def test_file_metadata_record_shape(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Test/Tag")
file_obj.set_date("2025-06-15")
data = index.record_for(file_obj.file_path)
assert "new" in data
assert "ignored" in data
assert "tags" in data
assert "date" in data
assert isinstance(data["tags"], list)
def test_file_unicode_handling(self, tmp_path, tag_manager, index):
test_file = tmp_path / "český_soubor.txt"
test_file.write_text("obsah")
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Kategorie/Český tag")
file_obj.save_metadata()
file_obj2 = File(test_file, tag_manager, index=index)
assert "Kategorie/Český tag" in {t.full_path for t in file_obj2.tags}
def test_file_complex_scenario(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("Video/HD")
file_obj.add_tag("Video/Stereo")
file_obj.add_tag("Stav/Zkontrolováno")
file_obj.set_date("2025-01-01")
file_obj.remove_tag("Stav/Nové")
tag_paths = {t.full_path for t in file_obj.tags}
assert "Video/HD" in tag_paths
assert "Video/Stereo" in tag_paths
assert "Stav/Zkontrolováno" in tag_paths
assert "Stav/Nové" not in tag_paths
assert file_obj.date == "2025-01-01"
file_obj2 = File(test_file, tag_manager, index=index)
assert {t.full_path for t in file_obj2.tags} == tag_paths
assert file_obj2.date == "2025-01-01"
class TestApplyNfoTags:
"""Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
@pytest.fixture
def tag_manager(self):
return TagManager()
@pytest.fixture
def index(self, tmp_path):
return PoolIndex(tmp_path)
@pytest.fixture
def movie_file(self, tmp_path, tag_manager, index):
path = tmp_path / "Matrix.mkv"
path.write_text("x")
return File(path, tag_manager, index=index)
def test_apply_nfo_assigns_expected_categories(self, movie_file):
from src.core.nfo import NfoData
nfo = NfoData(
title="Matrix",
year="1999",
genres=["Akční", "Sci-Fi"],
countries=["USA"],
rating=90,
)
result = movie_file.apply_nfo(nfo)
assert result["success"]
paths = {t.full_path for t in movie_file.tags}
assert "Žánr/Akční" in paths
assert "Žánr/Sci-Fi" in paths
assert "Rok/1999" in paths
assert "Země původu/USA" in paths
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
def test_apply_nfo_none_is_noop(self, movie_file):
result = movie_file.apply_nfo(None)
assert not result["success"]
assert movie_file.tags == []
def test_name_context_fields(self, movie_file):
movie_file.title = "Dr. No"
movie_file.meta = {"year": 1962, "rating": 75}
ctx = movie_file.name_context()
assert ctx["title"] == "Dr. No"
assert ctx["year"] == "1962" # coerced to str for uniform template fields
assert ctx["rating"] == 75
assert ctx["ext"] == movie_file.file_path.suffix
assert ctx["filename"] == movie_file.filename
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
movie_file.meta = None
movie_file.add_tag("Rok/1999")
assert movie_file.name_context()["year"] == "1999"
def test_added_timestamp_uses_stored_added(self, movie_file):
movie_file.added = "2026-06-16T12:00:00"
from datetime import datetime
assert (
movie_file.added_timestamp()
== datetime.fromisoformat("2026-06-16T12:00:00").timestamp()
)
def test_added_timestamp_falls_back_to_mtime(self, movie_file):
movie_file.added = None
import os
expected = os.stat(movie_file.file_path).st_mtime
assert movie_file.added_timestamp() == expected
def test_set_attribute_persists_and_in_context(self, movie_file):
movie_file.set_attribute("collection_sort", "03")
assert movie_file.attributes["collection_sort"] == "03"
assert movie_file.name_context()["collection_sort"] == "03"
# reload (from index) keeps it
reloaded = File(
movie_file.file_path, movie_file.tagmanager, index=movie_file.index
)
assert reloaded.attributes["collection_sort"] == "03"
def test_set_attribute_empty_removes_it(self, movie_file):
movie_file.set_attribute("collection_sort", "03")
movie_file.set_attribute("collection_sort", None)
assert "collection_sort" not in movie_file.attributes
def test_attribute_usable_in_filename_template(self, movie_file):
movie_file.title = "Dr. No"
movie_file.set_attribute("collection_sort", "01")
ctx = movie_file.name_context()
assert (
"{collection_sort} - {title}{ext}".format(**ctx)
== f"01 - Dr. No{ctx['ext']}"
)
def test_apply_nfo_honors_custom_schema(self, movie_file):
"""A schema without the rating entry produces no Hodnocení tags."""
from src.core.nfo import NfoData
schema = [
{
"category": "Žánr",
"csfd_field": "genres",
"transform": None,
"filmoteka_root": "",
}
]
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
paths = {t.full_path for t in movie_file.tags}
assert "Žánr/Drama" in paths
assert not any(p.startswith("Hodnocení/") for p in paths)
def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
"""Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
from src.core.nfo import NfoData
movie_file.add_tag("Sbírka/Oblíbené") # user tag
movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
paths = {t.full_path for t in movie_file.tags}
assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new NFO tag
assert "Rok/2009" in paths
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Rok/1999" not in paths
File diff suppressed because it is too large Load Diff
+989
View File
@@ -0,0 +1,989 @@
import os
import pytest
from src.core.file import File
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
from src.core.tag import Tag
from src.core.tag_manager import TagManager
class TestHardlinkManager:
"""Testy pro HardlinkManager"""
@pytest.fixture
def tag_manager(self):
"""Fixture pro TagManager"""
tm = TagManager()
# Remove default tags for cleaner tests
for cat in list(tm.tags_by_category.keys()):
tm.remove_category(cat)
return tm
@pytest.fixture
def temp_source_dir(self, tmp_path):
"""Fixture pro zdrojovou složku s testovacími soubory"""
source_dir = tmp_path / "source"
source_dir.mkdir()
(source_dir / "file1.txt").write_text("content1")
(source_dir / "file2.txt").write_text("content2")
(source_dir / "file3.txt").write_text("content3")
return source_dir
@pytest.fixture
def temp_output_dir(self, tmp_path):
"""Fixture pro výstupní složku"""
output_dir = tmp_path / "output"
output_dir.mkdir()
return output_dir
@pytest.fixture
def files_with_tags(self, temp_source_dir, tag_manager):
"""Fixture pro soubory s tagy"""
files = []
# File 1 with multiple tags
f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear() # ensure a clean tag set
f1.add_tag(Tag("žánr", "Komedie"))
f1.add_tag(Tag("žánr", "Akční"))
f1.add_tag(Tag("rok", "1988"))
files.append(f1)
# File 2 with one tag
f2 = File(temp_source_dir / "file2.txt", tag_manager)
f2.tags.clear() # ensure a clean tag set
f2.add_tag(Tag("žánr", "Drama"))
files.append(f2)
# File 3 with no tags
f3 = File(temp_source_dir / "file3.txt", tag_manager)
f3.tags.clear() # ensure a clean tag set
files.append(f3)
return files
def test_hardlink_manager_creation(self, temp_output_dir):
"""Test vytvoření HardlinkManager"""
manager = HardlinkManager(temp_output_dir)
assert manager.output_dir == temp_output_dir
assert manager.created_links == []
assert manager.errors == []
def test_create_structure_basic(self, files_with_tags, temp_output_dir):
"""Test základního vytvoření struktury"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files(files_with_tags)
# File1 has 3 tags, File2 has 1 tag, File3 has 0 tags
# Should create 4 hardlinks total
assert success == 4
assert fail == 0
# Check directory structure
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
assert (temp_output_dir / "žánr" / "Akční" / "file1.txt").exists()
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
def test_hardlinks_are_same_inode(
self, files_with_tags, temp_output_dir, temp_source_dir
):
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(files_with_tags)
original = temp_source_dir / "file1.txt"
hardlink = temp_output_dir / "žánr" / "Komedie" / "file1.txt"
# Same inode = hardlink
assert original.stat().st_ino == hardlink.stat().st_ino
def test_create_structure_with_category_filter(
self, files_with_tags, temp_output_dir
):
"""Test vytvoření struktury jen pro vybrané kategorie"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files(
files_with_tags, categories=["žánr"]
)
# Only "žánr" tags should be processed (3 links)
assert success == 3
assert fail == 0
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
assert not (temp_output_dir / "rok").exists()
def test_create_structure_with_category_roots(
self, files_with_tags, temp_output_dir
):
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
manager = HardlinkManager(temp_output_dir)
roots = {"žánr": "", "rok": "Dle roku"}
manager.create_structure_for_files(files_with_tags, category_roots=roots)
# Genres directly at the output root (no "žánr" wrapper folder)
assert (temp_output_dir / "Komedie" / "file1.txt").exists()
assert (temp_output_dir / "Akční" / "file1.txt").exists()
assert (temp_output_dir / "Drama" / "file2.txt").exists()
assert not (temp_output_dir / "žánr").exists()
# Rok grouped under its own "Dle roku" folder
assert (temp_output_dir / "Dle roku" / "1988" / "file1.txt").exists()
def test_sync_with_roots_leaves_unmanaged_mirror_untouched(
self, files_with_tags, temp_source_dir, temp_output_dir
):
"""Cleanup must not delete links in a copy-as-is mirror (e.g. Seriály)."""
manager = HardlinkManager(temp_output_dir)
roots = {"žánr": "", "rok": "Dle roku"}
manager.create_structure_for_files(files_with_tags, category_roots=roots)
# Simulate a copy-as-is mirror holding a hardlink to a source file
mirror = temp_output_dir / "Seriály"
mirror.mkdir()
mirror_link = mirror / "file1.txt"
os.link(temp_source_dir / "file1.txt", mirror_link)
manager.sync_structure(
files_with_tags, category_roots=roots, reserved_subfolders={"Seriály"}
)
# The mirror (reserved folder) is left alone
assert mirror_link.exists()
def test_sync_removes_orphan_link_from_replaced_movie(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""A stale link pointing at a no-longer-pooled inode is cleaned up."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Drama")) # a root-level category enables the sweep
f.add_tag(Tag("rok", "1968"))
roots = {"žánr": "", "rok": "- Dle roku"}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure([f], category_roots=roots)
# Simulate a leftover from an OLD layout: a different file (different
# inode, not in the pool) hardlinked under an old unprefixed folder.
orphan_src = temp_source_dir / "orphan.txt"
orphan_src.write_text("old data")
old_dir = temp_output_dir / "Dle roku" / "1968"
old_dir.mkdir(parents=True)
os.link(orphan_src, old_dir / "file1.txt")
manager.sync_structure([f], category_roots=roots)
# new prefixed folder kept, stale unprefixed one (orphan) removed
assert (temp_output_dir / "- Dle roku" / "1968" / "file1.txt").exists()
assert not (temp_output_dir / "Dle roku").exists()
def test_sync_replaces_orphan_squatting_the_expected_name(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""An orphan link with the *expected name* but a stale inode is replaced.
Regression: when a movie is re-imported (new inode), the old hardlink
keeps the correct name but points at the dead inode. Name-only obsolete
detection spared it and forced the real link to a ``_1`` suffix, which
was then swept every run — an endless created==removed churn. Obsolete
detection now matches the inode too, so the orphan is removed and the
base name is restored, stably.
"""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Drama"))
roots = {"žánr": ""}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure([f], category_roots=roots)
link = temp_output_dir / "Drama" / "file1.txt"
assert link.stat().st_ino == f.file_path.stat().st_ino
# Simulate a re-import: the expected name now points at a *different*
# (orphan) inode, exactly as a replaced movie leaves behind.
orphan_src = temp_source_dir / "orphan.txt"
orphan_src.write_text("old content")
link.unlink()
os.link(orphan_src, link)
assert link.stat().st_ino != f.file_path.stat().st_ino
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
# The orphan was swept and the correct link recreated under the base name.
assert removed >= 1 and created >= 1
assert link.stat().st_ino == f.file_path.stat().st_ino
assert not (temp_output_dir / "Drama" / "file1_1.txt").exists()
# And it is now stable: a second run touches nothing (no churn).
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
assert (created2, removed2) == (0, 0)
def test_link_name_strips_year_by_default(self, tmp_path, tag_manager):
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
source = tmp_path / "source"
source.mkdir()
(source / "Solaris (1972).mkv").write_text("x")
f = File(source / "Solaris (1972).mkv", tag_manager)
f.tags.clear()
f.title = "Solaris"
f.add_tag(Tag("žánr", "Sci-Fi"))
output = tmp_path / "output"
manager = HardlinkManager(output)
manager.create_structure_for_files([f], category_roots={"žánr": ""})
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager):
"""A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'."""
source = tmp_path / "source"
source.mkdir()
(source / "Matrix (1999) [tmdbid-603].mkv").write_text("x")
f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Sci-Fi"))
output = tmp_path / "output"
manager = HardlinkManager(output)
manager.create_structure_for_files([f], category_roots={"žánr": ""})
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
def test_link_name_keeps_year_on_collision(self, tmp_path, tag_manager):
"""Two same-title films in one folder keep the year to disambiguate."""
source = tmp_path / "source"
source.mkdir()
(source / "Solaris (1972).mkv").write_text("a")
(source / "Solaris (2002).mkv").write_text("b")
f1 = File(source / "Solaris (1972).mkv", tag_manager)
f1.tags.clear()
f1.title = "Solaris"
f1.meta = {"year": 1972}
f1.add_tag(Tag("žánr", "Sci-Fi"))
f1.add_tag(Tag("rok", "1972"))
f2 = File(source / "Solaris (2002).mkv", tag_manager)
f2.tags.clear()
f2.title = "Solaris"
f2.meta = {"year": 2002}
f2.add_tag(Tag("žánr", "Sci-Fi"))
f2.add_tag(Tag("rok", "2002"))
output = tmp_path / "output"
manager = HardlinkManager(output)
roots = {"žánr": "", "rok": "Dle roku"}
manager.create_structure_for_files([f1, f2], category_roots=roots)
# Same genre folder → clash → both keep the year
assert (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
assert (output / "Sci-Fi" / "Solaris (2002).mkv").exists()
assert not (output / "Sci-Fi" / "Solaris.mkv").exists()
# Separate year folders → no clash → clean name
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
def test_clean_naming_cleanup_is_consistent(self, tmp_path, tag_manager):
"""Clean-named links are stable: a second sync removes/creates nothing."""
source = tmp_path / "source"
source.mkdir()
(source / "Solaris (1972).mkv").write_text("x")
f = File(source / "Solaris (1972).mkv", tag_manager)
f.tags.clear()
f.title = "Solaris"
f.add_tag(Tag("žánr", "Sci-Fi"))
roots = {"žánr": ""}
output = tmp_path / "output"
manager = HardlinkManager(output)
manager.sync_structure([f], category_roots=roots)
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
assert (created, removed) == (0, 0)
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""A genre folder at the output root is cleaned when no movie has it."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Akční"))
roots = {"žánr": ""}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure([f], category_roots=roots)
assert (temp_output_dir / "Akční").is_dir()
# last movie with the tag loses it → folder must go
f.tags.clear()
manager.sync_structure([f], category_roots=roots)
assert not (temp_output_dir / "Akční").exists()
def test_category_transform_groups_folder_by_band(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Exact tag value, but the folder name goes through the transform."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("Hodnocení", "90")) # exact rating tag
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(
[f],
category_roots={"Hodnocení": "Dle hodnocení"},
category_transforms={"Hodnocení": "decade_band"},
)
# folder is the band; the file lands inside it
assert (temp_output_dir / "Dle hodnocení" / "90–100 %" / "file1.txt").exists()
# not a per-exact-value folder
assert not (temp_output_dir / "Dle hodnocení" / "90").exists()
def test_filename_template_applies_only_in_that_category(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""A per-category template renames the hardlink only inside its folder."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.title = "Dr. No"
f.meta = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond"))
f.add_tag(Tag("žánr", "Akční"))
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(
[f],
category_roots={"Kolekce": "Dle kolekce", "žánr": ""},
category_filename_templates={"Kolekce": "{year} - {title}{ext}"},
)
# Templated name inside the collection folder
assert (
temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt"
).exists()
# Other categories use the default clean title (no year, not the pool name)
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
def test_filename_template_cleanup_is_consistent(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""sync twice with a template leaves no stale/duplicate templated link."""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.title = "Dr. No"
f.meta = {"year": 1962}
f.add_tag(Tag("Kolekce", "James Bond"))
roots = {"Kolekce": "Dle kolekce"}
templates = {"Kolekce": "{year} - {title}{ext}"}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure(
[f], category_roots=roots, category_filename_templates=templates
)
_created, _, removed, _ = manager.sync_structure(
[f], category_roots=roots, category_filename_templates=templates
)
folder = temp_output_dir / "Dle kolekce" / "James Bond"
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
assert removed == 0 # nothing treated as obsolete on the second run
def test_generate_random_tips_creates_hardlinks(
self, files_with_tags, temp_output_dir
):
"""Tips folder gets `count` random pool files as hardlinks."""
manager = HardlinkManager(temp_output_dir)
created, fail = manager.generate_random_tips(files_with_tags, count=2)
tips = temp_output_dir / "Tipy dne"
assert created == 2
assert fail == 0
links = sorted(p.name for p in tips.iterdir())
assert len(links) == 2
# each is a hardlink to one of the source files
srcs = {f.filename for f in files_with_tags}
assert set(links) <= srcs
def test_generate_random_tips_caps_at_available(
self, files_with_tags, temp_output_dir
):
"""Asking for more than available yields all of them, no error."""
manager = HardlinkManager(temp_output_dir)
created, _ = manager.generate_random_tips(files_with_tags, count=99)
assert created == len(files_with_tags)
def test_generate_random_tips_refreshes_each_run(
self, files_with_tags, temp_output_dir
):
"""A second run replaces the previous selection (folder is emptied)."""
manager = HardlinkManager(temp_output_dir)
manager.generate_random_tips(files_with_tags, count=1)
manager.generate_random_tips(files_with_tags, count=1)
tips = temp_output_dir / "Tipy dne"
assert len(list(tips.iterdir())) == 1 # not accumulated to 2
def test_generate_recently_added_orders_by_added(
self, files_with_tags, temp_output_dir
):
"""The newest-added movies (by added_timestamp) fill the folder."""
# explicit added timestamps: file2 newest, file1 middle, file3 oldest
files_with_tags[0].added = "2026-01-02T00:00:00"
files_with_tags[1].added = "2026-01-03T00:00:00"
files_with_tags[2].added = "2026-01-01T00:00:00"
manager = HardlinkManager(temp_output_dir)
created, fail = manager.generate_recently_added(
files_with_tags, count=2, subfolder="- Nově přidané"
)
recent = temp_output_dir / "- Nově přidané"
assert created == 2 and fail == 0
names = {p.name for p in recent.iterdir()}
assert names == {"file2.txt", "file1.txt"} # newest two, file3 excluded
def test_dry_run(self, files_with_tags, temp_output_dir):
"""Test dry run (bez skutečného vytváření)"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files(
files_with_tags, dry_run=True
)
assert success == 4
assert fail == 0
# No actual files should be created
assert not (temp_output_dir / "žánr").exists()
def test_get_preview(self, files_with_tags, temp_output_dir):
"""Test náhledu co bude vytvořeno"""
manager = HardlinkManager(temp_output_dir)
preview = manager.get_preview(files_with_tags)
assert len(preview) == 4
# Check that preview contains expected paths
targets = [p[1] for p in preview]
assert temp_output_dir / "žánr" / "Komedie" / "file1.txt" in targets
assert temp_output_dir / "žánr" / "Drama" / "file2.txt" in targets
def test_get_preview_with_category_filter(self, files_with_tags, temp_output_dir):
"""Test náhledu s filtrem kategorií"""
manager = HardlinkManager(temp_output_dir)
preview = manager.get_preview(files_with_tags, categories=["rok"])
assert len(preview) == 1
assert preview[0][1] == temp_output_dir / "rok" / "1988" / "file1.txt"
def test_remove_created_links(self, files_with_tags, temp_output_dir):
"""Test odstranění vytvořených hardlinků"""
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(files_with_tags)
# Verify links exist
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
# Remove links
removed = manager.remove_created_links()
assert removed == 4
# Links should be gone
assert not (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
# Empty directories should also be removed
assert not (temp_output_dir / "žánr" / "Komedie").exists()
def test_empty_files_list(self, temp_output_dir):
"""Test s prázdným seznamem souborů"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files([])
assert success == 0
assert fail == 0
def test_files_without_tags(self, temp_source_dir, temp_output_dir, tag_manager):
"""Test se soubory bez tagů"""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear() # Remove default tags
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files([f1])
assert success == 0
assert fail == 0
def test_duplicate_link_same_file(self, files_with_tags, temp_output_dir):
"""Test že existující hardlink na stejný soubor je přeskočen"""
manager = HardlinkManager(temp_output_dir)
# Create first time
_success1, _ = manager.create_structure_for_files(files_with_tags)
# Create second time - should skip existing
manager2 = HardlinkManager(temp_output_dir)
success2, fail2 = manager2.create_structure_for_files(files_with_tags)
# All should be skipped (same inode)
assert success2 == 0
assert fail2 == 0
def test_unique_name_on_conflict(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Two different movies with the same clean name (no year to tell them
apart) in one folder get distinct links via a numeric suffix."""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear()
f1.add_tag(Tag("test", "tag"))
source2 = temp_source_dir / "subdir"
source2.mkdir()
(source2 / "file1.txt").write_text("different content")
f2 = File(source2 / "file1.txt", tag_manager)
f2.tags.clear()
f2.add_tag(Tag("test", "tag"))
# One generation over both files → the plan disambiguates the clash.
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files([f1, f2])
assert success == 2 and fail == 0
folder = temp_output_dir / "test" / "tag"
assert (folder / "file1.txt").exists()
assert (folder / "file1_2.txt").exists()
def test_czech_characters_in_tags(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Test českých znaků v názvech tagů"""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear()
f1.add_tag(Tag("Žánr", "Česká komedie"))
f1.add_tag(Tag("Štítky", "Příběh"))
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files([f1])
assert success == 2
assert fail == 0
assert (temp_output_dir / "Žánr" / "Česká komedie" / "file1.txt").exists()
assert (temp_output_dir / "Štítky" / "Příběh" / "file1.txt").exists()
class TestConvenienceFunction:
"""Testy pro convenience funkci create_hardlink_structure"""
@pytest.fixture
def tag_manager(self):
tm = TagManager()
for cat in list(tm.tags_by_category.keys()):
tm.remove_category(cat)
return tm
@pytest.fixture
def temp_files(self, tmp_path, tag_manager):
source = tmp_path / "source"
source.mkdir()
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag"))
return [f]
def test_create_hardlink_structure_function(self, temp_files, tmp_path):
"""Test convenience funkce"""
output = tmp_path / "output"
output.mkdir()
success, fail, errors = create_hardlink_structure(temp_files, output)
assert success == 1
assert fail == 0
assert len(errors) == 0
assert (output / "cat" / "tag" / "file.txt").exists()
def test_create_hardlink_structure_with_categories(self, tmp_path, tag_manager):
"""Test convenience funkce s filtrem kategorií"""
source = tmp_path / "source"
source.mkdir()
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("include", "yes"))
f.add_tag(Tag("exclude", "no"))
output = tmp_path / "output"
output.mkdir()
success, _fail, _errors = create_hardlink_structure(
[f], output, categories=["include"]
)
assert success == 1
assert (output / "include" / "yes" / "file.txt").exists()
assert not (output / "exclude").exists()
class TestSyncStructure:
"""Testy pro synchronizaci hardlink struktury"""
@pytest.fixture
def tag_manager(self):
tm = TagManager()
for cat in list(tm.tags_by_category.keys()):
tm.remove_category(cat)
return tm
@pytest.fixture
def setup_dirs(self, tmp_path):
source = tmp_path / "source"
source.mkdir()
output = tmp_path / "output"
output.mkdir()
return source, output
def test_find_obsolete_links_empty_output(self, setup_dirs, tag_manager):
"""Test find_obsolete_links s prázdným výstupem"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag"))
manager = HardlinkManager(output)
obsolete = manager.find_obsolete_links([f])
assert obsolete == []
def test_find_obsolete_links_detects_removed_tag(self, setup_dirs, tag_manager):
"""Test že find_obsolete_links najde hardlink pro odebraný tag"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
f.add_tag(Tag("cat", "tag2"))
# Create structure with both tags
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
assert (output / "cat" / "tag1" / "file.txt").exists()
assert (output / "cat" / "tag2" / "file.txt").exists()
# Remove one tag from file
f.tags.clear()
f.add_tag(Tag("cat", "tag1")) # Only tag1 remains
# Find obsolete
obsolete = manager.find_obsolete_links([f])
assert len(obsolete) == 1
assert obsolete[0][0] == output / "cat" / "tag2" / "file.txt"
def test_remove_obsolete_links(self, setup_dirs, tag_manager):
"""Test odstranění zastaralých hardlinků"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
f.add_tag(Tag("cat", "tag2"))
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
# Remove tag2
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
# Remove obsolete links
removed, _paths = manager.remove_obsolete_links([f])
assert removed == 1
assert not (output / "cat" / "tag2" / "file.txt").exists()
assert (output / "cat" / "tag1" / "file.txt").exists()
def test_remove_obsolete_links_dry_run(self, setup_dirs, tag_manager):
"""Test dry run pro remove_obsolete_links"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
f.add_tag(Tag("cat", "tag2"))
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
removed, _paths = manager.remove_obsolete_links([f], dry_run=True)
assert removed == 1
# File should still exist (dry run)
assert (output / "cat" / "tag2" / "file.txt").exists()
def test_sync_structure_creates_and_removes(self, setup_dirs, tag_manager):
"""Test sync_structure vytvoří nové a odstraní staré hardlinky"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "old_tag"))
# Create initial structure
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
assert (output / "cat" / "old_tag" / "file.txt").exists()
# Change tags
f.tags.clear()
f.add_tag(Tag("cat", "new_tag"))
# Sync
created, c_fail, removed, r_fail = manager.sync_structure([f])
assert created == 1
assert removed == 1
assert c_fail == 0
assert r_fail == 0
assert not (output / "cat" / "old_tag").exists()
assert (output / "cat" / "new_tag" / "file.txt").exists()
def test_sync_structure_no_changes_needed(self, setup_dirs, tag_manager):
"""Test sync_structure když není potřeba žádná změna"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag"))
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
# Sync again without changes
_created, _c_fail, removed, _r_fail = manager.sync_structure([f])
# Nothing should change (existing links are skipped)
assert removed == 0
assert (output / "cat" / "tag" / "file.txt").exists()
def test_find_obsolete_with_category_filter(self, setup_dirs, tag_manager):
"""Test find_obsolete_links s filtrem kategorií"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat1", "tag"))
f.add_tag(Tag("cat2", "tag"))
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
# Remove both tags
f.tags.clear()
# Find obsolete only in cat1
obsolete = manager.find_obsolete_links([f], categories=["cat1"])
assert len(obsolete) == 1
assert obsolete[0][0] == output / "cat1" / "tag" / "file.txt"
def test_removes_empty_directories(self, setup_dirs, tag_manager):
"""Test že prázdné adresáře jsou odstraněny po sync"""
source, output = setup_dirs
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("category", "tag"))
manager = HardlinkManager(output)
manager.create_structure_for_files([f])
# Remove all tags
f.tags.clear()
manager.remove_obsolete_links([f])
# Directory should be gone
assert not (output / "category" / "tag").exists()
assert not (output / "category").exists()
class TestEdgeCases:
"""Testy pro okrajové případy"""
@pytest.fixture
def tag_manager(self):
tm = TagManager()
for cat in list(tm.tags_by_category.keys()):
tm.remove_category(cat)
return tm
def test_nonexistent_output_dir_created(self, tmp_path, tag_manager):
"""Test že výstupní složka je vytvořena pokud neexistuje"""
source = tmp_path / "source"
source.mkdir()
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag"))
output = tmp_path / "output" / "nested" / "deep"
# output doesn't exist
manager = HardlinkManager(output)
success, _fail = manager.create_structure_for_files([f])
assert success == 1
assert (output / "cat" / "tag" / "file.txt").exists()
def test_special_characters_in_filename(self, tmp_path, tag_manager):
"""Test souboru se speciálními znaky v názvu"""
source = tmp_path / "source"
source.mkdir()
# Parentheses that are NOT a four-digit year must survive verbatim
# (a trailing "(YYYY)" would be stripped by the naming rule).
(source / "file with spaces (part 2).txt").write_text("content")
f = File(source / "file with spaces (part 2).txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("test", "tag"))
output = tmp_path / "output"
output.mkdir()
manager = HardlinkManager(output)
success, _fail = manager.create_structure_for_files([f])
assert success == 1
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
def test_empty_category_filter(self, tmp_path, tag_manager):
"""Test s prázdným seznamem kategorií"""
source = tmp_path / "source"
source.mkdir()
(source / "file.txt").write_text("content")
f = File(source / "file.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("cat", "tag"))
output = tmp_path / "output"
output.mkdir()
manager = HardlinkManager(output)
# Empty list = no categories = no links
success, _fail = manager.create_structure_for_files([f], categories=[])
assert success == 0
def test_is_same_file_method(self, tmp_path):
"""Test metody _is_same_file"""
file1 = tmp_path / "file1.txt"
file1.write_text("content")
link = tmp_path / "link.txt"
os.link(file1, link)
file2 = tmp_path / "file2.txt"
file2.write_text("different")
manager = HardlinkManager(tmp_path)
# Same inode
assert manager._is_same_file(file1, link) is True
# Different inode
assert manager._is_same_file(file1, file2) is False
# Non-existent file
assert manager._is_same_file(file1, tmp_path / "nonexistent") is False
def test_get_unique_name_method(self, tmp_path):
"""Test metody _get_unique_name"""
(tmp_path / "file.txt").write_text("1")
(tmp_path / "file_1.txt").write_text("2")
(tmp_path / "file_2.txt").write_text("3")
manager = HardlinkManager(tmp_path)
unique = manager._get_unique_name(tmp_path / "file.txt")
assert unique == tmp_path / "file_3.txt"
class TestMirrorAsIs:
"""Testy pro copy-as-is zrcadlení (Seriály)"""
def test_mirror_clones_hierarchy_with_hardlinks(self, tmp_path):
"""Adresářová struktura se zrcadlí 1:1 a soubory jsou hardlinky"""
source = tmp_path / "Seriály"
(source / "Show" / "S01").mkdir(parents=True)
ep1 = source / "Show" / "S01" / "ep1.mkv"
ep2 = source / "Show" / "S01" / "ep2.mkv"
ep1.write_text("a")
ep2.write_text("b")
output = tmp_path / "out"
manager = HardlinkManager(output)
created, failed = manager.mirror_as_is(source, "Seriály")
assert failed == 0
assert created == 2
linked = output / "Seriály" / "Show" / "S01" / "ep1.mkv"
assert linked.exists()
assert linked.stat().st_ino == ep1.stat().st_ino
def test_mirror_skips_curator_metadata(self, tmp_path):
"""Metadata soubory (.!tag, .!index) se nezrcadlí"""
source = tmp_path / "Seriály"
source.mkdir()
(source / "ep1.mkv").write_text("a")
(source / ".ep1.mkv.!tag").write_text("{}")
(source / ".Curator.!index").write_text("{}")
output = tmp_path / "out"
manager = HardlinkManager(output)
created, failed = manager.mirror_as_is(source, "Seriály")
assert created == 1
assert failed == 0
assert not (output / "Seriály" / ".ep1.mkv.!tag").exists()
def test_mirror_nonexistent_source_is_noop(self, tmp_path):
"""Neexistující zdroj nic neudělá"""
manager = HardlinkManager(tmp_path / "out")
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
class TestRemoveMirror:
"""Removing a whole mirrored library (or one bucket entry) from the output."""
def test_removes_a_whole_mirrored_folder(self, tmp_path):
output = tmp_path / "out"
(output / "Dokumenty" / "sub").mkdir(parents=True)
(output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x")
manager = HardlinkManager(output)
assert manager.remove_mirror("Dokumenty") is True
assert not (output / "Dokumenty").exists()
def test_removes_one_bucket_entry_by_compound_name(self, tmp_path):
output = tmp_path / "out"
(output / "Seriály" / "Show A").mkdir(parents=True)
(output / "Seriály" / "Show B").mkdir(parents=True)
manager = HardlinkManager(output)
assert manager.remove_mirror("Seriály/Show A") is True
assert not (output / "Seriály" / "Show A").exists()
assert (output / "Seriály" / "Show B").exists() # sibling untouched
def test_missing_target_returns_false(self, tmp_path):
manager = HardlinkManager(tmp_path / "out")
assert manager.remove_mirror("Nope") is False
+164
View File
@@ -0,0 +1,164 @@
"""Tests for video integrity checking (src.core.integrity).
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
installed and don't touch real video data.
"""
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
from src.core import integrity
from src.core.integrity import IntegrityResult, check_video_integrity
@pytest.fixture
def video_file(tmp_path) -> Path:
f = tmp_path / "movie.mkv"
f.write_bytes(b"not really a video, but a real file")
return f
def _fake_run(returncode=0, stdout="", stderr=""):
def run(cmd, **kwargs):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
return run
def test_deep_clean_file_is_ok(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
result = check_video_integrity(video_file, deep=True)
assert result.ok and result.checked and result.error == ""
def test_deep_reports_decode_errors(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess,
"run",
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"),
)
result = check_video_integrity(video_file, deep=True)
assert not result.ok and result.checked
assert result.error == "[matroska @ 0x..] Read error" # first line only
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
result = check_video_integrity(video_file, deep=True)
assert not result.ok
def test_quick_ok_when_video_stream_present(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "video\n", ""))
result = check_video_integrity(video_file, deep=False)
assert result.ok
def test_quick_fails_without_video_stream(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
result = check_video_integrity(video_file, deep=False)
assert not result.ok and "video stream" in result.error.lower()
def test_missing_file_is_unchecked_not_corrupt(monkeypatch, tmp_path):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
result = check_video_integrity(tmp_path / "nope.mkv", deep=True)
assert not result.ok and not result.checked and "neexistuje" in result.error
def test_missing_tool_is_unchecked(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
result = check_video_integrity(video_file, deep=True)
assert not result.ok and not result.checked
assert "ffmpeg" in result.error
def test_timeout_reports_failure(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
def raise_timeout(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd, 1)
monkeypatch.setattr(integrity.subprocess, "run", raise_timeout)
result = check_video_integrity(video_file, deep=True, timeout=1)
assert not result.ok and result.checked and "timeout" in result.error.lower()
def test_ffmpeg_available_reflects_which(monkeypatch):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
assert integrity.ffmpeg_available(deep=True)
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
assert not integrity.ffmpeg_available(deep=True)
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
"""FileManager.scan_video_integrity aggregates per-file results."""
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
good = tmp_path / "good.mkv"
bad = tmp_path / "bad.mkv"
good.write_bytes(b"x")
bad.write_bytes(b"y")
tm = TagManager()
fm = FileManager(tm)
files = [File(good, tm), File(bad, tm)]
def fake_check(path, deep=True):
ok = Path(path).name == "good.mkv"
return IntegrityResult(Path(path), ok=ok, error="" if ok else "broken")
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
monkeypatch.setattr("src.core.file_manager.check_video_integrity", fake_check)
seen: list[int] = []
result = fm.scan_video_integrity(
files, deep=True, on_progress=lambda d, t, f: seen.append(d)
)
assert result["ok"] and result["checked"] == 2
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
assert seen == [1, 2]
def test_scan_stops_on_cancel(monkeypatch, tmp_path):
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
files = []
for i in range(3):
p = tmp_path / f"m{i}.mkv"
p.write_bytes(b"x")
files.append(File(p, TagManager()))
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
monkeypatch.setattr(
"src.core.file_manager.check_video_integrity",
lambda path, deep=True: IntegrityResult(Path(path), ok=True),
)
fm = FileManager(TagManager())
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
assert result["cancelled"] and result["checked"] == 0
def test_scan_reports_missing_ffmpeg(monkeypatch, tmp_path):
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
fm = FileManager(TagManager())
result = fm.scan_video_integrity([], deep=True)
assert not result["ok"] and "ffmpeg" in result["error"]
+123
View File
@@ -0,0 +1,123 @@
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
from types import SimpleNamespace
import pytest
from src.core import jellyfin
from src.core.jellyfin import JellyfinError, JellyfinMovie
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setenv("JELLYFIN_URL", "http://jf:8096/")
monkeypatch.setenv("JELLYFIN_API_KEY", "key")
def _resp(status=200, payload=None):
return SimpleNamespace(
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
)
def test_base_url_strips_trailing_slash():
assert jellyfin.base_url() == "http://jf:8096"
def test_api_available(monkeypatch):
assert jellyfin.api_available()
monkeypatch.delenv("JELLYFIN_API_KEY", raising=False)
assert not jellyfin.api_available()
def test_server_version(monkeypatch):
monkeypatch.setattr(
jellyfin.requests, "request", lambda *a, **k: _resp(200, {"Version": "10.9.11"})
)
assert jellyfin.server_version() == "10.9.11"
def test_scan_all_posts_refresh(monkeypatch):
seen = {}
def fake(method, url, **k):
seen["method"], seen["url"] = method, url
return _resp(200, {})
monkeypatch.setattr(jellyfin.requests, "request", fake)
jellyfin.scan_all()
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
def test_fetch_movies_maps_tmdb_id(monkeypatch):
payload = {
"Items": [
{
"Id": "a1",
"Name": "Matrix",
"ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"},
"Path": "/m/Matrix (1999) [tmdbid-603].mkv",
},
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
]
}
monkeypatch.setattr(
jellyfin.requests, "request", lambda *a, **k: _resp(200, payload)
)
movies = jellyfin.fetch_movies()
assert movies[0] == JellyfinMovie(
"a1", "Matrix", 1999, 603, "/m/Matrix (1999) [tmdbid-603].mkv"
)
assert movies[1].tmdb_id is None
def test_missing_config_raises(monkeypatch):
monkeypatch.delenv("JELLYFIN_URL", raising=False)
with pytest.raises(JellyfinError):
jellyfin.server_version()
def test_bad_auth_raises(monkeypatch):
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(401))
with pytest.raises(JellyfinError):
jellyfin.scan_all()
class TestVerifyAgainstJellyfin:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_match_by_tmdb_id_and_report_gaps(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"y")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
file_manager.import_movie(tmp_path / "b.mkv", "Neznámý", year=2020) # no id
jf = [
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
JellyfinMovie("j2", "Cizí film", 2011, 999, None), # jellyfin-only
]
result = file_manager.verify_against_jellyfin(jf)
assert result["matched"] == 1
assert result["pool_only"] == ["Neznámý (2020).mkv"]
assert result["jellyfin_only"] == ["Cizí film"]
assert result["pool_total"] == 2 and result["jellyfin_total"] == 2
def test_match_by_title_year_fallback(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
file_manager.import_movie(tmp_path / "a.mkv", "Kolja", year=1996) # no id
jf = [JellyfinMovie("j1", "Kolja", 1996, None, None)]
result = file_manager.verify_against_jellyfin(jf)
assert result["matched"] == 1 and result["pool_only"] == []
+142
View File
@@ -0,0 +1,142 @@
"""Tests for recovering ČSFD link / title / year from pre-index layouts."""
import json
from src.core.legacy_meta import LegacyMeta, read_legacy_meta
def _video(tmp_path, name="Matrix (1999).mkv"):
v = tmp_path / name
v.write_bytes(b"x")
return v
def test_reads_tagger_era_sidecar(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps(
{
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999, "rating": 90},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta == LegacyMeta(
csfd_url="https://www.csfd.cz/film/9499-matrix/",
title="Matrix",
year="1999",
source="sidecar",
)
def test_sidecar_new_key_and_meta_block(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps(
{
"title": "Matrix",
"csfd_url": "https://www.csfd.cz/film/9499/",
"meta": {"year": "1999"},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
assert meta.year == "1999"
def test_reads_old_pool_index_by_relative_key(tmp_path):
pool = tmp_path / "old-pool"
movies_dir = pool / "Filmy"
movies_dir.mkdir(parents=True)
video = _video(movies_dir)
(pool / ".Curator.!index").write_text(
json.dumps(
{
"settings": {},
"movies": {
"Filmy/Matrix (1999).mkv": {
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999},
}
},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
assert meta.title == "Matrix"
assert meta.year == "1999"
assert meta.source == "index"
def test_index_fallback_to_unique_basename_match(tmp_path):
pool = tmp_path / "pool"
pool.mkdir()
elsewhere = tmp_path / "loose"
elsewhere.mkdir()
video = _video(elsewhere)
(pool.parent / ".Curator.!index").write_text(
json.dumps(
{
"movies": {
"whatever/Matrix (1999).mkv": {
"csfd_url": "https://www.csfd.cz/film/9499/"
}
},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta is not None
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
def test_sidecar_wins_over_index(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8"
)
(tmp_path / ".Curator.!index").write_text(
json.dumps(
{
"movies": {
"Matrix (1999).mkv": {
"csfd_link": "https://www.csfd.cz/film/INDEX/"
}
},
}
),
encoding="utf-8",
)
assert read_legacy_meta(video).csfd_url == "https://www.csfd.cz/film/SIDECAR/"
def test_none_when_no_metadata(tmp_path):
assert read_legacy_meta(_video(tmp_path)) is None
def test_none_on_broken_sidecar(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text("{not json", encoding="utf-8")
assert read_legacy_meta(video) is None
def test_record_without_useful_fields_is_none(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8"
)
assert read_legacy_meta(video) is None
+76
View File
@@ -0,0 +1,76 @@
import tempfile
import tkinter as tk
from pathlib import Path
import pytest
from PIL import Image, ImageTk
from src.core.media_utils import load_icon
@pytest.fixture(scope="module")
def tk_root():
"""Fixture pro inicializaci Tkinteru (nutné pro ImageTk)."""
root = tk.Tk()
yield root
root.destroy()
def test_load_icon_returns_photoimage(tk_root):
"""Test že load_icon vrací PhotoImage"""
# vytvoříme dočasný obrázek
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme 100x100 červený obrázek
img = Image.new("RGB", (100, 100), color="red")
img.save(tmp_path)
icon = load_icon(tmp_path)
# musí být PhotoImage
assert isinstance(icon, ImageTk.PhotoImage)
# ověříme velikost 16x16
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)
def test_load_icon_resizes_image(tk_root):
"""Test že load_icon správně změní velikost obrázku"""
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# vytvoříme velký obrázek 500x500
img = Image.new("RGB", (500, 500), color="blue")
img.save(tmp_path)
icon = load_icon(tmp_path)
# i velký obrázek by měl být zmenšen na 16x16
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)
def test_load_icon_different_formats(tk_root):
"""Test načítání různých formátů obrázků"""
formats = [".png", ".jpg", ".bmp"]
for fmt in formats:
with tempfile.NamedTemporaryFile(suffix=fmt, delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
img = Image.new("RGB", (32, 32), color="green")
img.save(tmp_path)
icon = load_icon(tmp_path)
assert isinstance(icon, ImageTk.PhotoImage)
assert icon.width() == 16
assert icon.height() == 16
finally:
tmp_path.unlink(missing_ok=True)

Some files were not shown because too many files have changed in this diff Show More