Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping

This commit is contained in:
2026-09-13 17:16:48 +02:00
parent c0ad1c1553
commit c399a6aed3
26 changed files with 4635 additions and 221 deletions
+186 -6
View File
@@ -21,7 +21,125 @@ Each version entry uses these sections (include only those that apply):
## Unreleased ## Unreleased
### Changed
- **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.
### Added ### Added
- **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`) and **"➖ Odebrat verzi"** (removing down to one version
**demotes back to flat** automatically). 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`. Scoped to `movie`-kind libraries for now —
`mixed`-library grouped movies are a follow-up (their subfolders are still
read as series only).
- **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 - **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 **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 the episodes for a series. Double-click (or the context menu) opens the right
@@ -52,7 +170,67 @@ Each version entry uses these sections (include only those that apply):
imported into a mixed library and show up in the series overview/editor. 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 - **Č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*. import (a column in the movie import dialog) and editable in *Upravit film*.
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD. - **Č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 - **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 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 title + year and proposes an id, filling a missing year from TMDb. Conservative
@@ -71,11 +249,13 @@ Each version entry uses these sections (include only those that apply):
Jellyfin to write NFO before the genre tree is populated. Jellyfin to write NFO before the genre tree is populated.
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz** - **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). (genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext` - **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The Season NN/`. The release string is deliberately dropped — identity is the
`SxxEyy` code is stripped from the original before re-applying, so repeated `SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb
renames don't double it (`series.episode_body`); `episode_filename` / (matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title
`is_canonical_episode` and the series import/rename/edit paths follow. 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 ### Fixed
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the - **Movies can live in mixed libraries.** The import/edit/move dialogs and the
+102 -27
View File
@@ -66,6 +66,16 @@ movie table, and one-click Filmotéka generation.
- **Metadata storage:** one **unified metadata file** for the whole pool (a - **Metadata storage:** one **unified metadata file** for the whole pool (a
central index), not per-file sidecars. Justified because Curator owns the pool 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. 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 - **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir` data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
pointers (which must stay global: the index is found *through* them). Everything pointers (which must stay global: the index is found *through* them). Everything
@@ -182,11 +192,29 @@ library manager**. Agreed shape:
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda` `Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
(homevideo). Kids are **not** a separate folder — see decisions. (homevideo). Kids are **not** a separate folder — see decisions.
- **Naming convention:** - **Naming convention:**
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. - Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext` movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's
(season 0 → `Specials`); the episode keeps its original filename after the own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the
`SxxEyy` code. Episode numbers are recognised from the filename; editable in edit dialog and preserved across every future rename like the tmdbid is.
the series GUI. - **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*); the
table always shows one row per movie (`visible_movies`). Shared metadata
lives on one **primary** version (`File.is_primary_version`), migrated if
that one is ever removed. **Not yet supported:** renaming/moving/retitling
a grouped movie (remove the version or edit files directly), and grouped
movies inside `mixed` libraries (scoped to `movie`-kind for now — a mixed
library's subfolders are still read as series only).
- 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.
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by - **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
`tmdbid` (yearless title search picks the wrong film, hence the earlier `tmdbid` (yearless title search picks the wrong film, hence the earlier
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when "weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
@@ -195,15 +223,45 @@ library manager**. Agreed shape:
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to) - **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall. writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
kept **only** as an optional CZ-title source for the filename (not for tags/ kept **only** as a title source (not for tags/matching — it exposes no
matching — it exposes no TMDb/IMDb id, verified). 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** - **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
(CZ-title source/override); filename hand-editable before/after import. (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, - **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` + but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
`naming.py`), not from ČSFD tags. `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; - **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
key + URL in `.env`. NFO on disk stays authoritative. 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) ### Decisions (locked)
@@ -231,9 +289,9 @@ physical partitions + import-into / move-between [done]; (3) Jellyfin API — sc
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done]; verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing (5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs + pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
library organizer (unified `libraries` model with kinds) [done]. library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs); scanning + generation (movies + series in one folder) [done].
**(9) mixed-library scanning + generation** (movies + series in one folder). Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs).
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok) Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
[tmdbid].ext`); per-movie folders are a later refinement. [tmdbid].ext`); per-movie folders are a later refinement.
@@ -249,28 +307,42 @@ Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
skip the TMDb requirement; `movie` keeps requiring a year (as today). skip the TMDb requirement; `movie` keeps requiring a year (as today).
- `homevideo` library scanning: load its files as items (flat), no TMDb naming, - `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. 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. - Tests: import with no year/id lands correctly; homevideo library loads.
### Phase 9 — mixed-library scanning + generation ### Phase 9 — mixed-library scanning + generation
- Scan a `mixed` library by structure: a top-level **single video file = movie**; - Scanning **[done]**: a `mixed` library's top-level files load as movies
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse (`load_pool_movies`), its subfolders as series (`load_series` /
`series.scan_series` for the series part and the flat movie scan for the rest. `series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`).
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`, Both are kind-aware over the unified `libraries` list. The library filter /
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`. table already surfaces mixed libraries.
- miniDLNA generation + NFO refresh must cover mixed libraries too. - miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as
- The library filter / table should surface mixed libraries once loadable. usual; series in its subfolders have no genre tags, so they're hardcopied
- Tests: a mixed folder with both a movie and a series loads/generates correctly. 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 ### Later / optional
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good - **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the solver, film-page + search parsing, `original_title` extraction — the entry in
filename in the GUI. The ČSFD **link** is already stored per movie `.film-names` whose flag matches the country of origin). `resolve_via_csfd`
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later — drives it from the import dialog and from migration. **Not** a return of ČSFD
revisit fetching only if TMDb proves insufficient in practice. tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files. "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. - Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs - Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
path-aware `library_of` (currently single-segment). path-aware `library_of` (currently single-segment).
@@ -280,6 +352,9 @@ Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie - Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs 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/ - Library organizer (`libraries` unified model with kinds movie/series/mixed/
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at - Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
+421
View File
@@ -0,0 +1,421 @@
"""Č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 typing import Optional
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: Optional[int] = None
czech_title: str = ""
original_title: str = ""
year: Optional[str] = None # four-digit string, or None
@property
def tmdb_query(self) -> tuple[str, Optional[str]]:
"""``(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' "
"(poetry install je doplní)."
)
def _extract_csfd_id(url: str) -> Optional[int]:
"""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.S,
)
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, Optional[str]]:
"""``(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: Optional[str] = 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[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.
"""
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: Optional[str] = None
year: Optional[str] = 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: 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.
"""
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) -> 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.
"""
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: Optional[str] = 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
+121 -15
View File
@@ -33,15 +33,64 @@ class File:
# free-form per-movie attributes (e.g. "collection_sort"); usable in # free-form per-movie attributes (e.g. "collection_sort"); usable in
# miniDLNA filename templates via name_context. # miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {} 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() 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 = ""
if 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: def get_metadata(self) -> None:
record = self.index.get(self.file_path) if self.index is not None else None """Load this file's metadata from the movie record that owns it.
if record is None:
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() self._init_new_metadata()
self.save_metadata() return
else:
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
self._movie_key, record = found
self._apply_record(record) 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: def _init_new_metadata(self) -> None:
self.new = True self.new = True
@@ -101,9 +150,42 @@ class File:
self.tags.append(tag) self.tags.append(tag)
def save_metadata(self): def save_metadata(self):
"""Persist metadata to the pool index; a no-op for an index-less File.""" """Persist metadata into this file's movie record (no-op without index).
if self.index is not None:
self.index.set(self.file_path, self._build_record()) 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: def added_timestamp(self) -> float:
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime. """Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
@@ -169,19 +251,43 @@ class File:
self.save_metadata() self.save_metadata()
def delete_metadata(self) -> None: def delete_metadata(self) -> None:
"""Remove this file's metadata from the pool index (no-op without one).""" """Drop this file from its movie record; the last one takes the record.
if self.index is not None:
self.index.delete(self.file_path) 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: def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along. """Point this File at a new path, moving its media entry along.
The physical file must already have been moved/renamed by the caller. The physical file must already have been moved/renamed by the caller;
Drops the metadata under the old index key and rebinds to the new path; this only repoints the movie record's media entry (and the File), so a
call ``save_metadata()`` afterwards to write it back. rename never detaches a file from its movie.
""" """
if self.index is not None: if self.index is not None:
self.index.delete(self.file_path) 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.file_path = Path(new_path)
self.filename = self.file_path.name self.filename = self.file_path.name
+551 -39
View File
@@ -7,7 +7,10 @@ from .tag_manager import TagManager
from .pool_index import PoolIndex from .pool_index import PoolIndex
from .utils import list_files from .utils import list_files
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id from .naming import (
canonical_pool_stem, is_canonical_pool_stem, is_valid_year, parse_3d_format,
parse_pool_stem, parse_tmdb_id)
from . import subtitles
from typing import Callable, Iterable from typing import Callable, Iterable
from src.core.config import ( from src.core.config import (
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
@@ -168,6 +171,118 @@ class FileManager:
"""Names of libraries matching any of ``kinds``.""" """Names of libraries matching any of ``kinds``."""
return [lib["name"] for lib in self.libraries if lib["kind"] in kinds] return [lib["name"] for lib in self.libraries if lib["kind"] in kinds]
@property
def minidlna_libraries(self) -> list[str] | None:
"""Libraries included in miniDLNA generation, or ``None`` for "all" (default).
Persisted in the pool index (it describes this pool, not app state) via
*miniDLNA → "Knihovny pro generování…"*. Names no longer in
:attr:`libraries` are dropped when read, so a renamed/removed library
can't silently wedge the selection.
"""
idx = self._ensure_index()
raw = idx.get_setting("minidlna_libraries", None) if idx is not None else None
if raw is None:
return None
known = {lib["name"] for lib in self.libraries}
return [n for n in raw if n in known]
def set_minidlna_libraries(self, names: list[str] | None) -> None:
"""Restrict miniDLNA generation to ``names``; ``None`` resets to "all"."""
self._set_setting_raw(
"minidlna_libraries", None if names is None else list(dict.fromkeys(names)))
def _set_setting_raw(self, key: str, value) -> None:
"""Persist an arbitrary pool-index setting (no legacy-default fallback)."""
idx = self._ensure_index()
if idx is None:
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
idx.set_setting(key, value)
def minidlna_scope(self) -> tuple[list[File], list[str], list]:
"""``(movies, mirrored library names, mixed-library series)`` for miniDLNA.
Narrowed to :attr:`minidlna_libraries` when set — movies by
:meth:`library_of`, mirrored libraries by name, mixed-library series by
their library — otherwise the whole pool (today's behaviour). Excluding
a movie library here means its genre-tree links (and its share of "Tipy
dne" / "Nově přidané") are swept as obsolete on the next generation, not
merely skipped — the caller should say so before generating.
The **mirrored libraries** are ``series`` (copy-as-is) *and*
``homevideo`` kinds — neither has genre tags to build a tree from
(homevideo carries no TMDb id either), so both are hardcopied whole
into ``output/<library name>`` instead of going through the tag tree.
A ``mixed`` library isn't itself mirrored (its top-level files are
movies, tagged into the genre tree like any other), but any **series
sitting in its subfolders** have no genre tags to speak of — those are
returned separately so the caller can hardcopy them too (e.g. into
``output/Seriály/<jejich vlastní název>``).
"""
selected = self.minidlna_libraries
mixed_libs = set(self.libraries_of_kind("mixed"))
if selected is not None:
mixed_libs &= set(selected)
mixed_series = (
[s for s in self.load_series() if s.library in mixed_libs]
if mixed_libs else [])
mirror_libs = self.mirror_as_is_libraries
if selected is None:
return list(self.filelist), mirror_libs, mixed_series
chosen = set(selected)
movies = [f for f in self.filelist if self.library_of(f) in chosen]
mirrors = [name for name in mirror_libs if name in chosen]
return movies, mirrors, mixed_series
def minidlna_prune_targets(
self, mirror_libs: list[str], mixed_series: list, mixed_series_bucket: str,
) -> tuple[list[str], set[str]]:
"""What miniDLNA output to remove for mirrors that fell out of scope.
``mirror_libs`` / ``mixed_series`` come from :meth:`minidlna_scope` (the
*current* run's selection); ``mixed_series_bucket`` is the shared output
folder mixed-library series hardcopy into (e.g. ``"Seriály"``) — the same
name a real ``series``/``homevideo`` library may also use, so the two
sources are reconciled together rather than one blindly wiping the other.
Returns ``(stale_libraries, valid_bucket_children)``:
- ``stale_libraries`` — mirrored-library output folders (``output/<name>``)
to delete **entirely**: every :attr:`mirror_as_is_libraries` name no
longer in ``mirror_libs``, except one that shares the bucket's name
(reconciled via the second element instead).
- ``valid_bucket_children`` — basenames that must remain directly under
``output/<mixed_series_bucket>`` this run: the in-scope mixed-library
series' own folder names, plus — when a real library named
``mixed_series_bucket`` is itself in scope — that library's current
subfolders. Anything else found there is stale.
"""
in_scope = set(mirror_libs)
stale = [name for name in self.mirror_as_is_libraries
if name not in in_scope and name != mixed_series_bucket]
valid = {ser.folder.name for ser in mixed_series}
if mixed_series_bucket in in_scope:
pool = self.pool_dir
src = pool / mixed_series_bucket if pool else None
if src and src.is_dir():
valid |= {p.name for p in src.iterdir() if p.is_dir()}
return stale, valid
@property
def mirror_as_is_libraries(self) -> list[str]:
"""Libraries hardcopied 1:1 into miniDLNA — ``series`` and ``homevideo``.
Neither has genre tags to build a tree from (homevideo has no TMDb id
either), so both mirror whole into ``output/<library name>`` instead of
going through the tag tree. A superset of :attr:`copyasis_folders`
(which the library organizer UI still calls "copy-as-is" and keeps
scoped to kind ``series`` only).
"""
return self.libraries_of_kind("series", "homevideo")
@property @property
def copyasis_folders(self) -> list[str]: def copyasis_folders(self) -> list[str]:
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries.""" """Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
@@ -334,7 +449,7 @@ class FileManager:
Returns a dict with: Returns a dict with:
ok — False if the pool isn't set (with 'error') ok — False if the pool isn't set (with 'error')
missing — index keys whose file no longer exists on disk missing — indexed media files that no longer exist on disk
untracked — pool files (pool-relative) with no index entry untracked — pool files (pool-relative) with no index entry
index_count / disk_count — totals for the summary index_count / disk_count — totals for the summary
""" """
@@ -344,7 +459,14 @@ class FileManager:
"missing": [], "untracked": []} "missing": [], "untracked": []}
index = self.index or PoolIndex(pool) index = self.index or PoolIndex(pool)
index_keys = set(index.records.keys()) # Records are keyed by movie identity, so the paths to compare are the
# media entries listed inside them.
index_keys = {
media["file"]
for record in index.all_movies().values()
for media in record.get("media", [])
if media.get("file")
}
disk_keys = set() disk_keys = set()
for lib in self.libraries: for lib in self.libraries:
@@ -518,6 +640,7 @@ class FileManager:
move: bool = False, on_conflict: str = "suffix", move: bool = False, on_conflict: str = "suffix",
year: int | str | None = None, tmdb_id: int | str | None = None, year: int | str | None = None, tmdb_id: int | str | None = None,
library: str | None = None, csfd_url: str | None = None, library: str | None = None, csfd_url: str | None = None,
format_3d: str | None = None,
) -> File | None: ) -> File | None:
"""Bring a video file into a movie library and index its metadata. """Bring a video file into a movie library and index its metadata.
@@ -527,6 +650,8 @@ class FileManager:
``tmdb_id`` when known — the form Jellyfin matches on); without a year it ``tmdb_id`` when known — the form Jellyfin matches on); without a year it
falls back to ``Title.ext``. The GUI import always supplies a year, so a falls back to ``Title.ext``. The GUI import always supplies a year, so a
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``. library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
``format_3d`` (one of :data:`naming.THREE_D_FORMATS`) appends the
``.3D.<FORMAT>`` tag right away, when the file is already a 3D rip.
By default the original is **copied** (non-destructive). With ``move=True`` By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind. the source file is moved into the pool instead, leaving nothing behind.
@@ -555,7 +680,7 @@ class FileManager:
source = Path(source) source = Path(source)
safe_title = title.strip() or source.stem safe_title = title.strip() or source.stem
has_year = year is not None and str(year).strip() != "" has_year = year is not None and str(year).strip() != ""
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year stem = (canonical_pool_stem(safe_title, year, tmdb_id, format_3d) if has_year
else safe_title) else safe_title)
target = movies / f"{stem}{source.suffix}" target = movies / f"{stem}{source.suffix}"
@@ -600,7 +725,9 @@ class FileManager:
extension is preserved; ``new_title`` is the bare name without it. extension is preserved; ``new_title`` is the bare name without it.
Raises: Raises:
ValueError: empty name or a name containing a path separator. ValueError: empty name, a name containing a path separator, or
``file_obj`` is one version of a multi-version movie (renaming
just one file would orphan its siblings).
FileExistsError: another pooled file already uses that name. FileExistsError: another pooled file already uses that name.
""" """
new_title = new_title.strip() new_title = new_title.strip()
@@ -608,6 +735,10 @@ class FileManager:
raise ValueError("Název nesmí být prázdný.") raise ValueError("Název nesmí být prázdný.")
if "/" in new_title or "\\" in new_title: if "/" in new_title or "\\" in new_title:
raise ValueError("Název nesmí obsahovat lomítka.") raise ValueError("Název nesmí obsahovat lomítka.")
if self.is_movie_group_member(file_obj):
raise ValueError(
"Film s více verzemi zatím nelze takhle přejmenovat — "
"odeber verzi nebo uprav soubory ve složce filmu ručně.")
old_path = file_obj.file_path old_path = file_obj.file_path
new_path = old_path.with_name(f"{new_title}{old_path.suffix}") new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
@@ -618,6 +749,7 @@ class FileManager:
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.") raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
old_path.rename(new_path) old_path.rename(new_path)
subtitles.rename_subtitles_with(old_path, new_path)
file_obj.relocate(new_path) file_obj.relocate(new_path)
file_obj.title = new_title file_obj.title = new_title
file_obj.save_metadata() file_obj.save_metadata()
@@ -626,13 +758,15 @@ class FileManager:
self.on_files_changed(self.filelist) self.on_files_changed(self.filelist)
return file_obj return file_obj
def _canonical_name_for(self, file_obj: File) -> str | None: def _canonical_name_for(self, file_obj: File, *, include_3d: bool = True) -> str | None:
"""The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``. """The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
Uses the clean title and the year from the movie's metadata Uses the clean title and the year from the movie's metadata
(``name_context``: cache → ``Rok`` tag → the current filename). A (``name_context``: cache → ``Rok`` tag → the current filename). A
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and ``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
an existing ``[tmdbid-N]`` on the current filename is preserved. Returns an existing ``[tmdbid-N]`` / ``.3D.<FORMAT>`` on the current filename is
preserved (``include_3d=False`` drops the 3D tag — used for a movie-group
**folder** name, where 3D becomes a per-version label instead). Returns
``None`` when no four-digit year is known. ``None`` when no four-digit year is known.
""" """
ctx = file_obj.name_context() ctx = file_obj.name_context()
@@ -644,13 +778,14 @@ class FileManager:
title = parsed[0] title = parsed[0]
year = year or str(parsed[1]) year = year or str(parsed[1])
# Keep the TMDb id already in the pool filename, if any. # Keep the TMDb id / 3D tag already in the pool filename, if any.
tmdb_id = parse_tmdb_id(file_obj.file_path.stem) tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
format_3d = parse_3d_format(file_obj.file_path.stem) if include_3d else None
title = title.replace("/", "-").replace("\\", "-").strip() title = title.replace("/", "-").replace("\\", "-").strip()
if not (title and is_valid_year(year)): if not (title and is_valid_year(year)):
return None return None
return canonical_pool_stem(title, year, tmdb_id) return canonical_pool_stem(title, year, tmdb_id, format_3d)
def rename_to_canonical(self, file_obj: File) -> File | None: def rename_to_canonical(self, file_obj: File) -> File | None:
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata. """Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
@@ -658,15 +793,20 @@ class FileManager:
The year comes from the movie's metadata (NFO cache, falling back to a The year comes from the movie's metadata (NFO cache, falling back to a
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title ``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
(without the year). Does not fire ``on_files_changed`` — the caller (without the year). Does not fire ``on_files_changed`` — the caller
refreshes once after a batch. refreshes once after a batch. A movie with multiple versions is skipped
(``None``) — renaming one version alone would orphan its siblings; use
the version tools in *Upravit film* instead.
Returns: Returns:
The file when renamed (or already canonical); ``None`` when no The file when renamed (or already canonical); ``None`` when no
four-digit year is known, so no canonical name can be built. four-digit year is known (or the movie has multiple versions), so
no canonical name can be built.
Raises: Raises:
FileExistsError: another pooled file already uses that name. FileExistsError: another pooled file already uses that name.
""" """
if self.is_movie_group_member(file_obj):
return None
new_stem = self._canonical_name_for(file_obj) new_stem = self._canonical_name_for(file_obj)
if new_stem is None: if new_stem is None:
return None return None
@@ -680,6 +820,7 @@ class FileManager:
parsed = parse_pool_stem(new_stem) parsed = parse_pool_stem(new_stem)
old_path.rename(new_path) old_path.rename(new_path)
subtitles.rename_subtitles_with(old_path, new_path)
file_obj.relocate(new_path) file_obj.relocate(new_path)
if parsed is not None: if parsed is not None:
file_obj.title = parsed[0] file_obj.title = parsed[0]
@@ -740,7 +881,9 @@ class FileManager:
Moves the file into ``pool/<new_library>/`` and re-indexes it under the Moves the file into ``pool/<new_library>/`` and re-indexes it under the
new key. Raises: new key. Raises:
ValueError: the target isn't a known movie library. ValueError: the target isn't a known movie library, or ``file_obj``
is one version of a multi-version movie (moving just one file
would orphan its siblings).
FileExistsError: a file of that name already sits in the target. FileExistsError: a file of that name already sits in the target.
""" """
pool = self.pool_dir pool = self.pool_dir
@@ -749,6 +892,10 @@ class FileManager:
new_library = new_library.strip() new_library = new_library.strip()
if new_library not in self.libraries_accepting_movies: if new_library not in self.libraries_accepting_movies:
raise ValueError(f"Neznámá knihovna „{new_library}“.") raise ValueError(f"Neznámá knihovna „{new_library}“.")
if self.is_movie_group_member(file_obj):
raise ValueError(
"Film s více verzemi zatím nelze takhle přesunout — "
"odeber verzi, nebo přesuň celou složku filmu ručně.")
old_path = file_obj.file_path old_path = file_obj.file_path
dest_dir = pool / new_library dest_dir = pool / new_library
@@ -760,6 +907,7 @@ class FileManager:
raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.") raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.")
shutil.move(str(old_path), str(new_path)) shutil.move(str(old_path), str(new_path))
subtitles.rename_subtitles_with(old_path, new_path)
file_obj.relocate(new_path) # drops the old index key, rebinds to the new file_obj.relocate(new_path) # drops the old index key, rebinds to the new
file_obj.save_metadata() file_obj.save_metadata()
@@ -772,13 +920,19 @@ class FileManager:
tmdb_id: int | str | None = None, library: str | None = None, tmdb_id: int | str | None = None, library: str | None = None,
date: str | None = None, attributes: dict | None = None, date: str | None = None, attributes: dict | None = None,
tags: list[str] | None = None, csfd_url: str | None = None, tags: list[str] | None = None, csfd_url: str | None = None,
format_3d: str | None = None,
) -> File: ) -> File:
"""Apply all editable parameters of a movie at once (rename/move + metadata). """Apply all editable parameters of a movie at once (rename/move + metadata).
Renames the file to ``Title (rok) [tmdbid].ext`` (year permitting), moves Renames the file to ``Title (rok) [tmdbid].3D.<FORMAT>.ext`` (year/3D
it into ``library``, and sets ``title`` / ``date`` / ``attributes`` / permitting), moves it into ``library``, and sets ``title`` / ``date`` /
``tags`` (a full ``["Category/Name", …]`` list; NFO-tag provenance is kept ``attributes`` / ``tags`` (a full ``["Category/Name", …]`` list;
for the ones that remain). No-ops that don't change the path skip the move. NFO-tag provenance is kept for the ones that remain). No-ops that don't
change the path skip the move.
``format_3d`` follows :func:`naming.canonical_pool_stem`: omitted
(``None``) keeps whatever 3D tag the file already has, an empty string
clears it, one of :data:`naming.THREE_D_FORMATS` sets it.
Raises: Raises:
FileExistsError: the target name is already taken. FileExistsError: the target name is already taken.
@@ -788,18 +942,34 @@ class FileManager:
raise RuntimeError("Pool není nastaven.") raise RuntimeError("Pool není nastaven.")
title = (title or "").strip() or file_obj.file_path.stem title = (title or "").strip() or file_obj.file_path.stem
year_s = str(year).strip() if year not in (None, "") else "" year_s = str(year).strip() if year not in (None, "") else ""
stem = (canonical_pool_stem(title, year_s, tmdb_id) grouped = self.is_movie_group_member(file_obj)
if format_3d is None:
format_3d = None if grouped else parse_3d_format(file_obj.file_path.stem)
stem = (canonical_pool_stem(title, year_s, tmdb_id, format_3d)
if is_valid_year(year_s) else title) if is_valid_year(year_s) else title)
lib = ((library or "").strip() lib = ((library or "").strip()
or self.library_of(file_obj) or self.movie_libraries[0]) or self.library_of(file_obj) or self.movie_libraries[0])
old_path = file_obj.file_path old_path = file_obj.file_path
new_path = pool / lib / f"{stem}{old_path.suffix}"
if new_path != old_path: if grouped:
if new_path.exists(): # Identity is locked to the container folder's name; only a
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.") # no-op (title/year/tmdb_id/library all unchanged) is allowed
new_path.parent.mkdir(parents=True, exist_ok=True) # here — metadata-only fields below still apply normally.
shutil.move(str(old_path), str(new_path)) if stem != old_path.parent.name or lib != self.library_of(file_obj):
file_obj.relocate(new_path) raise ValueError(
"Film s více verzemi zatím nelze takhle přejmenovat/přesunout "
"(rok/název/TMDb ID/knihovna) — odeber verzi, nebo uprav "
"soubory ve složce filmu ručně. Datum/atributy/tagy/ČSFD "
"odkaz upravit lze.")
else:
new_path = pool / lib / f"{stem}{old_path.suffix}"
if new_path != old_path:
if new_path.exists():
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.")
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_path), str(new_path))
subtitles.rename_subtitles_with(old_path, new_path)
file_obj.relocate(new_path)
file_obj.title = title file_obj.title = title
if date is not None: if date is not None:
@@ -826,6 +996,218 @@ class FileManager:
self.on_files_changed(self.filelist) self.on_files_changed(self.filelist)
return file_obj return file_obj
# ------------------------------------------------------------------
# Movie versions (Jellyfin "multiple versions": 2D + 3D, 4K + 1080p, …)
# ------------------------------------------------------------------
#
# A movie is identified by (title, year, tmdbid) — the canonical stem. As
# long as it has exactly one video file, it stays flat:
# pool/<Library>/Title (rok) [tmdbid].ext
# The moment a second version is added, Curator promotes it to its own
# folder (Jellyfin's own "multiple versions" convention — every file in it
# must start with the folder's exact name):
# pool/<Library>/Title (rok) [tmdbid]/
# Title (rok) [tmdbid] - Výchozí.ext
# Title (rok) [tmdbid] - 3D HSBS.ext
# Shared metadata (tags, ČSFD odkaz, datum, atributy) lives on whichever
# version is alphabetically first (Jellyfin's own "default version" rule —
# `visible_movies` shows only that one in the table). Renaming/moving/
# retitling a grouped movie is not supported yet (see the guards in
# `rename_movie` / `move_to_library` / `apply_movie_edits`) — remove a
# version (auto-demotes back to flat when one is left) or edit the files
# directly if you need that.
def is_movie_group_member(self, file_obj: File) -> bool:
"""True when ``file_obj`` is one version inside its own per-movie folder.
Only recognised for ``movie``-kind libraries in this first cut — a
``mixed`` library's subfolders are always treated as series.
"""
pool = self.pool_dir
lib = self.library_of(file_obj)
if pool is None or lib is None or lib not in self.libraries_of_kind("movie"):
return False
parent = file_obj.file_path.parent
return parent != pool / lib and is_canonical_pool_stem(parent.name)
def list_movie_versions(self, file_obj: File) -> list[File]:
"""Every version sharing ``file_obj``'s movie-group folder, name-sorted.
A single-element list (just ``file_obj``) when it isn't grouped.
"""
if not self.is_movie_group_member(file_obj):
return [file_obj]
folder = file_obj.file_path.parent
versions = []
for p in sorted(folder.iterdir()):
if (not p.is_file() or p.suffix.lower() in SIDECAR_SUFFIXES
or p.name.endswith(METADATA_SUFFIXES)):
continue
existing = next((f for f in self.filelist if f.file_path == p), None)
versions.append(existing or File(p, self.tagmanager, index=self.index))
return versions
def primary_version(self, versions: list[File]) -> File:
"""Which version represents the movie in the table.
Metadata is shared by the whole movie record now, so this is purely a
display choice: the plain/unlabelled file first, then the one labelled
:data:`movie.DEFAULT_VERSION_LABEL`, then alphabetically. Unrelated to
which version Jellyfin plays by default (its own, alphabetical rule).
"""
from .movie import DEFAULT_VERSION_LABEL
def rank(f: File) -> tuple[int, str]:
label = f._identity_from_path()[4]
order = 0 if not label else (1 if label == DEFAULT_VERSION_LABEL else 2)
return order, f.file_path.name.casefold()
return min(versions, key=rank)
def visible_movies(self) -> list[File]:
"""``filelist`` collapsed to one row per movie for the table.
Ungrouped files pass through unchanged; a movie-group folder
contributes only its :meth:`primary_version` — the rest are reachable
via *Upravit film → Verze*, not as separate rows.
"""
seen_folders: set[Path] = set()
result: list[File] = []
for f in self.filelist:
if not self.is_movie_group_member(f):
result.append(f)
continue
folder = f.file_path.parent
if folder in seen_folders:
continue
seen_folders.add(folder)
result.append(self.primary_version(self.list_movie_versions(f)))
return result
def add_movie_version(
self, file_obj: File, source: Path, label: str, *,
move: bool = False, existing_label: str = "Výchozí",
) -> File:
"""Add ``source`` as another playable version of ``file_obj``'s movie.
Promotes a flat movie to its own per-movie folder on the first extra
version — the pre-existing file is moved in as
``<folder> - {existing_label}.ext`` (only on this first promotion) and
``source`` lands as ``<folder> - {label}.ext``. A movie already grouped
just gets one more file in the same folder. Shared metadata stays
wherever it already was; the new version starts with none of its own
(attach subtitles / a 3D formát to it separately if needed).
Raises:
RuntimeError: no pool, or ``file_obj`` has no valid title+year yet.
ValueError: ``label`` is empty.
FileExistsError: a version of that name already exists.
"""
label = (label or "").strip()
if not label:
raise ValueError("Popisek verze nesmí být prázdný.")
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
source = Path(source)
stem: str | None
if self.is_movie_group_member(file_obj):
folder = file_obj.file_path.parent
stem = folder.name
else:
stem = self._canonical_name_for(file_obj, include_3d=False)
if stem is None:
raise RuntimeError(
"Film nemá platný název/rok — nejde založit složku verzí.")
lib = self.library_of(file_obj) or self.movie_libraries[0]
folder = pool / lib / stem
if folder.exists():
raise FileExistsError(f"Složka „{stem}“ už v poolu existuje.")
folder.mkdir(parents=True)
old_path = file_obj.file_path
existing_target = folder / f"{stem} - {existing_label}{old_path.suffix}"
shutil.move(str(old_path), str(existing_target))
subtitles.rename_subtitles_with(old_path, existing_target)
file_obj.relocate(existing_target)
file_obj.save_metadata()
target = folder / f"{stem} - {label}{source.suffix}"
if target.exists():
raise FileExistsError(f"Verze „{label}“ už existuje.")
if move:
shutil.move(str(source), str(target))
else:
shutil.copy2(source, target)
new_version = File(target, self.tagmanager, index=self.index)
new_version.added = datetime.now().isoformat(timespec="seconds")
new_version.save_metadata()
self.filelist.append(new_version)
if self.on_files_changed:
self.on_files_changed(self.filelist)
return new_version
def remove_movie_version(self, file_obj: File) -> None:
"""Remove one version (+ its subtitles) from a movie's version group.
When exactly one version is left afterward, the movie is demoted back
to a flat file — moved out to the library root under the bare
canonical stem — and the now-empty folder is removed. Metadata needs no
rescuing: it lives on the movie record, not on any one version, so
removing even the version shown in the table leaves it untouched.
Raises:
ValueError: ``file_obj`` isn't part of a version group (nothing to
remove it *from* — a lone movie has no "versions" to drop).
"""
if not self.is_movie_group_member(file_obj):
raise ValueError("Film nemá žádné další verze k odebrání.")
folder = file_obj.file_path.parent
file_obj.delete_metadata()
for sub in subtitles.find_subtitles(file_obj.file_path):
sub.path.unlink()
file_obj.file_path.unlink()
if file_obj in self.filelist:
self.filelist.remove(file_obj)
remaining = [
p for p in folder.iterdir()
if p.is_file() and p.suffix.lower() not in SIDECAR_SUFFIXES
and not p.name.endswith(METADATA_SUFFIXES)]
if len(remaining) == 1:
sole = remaining[0]
sole_file = next((f for f in self.filelist if f.file_path == sole), None)
new_path = folder.parent / f"{folder.name}{sole.suffix}"
sole.rename(new_path)
subtitles.rename_subtitles_with(sole, new_path)
if sole_file is not None:
sole_file.relocate(new_path)
sole_file.save_metadata()
shutil.rmtree(folder, ignore_errors=True)
if self.on_files_changed:
self.on_files_changed(self.filelist)
def list_subtitles(self, file_obj: File) -> list:
"""External subtitle sidecars currently sitting next to a movie."""
return subtitles.find_subtitles(file_obj.file_path)
def attach_subtitle(
self, file_obj: File, source: Path, *, language: str | None = None,
flag: str | None = None, move: bool = False,
) -> Path:
"""Copy (or move) a picked subtitle file next to ``file_obj``, correctly named.
Raises:
ValueError: ``source`` isn't a recognised subtitle format.
"""
return subtitles.attach_subtitle(
file_obj.file_path, source, language=language, flag=flag, move=move)
def remove_subtitle(self, subtitle_path: Path) -> None:
"""Delete one attached subtitle file."""
Path(subtitle_path).unlink()
def refresh_tags_from_tmdb( def refresh_tags_from_tmdb(
self, files: list[File] | None = None, self, files: list[File] | None = None,
on_progress: Callable[[int, int, File], None] | None = None, on_progress: Callable[[int, int, File], None] | None = None,
@@ -867,6 +1249,43 @@ class FileManager:
return {"ok": True, "updated": updated, "without_id": without_id, return {"ok": True, "updated": updated, "without_id": without_id,
"errors": errors} "errors": errors}
def resolve_via_csfd(
self, *, csfd_url: str | None = None, query: str | None = None,
session: object | None = None,
) -> dict:
"""ČSFD → TMDb bridge for one movie.
With ``csfd_url`` set, reads that ČSFD film; otherwise searches ČSFD for
``query`` (a cleaned filename) and takes the top hit. The ČSFD original
title + year then drive a TMDb search — far more reliable than a raw
Czech-title search. Pass a shared ``requests.Session`` to reuse the
Anubis auth cookie across a batch.
Returns ``{"csfd_url", "film": CsfdFilm | None, "tmdb": TmdbResult | None}``.
Best-effort: a network / anti-bot / no-match failure is a ``None`` in the
result, not an exception.
"""
from . import csfd, tmdb
result: dict = {"csfd_url": csfd_url, "film": None, "tmdb": None}
try:
url = csfd_url
if not url and query:
url = csfd.find_csfd_url(query, session=session)
if not url:
return result
result["csfd_url"] = url
film = csfd.fetch_film(url, session=session)
result["film"] = film
title, year = film.tmdb_query
if title and tmdb.api_available():
hits = tmdb.search_movie(title, year=year, limit=1)
if not hits and film.czech_title and film.czech_title != title:
hits = tmdb.search_movie(film.czech_title, year=year, limit=1)
result["tmdb"] = hits[0] if hits else None
except (csfd.CsfdError, tmdb.TmdbError, OSError):
pass
return result
def match_pool_to_tmdb( def match_pool_to_tmdb(
self, files: list[File] | None = None, self, files: list[File] | None = None,
on_progress: Callable[[int, int, File], None] | None = None, on_progress: Callable[[int, int, File], None] | None = None,
@@ -875,14 +1294,18 @@ class FileManager:
"""Propose a TMDb id for every movie that doesn't have one yet. """Propose a TMDb id for every movie that doesn't have one yet.
Searches TMDb by each movie's title + year and classifies the result Searches TMDb by each movie's title + year and classifies the result
(see :func:`migrate.classify`). Movies whose filename already carries a (see :func:`migrate.classify`). A movie that carries a **ČSFD link**
(``File.csfd_url``) is first resolved through ČSFD so the search runs on
the *original* title + year — the pure Czech-title search picks the wrong
film for remakes/localized names. Movies whose filename already carries a
``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``. ``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``.
""" """
from . import tmdb from . import csfd, tmdb
from .migrate import MatchProposal, classify from .migrate import MatchProposal, classify
targets = files if files is not None else self.filelist targets = files if files is not None else self.filelist
proposals: list = [] proposals: list = []
total = len(targets) total = len(targets)
csfd_session = None
for i, f in enumerate(targets, 1): for i, f in enumerate(targets, 1):
if should_cancel is not None and should_cancel(): if should_cancel is not None and should_cancel():
break break
@@ -896,14 +1319,28 @@ class FileManager:
if parsed is not None: if parsed is not None:
title = parsed[0] title = parsed[0]
year = str(ctx.get("year") or "").strip() or None year = str(ctx.get("year") or "").strip() or None
note = ""
if f.csfd_url and csfd.dependencies_available():
if csfd_session is None and csfd.requests is not None:
csfd_session = csfd.requests.Session()
try:
film = csfd.fetch_film(f.csfd_url, session=csfd_session)
except csfd.CsfdError:
film = None
if film is not None and (film.original_title or film.czech_title):
title = film.original_title or film.czech_title
year = film.year or year
note = f"přes ČSFD: {title}" + (f" ({film.year})" if film.year else "")
try: try:
candidates = tmdb.search_movie(title, year=year, limit=5) candidates = tmdb.search_movie(title, year=year, limit=5)
status, chosen = classify(title, year, candidates) status, chosen = classify(title, year, candidates)
proposals.append(MatchProposal( proposals.append(MatchProposal(
f, f.filename, title, year, status, chosen, candidates)) f, f.filename, title, year, status, chosen, candidates, note=note))
except tmdb.TmdbError as exc: except tmdb.TmdbError as exc:
proposals.append(MatchProposal( proposals.append(MatchProposal(
f, f.filename, title, year, "error", None, [], str(exc))) f, f.filename, title, year, "error", None, [], str(exc), note))
if on_progress is not None: if on_progress is not None:
on_progress(i, total, f) on_progress(i, total, f)
return proposals return proposals
@@ -973,17 +1410,21 @@ class FileManager:
self, sources: list[Path], title: str, year: int | str | None = None, self, sources: list[Path], title: str, year: int | str | None = None,
tmdb_id: int | str | None = None, library: str | None = None, tmdb_id: int | str | None = None, library: str | None = None,
move: bool = False, on_conflict: str = "suffix", move: bool = False, on_conflict: str = "suffix",
codes: dict[Path, tuple[int, int]] | None = None,
) -> dict: ) -> dict:
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``. """Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
Each source's season/episode is parsed from its filename and it is copied Each source's season/episode comes from ``codes`` (an explicit
(or moved) in as ``Series SxxEyy.ext``. Files with no recognisable ``{path: (season, episode)}`` override set at import time) when present,
``SxxEyy`` are skipped and reported. otherwise it is parsed from the filename. It is then copied (or moved) in
as ``Series SxxEyy.ext``. Files with neither an override nor a
recognisable ``SxxEyy`` are skipped and reported.
Returns ``{imported, skipped_no_code, errors}``. Returns ``{imported, skipped_no_code, errors}``.
""" """
from .series import ( from .series import (
parse_episode, series_folder_stem, season_folder_name, episode_filename) parse_episode, series_folder_stem, season_folder_name, episode_filename)
codes = codes or {}
pool = self.pool_dir pool = self.pool_dir
if pool is None: if pool is None:
raise RuntimeError("Pool není nastaven.") raise RuntimeError("Pool není nastaven.")
@@ -996,21 +1437,21 @@ class FileManager:
errors: list[str] = [] errors: list[str] = []
for src in sources: for src in sources:
src = Path(src) src = Path(src)
code = parse_episode(src.name) code = codes.get(src) or parse_episode(src.name)
if code is None: if code is None:
skipped_no_code.append(src.name) skipped_no_code.append(src.name)
continue continue
season, ep = code season, ep = code
dest_dir = pool / lib / stem / season_folder_name(season) dest_dir = pool / lib / stem / season_folder_name(season)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
target = dest_dir / episode_filename(season, ep, src.stem, src.suffix) target = dest_dir / episode_filename(season, ep, title, src.suffix)
if target.exists(): if target.exists():
if on_conflict == "skip": if on_conflict == "skip":
continue continue
if on_conflict == "replace": if on_conflict == "replace":
target.unlink() target.unlink()
else: # suffix else: # suffix
base = episode_filename(season, ep, src.stem, "") base = episode_filename(season, ep, title, "")
counter = 1 counter = 1
while target.exists(): while target.exists():
target = dest_dir / f"{base}_{counter}{src.suffix}" target = dest_dir / f"{base}_{counter}{src.suffix}"
@@ -1027,7 +1468,7 @@ class FileManager:
"errors": errors} "errors": errors}
def rename_episodes_to_canonical(self, series) -> dict: def rename_episodes_to_canonical(self, series) -> dict:
"""Rename a series' episodes to ``Series SxxEyy.ext`` in the right season. """Rename a series' episodes to ``SxxEyy - {series title}.ext`` in its season.
``series`` is a ``series.Series``. Episodes with no recognisable code are ``series`` is a ``series.Series``. Episodes with no recognisable code are
skipped; a target name already taken is reported as a conflict. skipped; a target name already taken is reported as a conflict.
@@ -1044,7 +1485,7 @@ class FileManager:
continue continue
dest_dir = series.folder / season_folder_name(ep.season) dest_dir = series.folder / season_folder_name(ep.season)
new_path = dest_dir / episode_filename( new_path = dest_dir / episode_filename(
ep.season, ep.episode, ep.path.stem, ep.path.suffix) ep.season, ep.episode, series.title, ep.path.suffix)
if new_path == ep.path: if new_path == ep.path:
continue continue
if new_path.exists(): if new_path.exists():
@@ -1052,14 +1493,64 @@ class FileManager:
continue continue
try: try:
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
old_name = ep.path.name old_path = ep.path
ep.path.rename(new_path) old_path.rename(new_path)
subtitles.rename_subtitles_with(old_path, new_path)
ep.path = new_path ep.path = new_path
renamed.append((old_name, new_path.name)) renamed.append((old_path.name, new_path.name))
except OSError as exc: except OSError as exc:
conflicts.append((ep.path.name, str(exc))) conflicts.append((ep.path.name, str(exc)))
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts} return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
def rename_all_series_to_canonical(
self,
on_progress: Callable[[int, int, str], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> dict:
"""Canonicalise every series' episode filenames across the pool.
Iterates all series (``series`` + ``mixed`` libraries); per series it
renames each episode to ``SxxEyy - {series title}.ext`` in the right
``Season NN`` / ``Specials`` folder (:meth:`rename_episodes_to_canonical`)
and then removes any season folder left empty by the moves. Episodes with
no recognisable ``SxxEyy`` are left in place and listed.
Returns ``{series, renamed:[(series, old, new)],
skipped_no_code:[(series, name)], conflicts:[(series, name, msg)],
empty_dirs_removed:[str], cancelled}``.
"""
from .series import is_season_folder
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
all_series = self.load_series()
total = len(all_series)
renamed: list[tuple[str, str, str]] = []
skipped: list[tuple[str, str]] = []
conflicts: list[tuple[str, str, str]] = []
empty_removed: list[str] = []
cancelled = False
for i, ser in enumerate(all_series, 1):
if should_cancel is not None and should_cancel():
cancelled = True
break
if on_progress is not None:
on_progress(i, total, ser.title)
res = self.rename_episodes_to_canonical(ser)
renamed += [(ser.title, old, new) for old, new in res["renamed"]]
skipped += [(ser.title, name) for name in res["skipped"]]
conflicts += [(ser.title, name, msg) for name, msg in res["conflicts"]]
for sub in sorted(p for p in ser.folder.iterdir() if p.is_dir()):
if is_season_folder(sub.name) and not any(sub.iterdir()):
try:
sub.rmdir()
empty_removed.append(str(sub.relative_to(pool)))
except OSError:
pass
return {"series": total, "renamed": renamed, "skipped_no_code": skipped,
"conflicts": conflicts, "empty_dirs_removed": empty_removed,
"cancelled": cancelled}
def apply_series_edits( def apply_series_edits(
self, series, *, title: str, year: int | str | None = None, self, series, *, title: str, year: int | str | None = None,
tmdb_id: int | str | None = None, library: str | None = None, tmdb_id: int | str | None = None, library: str | None = None,
@@ -1096,6 +1587,27 @@ class FileManager:
else {"renamed": [], "skipped": [], "conflicts": []}) else {"renamed": [], "skipped": [], "conflicts": []})
return {"folder": new_folder, "episodes": result} return {"folder": new_folder, "episodes": result}
def remove_series(self, series) -> None:
"""Delete a whole series folder from the pool (every season and episode).
Series carry no index metadata (they are scanned from disk), so removing
one is just deleting its folder. Refuses a folder that is not inside the
pool as a safety check.
Raises:
ValueError: the series folder is outside the pool.
"""
pool = self.pool_dir
if pool is None:
raise RuntimeError("Pool není nastaven.")
folder = Path(series.folder)
try:
folder.resolve().relative_to(pool.resolve())
except ValueError:
raise ValueError(f"Složka „{folder}“ není v poolu.")
if folder.is_dir():
shutil.rmtree(folder)
def assign_tag_to_file_objects(self, files_objs: list[File], tag): def assign_tag_to_file_objects(self, files_objs: list[File], tag):
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs: for f in files_objs:
+21
View File
@@ -19,6 +19,7 @@ Example:
""" """
import os import os
import random import random
import shutil
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import List, Tuple, Optional, Dict, Set from typing import List, Tuple, Optional, Dict, Set
@@ -341,6 +342,26 @@ class HardlinkManager:
return success_count, fail_count 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( def _fill_special_folder(
self, chosen: List[File], subfolder: str, dry_run: bool self, chosen: List[File], subfolder: str, dry_run: bool
) -> Tuple[int, int]: ) -> Tuple[int, int]:
+106
View File
@@ -0,0 +1,106 @@
"""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 typing import Optional
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: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None # four-digit string
source: str = "" # "sidecar" | "index"
def _record_to_meta(record: object, source: str) -> Optional[LegacyMeta]:
"""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) -> Optional[LegacyMeta]:
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) -> 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).
"""
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) -> Optional[LegacyMeta]:
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) -> 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.
"""
video_path = Path(video_path)
return _from_sidecar(video_path) or _from_legacy_index(video_path)
+1
View File
@@ -27,6 +27,7 @@ class MatchProposal:
chosen: object = None # tmdb.TmdbResult preselected for the user, or None chosen: object = None # tmdb.TmdbResult preselected for the user, or None
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult] candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
error: str = "" error: str = ""
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
def normalize(text: str) -> str: def normalize(text: str) -> str:
+251
View File
@@ -0,0 +1,251 @@
"""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 typing import Optional
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) -> 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.
"""
match = _LABEL_3D_RE.match(str(label or "").strip())
return match.group(1).upper() if match else None
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) -> Optional[str]:
"""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: Optional[str] = None
tmdb_id: Optional[int] = 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: Optional[str] = None
date: Optional[str] = None
added: Optional[str] = None
attributes: dict[str, str] = field(default_factory=dict)
meta: Optional[dict] = 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) -> Optional[MediaFile]:
"""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
+46 -10
View File
@@ -4,16 +4,32 @@ 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 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 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 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. These lets two same-named films coexist and makes the filename self-describing.
helpers build, validate and parse the stem (the filename without its extension).
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 import re
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The # Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
# title is non-greedy so a title that itself contains parentheses keeps them: # always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019. # 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( _POOL_STEM_RE = re.compile(
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$") 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: def is_valid_year(year: object) -> bool:
@@ -21,12 +37,16 @@ def is_valid_year(year: object) -> bool:
return bool(re.fullmatch(r"\d{4}", str(year).strip())) return bool(re.fullmatch(r"\d{4}", str(year).strip()))
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str: def canonical_pool_stem(
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given). 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: Raises:
ValueError: the title is empty, the year is not four digits, or a given ValueError: the title is empty, the year is not four digits, a given
``tmdb_id`` is not a positive integer. ``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one
of :data:`THREE_D_FORMATS`.
""" """
title = (title or "").strip() title = (title or "").strip()
year_s = str(year).strip() year_s = str(year).strip()
@@ -40,6 +60,13 @@ def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str
if not id_s.isdigit(): if not id_s.isdigit():
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.") raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
stem += f" [tmdbid-{id_s}]" 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 return stem
@@ -63,6 +90,15 @@ def parse_tmdb_id(stem: str) -> int | None:
return int(match.group("id")) 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: def is_canonical_pool_stem(stem: str) -> bool:
"""True when ``stem`` matches the ``Title (YYYY)`` convention.""" """True when ``stem`` matches the ``Title (YYYY)`` convention."""
return _POOL_STEM_RE.match((stem or "").strip()) is not None return _POOL_STEM_RE.match((stem or "").strip()) is not None
+25
View File
@@ -26,6 +26,7 @@ class NfoData:
countries: list[str] = field(default_factory=list) countries: list[str] = field(default_factory=list)
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
tmdb_id: Optional[int] = None tmdb_id: Optional[int] = None
csfd_url: Optional[str] = None # ČSFD film URL, if the NFO carries a csfd id
def field_values(self, name: str) -> list[str]: def field_values(self, name: str) -> list[str]:
"""Values for a tag-schema source field (genres/countries/year/rating).""" """Values for a tag-schema source field (genres/countries/year/rating)."""
@@ -78,6 +79,29 @@ def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
return int(raw.strip()) if raw and raw.strip().isdigit() else None return int(raw.strip()) if raw and raw.strip().isdigit() else None
def _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>/``.
"""
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) -> Optional[NfoData]: def read_nfo(video_path: Path) -> Optional[NfoData]:
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken.""" """Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
path = find_nfo(video_path) path = find_nfo(video_path)
@@ -98,4 +122,5 @@ def read_nfo(video_path: Path) -> Optional[NfoData]:
countries=_texts(root, "country"), countries=_texts(root, "country"),
rating=_parse_rating(root), rating=_parse_rating(root),
tmdb_id=_parse_tmdb_id(root), tmdb_id=_parse_tmdb_id(root),
csfd_url=_parse_csfd_url(root),
) )
+130 -15
View File
@@ -10,17 +10,26 @@ The index also carries **library-level settings** (``tag_schema``,
particular pool travels with the pool. The global config keeps only app data 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. (window state, MRU) and the pointers to where the pool/output live.
On-disk shape:: On-disk shape (**v2**)::
{"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}} {"version": 2,
"settings": {...},
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
Records are keyed by the file's path relative to the pool root (POSIX form), Records are keyed by the movie's **identity** (``tmdb-<id>``, or a title/year
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines. 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 json
import shutil
from pathlib import Path from pathlib import Path
INDEX_FILENAME = ".Curator.!index" INDEX_FILENAME = ".Curator.!index"
INDEX_VERSION = 2
class PoolIndex: class PoolIndex:
@@ -42,7 +51,11 @@ class PoolIndex:
return path.as_posix() return path.as_posix()
def load(self) -> None: def load(self) -> None:
"""Load the index from disk (missing/corrupt index = empty).""" """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(): if not self.index_path.exists():
self.records = {} self.records = {}
self.settings = {} self.settings = {}
@@ -55,13 +68,82 @@ class PoolIndex:
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
self.records = {} self.records = {}
self.settings = {} 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: def save(self) -> None:
"""Persist the index to disk.""" """Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True) self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f: with open(self.index_path, "w", encoding="utf-8") as f:
json.dump( json.dump(
{"settings": self.settings, "movies": self.records}, {"version": INDEX_VERSION,
"settings": self.settings,
"movies": self.records},
f, indent=2, ensure_ascii=False, f, indent=2, ensure_ascii=False,
) )
@@ -74,16 +156,49 @@ class PoolIndex:
self.settings[key] = value self.settings[key] = value
self.save() self.save()
def get(self, file_path: Path) -> dict | None: # ------------------------------------------------------------------
"""Return the record for a file, or None if it is not indexed.""" # Movie records (v2 — keyed by identity, own their media files)
return self.records.get(self._key(file_path)) # ------------------------------------------------------------------
def set(self, file_path: Path, data: dict) -> None: def get_movie(self, key: str) -> dict | None:
"""Upsert a record and persist the index.""" """Return the movie record under ``key``, or None."""
self.records[self._key(file_path)] = data 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() self.save()
def delete(self, file_path: Path) -> None: def delete_movie(self, key: str) -> None:
"""Remove a record (if present) and persist the index.""" """Remove a movie record (if present) and persist the index."""
if self.records.pop(self._key(file_path), None) is not None: if self.records.pop(key, None) is not None:
self.save() 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
+39 -18
View File
@@ -71,31 +71,52 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]:
return int(match.group(3)), int(match.group(4)) return int(match.group(3)), int(match.group(4))
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: def season_folder_name(season: int) -> str:
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``.""" """Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
return "Specials" if season == 0 else f"Season {season:02d}" return "Specials" if season == 0 else f"Season {season:02d}"
def episode_body(stem: str) -> str: def is_season_folder(name: str) -> bool:
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code. """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
Keeps the original (release) filename so it survives into the canonical name,
while stripping an already-applied ``SxxEyy - `` prefix so repeated renames
don't double it. Returns ``""`` when the stem was only the code.
"""
s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
return s.strip(" ._-")
def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str: # Characters not allowed in a filename on the common filesystems.
"""Canonical episode filename ``SxxEyy - {original name}.ext``. _FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
Keeps the original filename after the code; when the original was only the
code (nothing descriptive left) it collapses to ``SxxEyy.ext``. 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) -> 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``.
""" """
code = f"S{season:02d}E{episode:02d}" code = f"S{season:02d}E{episode:02d}"
body = episode_body(orig_stem) body = sanitize_title(series_title)
return f"{code} - {body}{ext}" if body else f"{code}{ext}" return f"{code} - {body}{ext}" if body else f"{code}{ext}"
@@ -115,13 +136,13 @@ def series_folder_stem(title: str, year: object = None, tmdb_id: object = None)
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
def is_canonical_episode(path: Path) -> bool: def is_canonical_episode(path: Path, series_title: str) -> bool:
"""True when ``path`` already follows ``SxxEyy - name`` in the right season.""" """True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
parsed = parse_episode(path.name) parsed = parse_episode(path.name)
if parsed is None: if parsed is None:
return False return False
season, episode = parsed season, episode = parsed
expected = episode_filename(season, episode, path.stem, path.suffix) expected = episode_filename(season, episode, series_title, path.suffix)
if path.name != expected: if path.name != expected:
return False return False
return path.parent.name == season_folder_name(season) return path.parent.name == season_folder_name(season)
+150
View File
@@ -0,0 +1,150 @@
"""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
from typing import Optional
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: Optional[str] = None # ISO 639-1/2 code, e.g. "cs"
flag: Optional[str] = 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: Optional[str] = None,
flag: Optional[str] = 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
) -> Optional[tuple[Optional[str], Optional[str]]]:
"""``(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: Optional[str] = None
flag: Optional[str] = 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: 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.
"""
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
+784 -39
View File
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
"""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")):
with 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
+4 -4
View File
@@ -37,7 +37,7 @@ class TestFile:
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index): def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
"""Metadata jsou zapsána do indexu už při vytvoření.""" """Metadata jsou zapsána do indexu už při vytvoření."""
file_obj = File(test_file, tag_manager, index=index) file_obj = File(test_file, tag_manager, index=index)
assert index.get(file_obj.file_path) is not None assert index.record_for(file_obj.file_path) is not None
def test_file_no_sidecar_written(self, test_file, tag_manager, index): def test_file_no_sidecar_written(self, test_file, tag_manager, index):
"""Žádný .!tag sidecar už nevzniká.""" """Žádný .!tag sidecar už nevzniká."""
@@ -57,7 +57,7 @@ class TestFile:
file_obj.ignored = True file_obj.ignored = True
file_obj.save_metadata() file_obj.save_metadata()
data = index.get(file_obj.file_path) data = index.record_for(file_obj.file_path)
assert data["new"] is False assert data["new"] is False
assert data["ignored"] is True assert data["ignored"] is True
@@ -78,7 +78,7 @@ class TestFile:
file_obj = File(test_file, tag_manager, index=index) file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25") file_obj.set_date("2025-12-25")
assert file_obj.date == "2025-12-25" assert file_obj.date == "2025-12-25"
assert index.get(file_obj.file_path)["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): def test_file_set_date_to_none(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index) file_obj = File(test_file, tag_manager, index=index)
@@ -169,7 +169,7 @@ class TestFile:
file_obj.add_tag("Test/Tag") file_obj.add_tag("Test/Tag")
file_obj.set_date("2025-06-15") file_obj.set_date("2025-06-15")
data = index.get(file_obj.file_path) data = index.record_for(file_obj.file_path)
assert "new" in data assert "new" in data
assert "ignored" in data assert "ignored" in data
assert "tags" in data assert "tags" in data
+645 -9
View File
@@ -208,7 +208,7 @@ class TestPoolManagement:
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
assert source.exists() # non-destructive copy assert source.exists() # non-destructive copy
assert movie.title == "Matrix" assert movie.title == "Matrix"
assert file_manager.index.get(movie.file_path) is not None assert file_manager.index.record_for(movie.file_path) is not None
def test_statistics_aggregates_pool(self, file_manager, tmp_path): def test_statistics_aggregates_pool(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
@@ -274,7 +274,7 @@ class TestPoolManagement:
# parseable ISO timestamp, persisted in the index # parseable ISO timestamp, persisted in the index
from datetime import datetime from datetime import datetime
datetime.fromisoformat(movie.added) datetime.fromisoformat(movie.added)
assert file_manager.index.get(movie.file_path)["added"] == movie.added assert file_manager.index.record_for(movie.file_path)["added"] == movie.added
def test_import_movie_move_removes_source(self, file_manager, tmp_path): def test_import_movie_move_removes_source(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
@@ -325,7 +325,7 @@ class TestPoolManagement:
assert second.file_path.read_bytes() == b"bb" # new content in place assert second.file_path.read_bytes() == b"bb" # new content in place
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"] assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
# index record reflects the new import (fresh metadata, old tags dropped) # index record reflects the new import (fresh metadata, old tags dropped)
record = file_manager.index.get(second.file_path) record = file_manager.index.record_for(second.file_path)
assert record is not None assert record is not None
assert record["tags"] == [] assert record["tags"] == []
assert second.tags == [] assert second.tags == []
@@ -367,8 +367,8 @@ class TestPoolManagement:
assert not old_path.exists() assert not old_path.exists()
assert movie.title == "Matrix Reloaded" assert movie.title == "Matrix Reloaded"
# metadata moved to the new key, old key gone, tags preserved # metadata moved to the new key, old key gone, tags preserved
assert file_manager.index.get(new_path) is not None assert file_manager.index.record_for(new_path) is not None
assert file_manager.index.get(old_path) is None assert file_manager.index.record_for(old_path) is None
# a fresh manager reading the index sees the renamed file with its tags # a fresh manager reading the index sees the renamed file with its tags
reloaded = FileManager(TagManager()) reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool") reloaded.set_pool_dir(tmp_path / "pool")
@@ -427,6 +427,15 @@ class TestPoolManagement:
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv" assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
assert movie.title == "Matrix" # clean title, no year/id assert movie.title == "Matrix" # clean title, no year/id
def test_import_movie_with_3d_format(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "raw.mkv", "Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
def test_libraries_default_and_derivation(self, file_manager, tmp_path): def test_libraries_default_and_derivation(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
assert file_manager.libraries == [ assert file_manager.libraries == [
@@ -529,15 +538,18 @@ class TestPoolManagement:
file_manager.set_movie_libraries(["Filmy", "Kids"]) file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"x") (tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006) movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
old_key = movie.file_path.relative_to(tmp_path / "pool").as_posix() old_path = movie.file_path
file_manager.move_to_library(movie, "Kids") file_manager.move_to_library(movie, "Kids")
assert file_manager.library_of(movie) == "Kids" assert file_manager.library_of(movie) == "Kids"
assert movie.file_path.exists() assert movie.file_path.exists()
assert file_manager.index.records.get(old_key) is None # old key dropped # The movie keeps its identity record; only its media path + library move.
new_key = movie.file_path.relative_to(tmp_path / "pool").as_posix() assert file_manager.index.record_for(old_path) is None
assert file_manager.index.records.get(new_key) is not None record = file_manager.index.record_for(movie.file_path)
assert record is not None
assert record["library"] == "Kids"
assert [m["file"] for m in record["media"]] == ["Kids/Auta (2006).mkv"]
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path): def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
@@ -613,6 +625,42 @@ class TestPoolManagement:
csfd_url="https://www.csfd.cz/film/9499-matrix/") csfd_url="https://www.csfd.cz/film/9499-matrix/")
assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/" assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
def test_apply_movie_edits_sets_3d_format(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
def test_apply_movie_edits_omitted_3d_format_is_preserved(self, file_manager, tmp_path):
"""Editing an unrelated field (e.g. date) must not silently drop the tag."""
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, date="2026-01-01")
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
assert movie.date == "2026-01-01"
def test_apply_movie_edits_empty_string_clears_3d_format(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="")
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].mkv"
def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path): def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x") (tmp_path / "a.mkv").write_bytes(b"x")
@@ -634,6 +682,19 @@ class TestPoolManagement:
assert renamed is not None assert renamed is not None
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv" assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
def test_rename_to_canonical_preserves_3d_format(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "raw.mkv", "Avatar", year=2009, tmdb_id=19995)
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="fsbs")
movie.title = "Avatar Retitled"
file_manager.rename_to_canonical(movie)
assert movie.file_path.name == "Avatar Retitled (2009) [tmdbid-19995].3D.FSBS.mkv"
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path): def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"a") (tmp_path / "a.mkv").write_bytes(b"a")
@@ -734,3 +795,578 @@ class TestPoolManagement:
assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"] assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"]
assert "copyasis_folders" not in file_manager.global_config assert "copyasis_folders" not in file_manager.global_config
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"] assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"]
class TestCsfdBridge:
"""ČSFD → TMDb title bridge (resolve_via_csfd, migration wiring)."""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
@pytest.fixture
def file_manager(self, temp_global_config):
return FileManager(TagManager())
def test_resolve_via_csfd_bridges_original_title_to_tmdb(
self, file_manager, monkeypatch
):
from src.core import csfd, tmdb
film = csfd.CsfdFilm(
url="https://www.csfd.cz/film/2294-x/", csfd_id=2294,
czech_title="Vykoupení z věznice Shawshank",
original_title="The Shawshank Redemption", year="1994")
monkeypatch.setattr(csfd, "fetch_film", lambda url, session=None: film)
monkeypatch.setattr(tmdb, "api_available", lambda: True)
captured: dict = {}
def fake_search(query, year=None, limit=5):
captured["query"], captured["year"] = query, year
return [tmdb.TmdbResult(278, "Vykoupení z věznice Shawshank", "1994", "movie")]
monkeypatch.setattr(tmdb, "search_movie", fake_search)
result = file_manager.resolve_via_csfd(
csfd_url="https://www.csfd.cz/film/2294-x/")
assert captured == {"query": "The Shawshank Redemption", "year": "1994"}
assert result["tmdb"].tmdb_id == 278
assert result["film"] is film
def test_resolve_via_csfd_swallows_scrape_failure(self, file_manager, monkeypatch):
from src.core import csfd
def boom(url, session=None):
raise csfd.CsfdError("anti-bot")
monkeypatch.setattr(csfd, "fetch_film", boom)
result = file_manager.resolve_via_csfd(csfd_url="https://www.csfd.cz/film/1/")
assert result == {"csfd_url": "https://www.csfd.cz/film/1/",
"film": None, "tmdb": None}
def test_match_pool_uses_csfd_url_for_the_search(
self, file_manager, tmp_path, monkeypatch
):
from src.core import csfd, tmdb
file_manager.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "a.mkv", "Vykoupení z věznice Shawshank", year=1994,
csfd_url="https://www.csfd.cz/film/2294-x/")
film = csfd.CsfdFilm(
url=movie.csfd_url, czech_title="Vykoupení z věznice Shawshank",
original_title="The Shawshank Redemption", year="1994")
monkeypatch.setattr(csfd, "fetch_film", lambda url, session=None: film)
monkeypatch.setattr(tmdb, "api_available", lambda: True)
seen: list = []
def fake_search(query, year=None, limit=5):
seen.append((query, year))
return [tmdb.TmdbResult(278, "The Shawshank Redemption", "1994", "movie")]
monkeypatch.setattr(tmdb, "search_movie", fake_search)
proposals = file_manager.match_pool_to_tmdb([movie])
assert seen == [("The Shawshank Redemption", "1994")]
assert len(proposals) == 1
assert proposals[0].status == "auto"
assert proposals[0].chosen.tmdb_id == 278
assert "ČSFD" in proposals[0].note
class TestSubtitleAttachment:
"""External subtitle sidecars for movies (attach / list / remove / rename-along)."""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
@pytest.fixture
def file_manager(self, temp_global_config, tmp_path):
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_attach_subtitle_copies_next_to_movie(self, file_manager, tmp_path):
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
srt = tmp_path / "downloaded.srt"
srt.write_bytes(b"1\n00:00:01,000 --> 00:00:02,000\nAhoj\n")
target = file_manager.attach_subtitle(movie, srt, language="cs")
assert target == movie.file_path.with_name("Matrix (1999).cs.srt")
assert target.exists()
assert srt.exists() # copied, not moved
assert [s.path for s in file_manager.list_subtitles(movie)] == [target]
def test_remove_subtitle_deletes_file(self, file_manager, tmp_path):
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
srt = tmp_path / "s.srt"
srt.write_bytes(b"x")
target = file_manager.attach_subtitle(movie, srt, language="cs")
file_manager.remove_subtitle(target)
assert not target.exists()
assert file_manager.list_subtitles(movie) == []
def test_rename_movie_carries_subtitle_along(self, file_manager, tmp_path):
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
srt = tmp_path / "s.srt"
srt.write_bytes(b"x")
old_sub = file_manager.attach_subtitle(movie, srt, language="cs")
file_manager.rename_movie(movie, "Matrix Reloaded")
assert not old_sub.exists()
new_sub = movie.file_path.with_name("Matrix Reloaded.cs.srt")
assert new_sub.exists()
def test_apply_movie_edits_carries_subtitle_along(self, file_manager, tmp_path):
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
srt = tmp_path / "s.srt"
srt.write_bytes(b"x")
file_manager.attach_subtitle(movie, srt, language="cs", flag="forced")
file_manager.apply_movie_edits(movie, title="Matrix", year=1999, tmdb_id=603)
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
assert movie.file_path.with_name(
"Matrix (1999) [tmdbid-603].cs.forced.srt").exists()
def test_move_to_library_carries_subtitle_along(self, file_manager, tmp_path):
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "raw.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
srt = tmp_path / "s.srt"
srt.write_bytes(b"x")
file_manager.attach_subtitle(movie, srt, language="cs")
file_manager.move_to_library(movie, "Kids")
assert movie.file_path == tmp_path / "pool" / "Kids" / "Matrix (1999).mkv"
assert (tmp_path / "pool" / "Kids" / "Matrix (1999).cs.srt").exists()
assert not (tmp_path / "pool" / "Filmy" / "Matrix (1999).cs.srt").exists()
class TestMinidlnaScope:
"""Which libraries feed miniDLNA generation (minidlna_libraries / scope)."""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
@pytest.fixture
def file_manager(self, temp_global_config, tmp_path):
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_default_is_none_meaning_all(self, file_manager):
assert file_manager.minidlna_libraries is None
def test_default_scope_is_the_whole_pool(self, file_manager, tmp_path):
file_manager.set_libraries([
{"name": "Filmy", "kind": "movie"}, {"name": "Seriály", "kind": "series"}])
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
movies, mirrors, mixed_series = file_manager.minidlna_scope()
assert movies == [movie]
assert mirrors == ["Seriály"]
assert mixed_series == []
def test_scope_filters_to_selected_libraries(self, file_manager, tmp_path):
file_manager.set_libraries([
{"name": "Filmy", "kind": "movie"}, {"name": "Kids", "kind": "movie"},
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "series"}])
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"x")
in_scope = file_manager.import_movie(
tmp_path / "a.mkv", "Matrix", year=1999, library="Filmy")
file_manager.import_movie(tmp_path / "b.mkv", "Kids Movie", year=2005, library="Kids")
file_manager.set_minidlna_libraries(["Filmy", "Seriály"])
movies, mirrors, mixed_series = file_manager.minidlna_scope()
assert movies == [in_scope]
assert mirrors == ["Seriály"]
assert mixed_series == []
def test_homevideo_libraries_are_mirrored_like_series(self, file_manager):
file_manager.set_libraries([
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
assert file_manager.mirror_as_is_libraries == ["Seriály", "Dokumenty"]
_, mirrors, _ = file_manager.minidlna_scope()
assert mirrors == ["Seriály", "Dokumenty"]
def test_homevideo_library_excluded_when_not_selected(self, file_manager):
file_manager.set_libraries([
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
file_manager.set_minidlna_libraries(["Seriály"])
_, mirrors, _ = file_manager.minidlna_scope()
assert mirrors == ["Seriály"]
def test_copyasis_folders_stays_series_only(self, file_manager):
"""The library-organizer "copy-as-is" concept doesn't widen to homevideo."""
file_manager.set_libraries([
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
assert file_manager.copyasis_folders == ["Seriály"]
def test_set_none_resets_to_all(self, file_manager):
file_manager.set_minidlna_libraries(["Filmy"])
file_manager.set_minidlna_libraries(None)
assert file_manager.minidlna_libraries is None
def test_drops_unknown_library_names_on_read(self, file_manager):
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
file_manager.set_minidlna_libraries(["Filmy", "Zaniklá knihovna"])
assert file_manager.minidlna_libraries == ["Filmy"]
def test_persists_across_reload(self, file_manager, tmp_path):
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
file_manager.set_minidlna_libraries(["Filmy"])
reloaded = FileManager(TagManager())
reloaded.set_pool_dir(tmp_path / "pool")
assert reloaded.minidlna_libraries == ["Filmy"]
def test_mixed_library_series_returned_separately_from_movies(
self, file_manager, tmp_path
):
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
(tmp_path / "movie.mkv").write_bytes(b"x")
movie = file_manager.import_movie(
tmp_path / "movie.mkv", "Matrix", year=1999, library="Hlavní")
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
library="Hlavní")
movies, mirrors, mixed_series = file_manager.minidlna_scope()
assert movies == [movie]
assert mirrors == []
assert len(mixed_series) == 1
assert mixed_series[0].title == "Breaking Bad"
assert mixed_series[0].library == "Hlavní"
def test_mixed_library_series_excluded_when_library_not_selected(
self, file_manager, tmp_path
):
file_manager.set_libraries([
{"name": "Hlavní", "kind": "mixed"}, {"name": "Filmy", "kind": "movie"}])
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, library="Hlavní")
file_manager.set_minidlna_libraries(["Filmy"])
_, _, mixed_series = file_manager.minidlna_scope()
assert mixed_series == []
class TestMinidlnaPruneTargets:
"""What to remove from a previous generation's output once deselected."""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
@pytest.fixture
def file_manager(self, temp_global_config, tmp_path):
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_deselected_series_library_is_stale(self, file_manager):
file_manager.set_libraries([
{"name": "Anime", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
# both deselected — neither is "Seriály" so both go whole-folder
stale, valid = file_manager.minidlna_prune_targets(
mirror_libs=[], mixed_series=[], mixed_series_bucket="Seriály")
assert set(stale) == {"Anime", "Dokumenty"}
assert valid == set()
def test_selected_library_is_not_stale(self, file_manager):
file_manager.set_libraries([{"name": "Anime", "kind": "series"}])
stale, _ = file_manager.minidlna_prune_targets(
mirror_libs=["Anime"], mixed_series=[], mixed_series_bucket="Seriály")
assert stale == []
def test_bucket_named_library_never_whole_folder_removed(self, file_manager):
"""A real 'Seriály' library, even deselected, is reconciled per-child —
never nuked via the whole-folder path (that would also erase valid
mixed-library series sharing the same bucket)."""
file_manager.set_libraries([{"name": "Seriály", "kind": "series"}])
stale, valid = file_manager.minidlna_prune_targets(
mirror_libs=[], mixed_series=[], mixed_series_bucket="Seriály")
assert "Seriály" not in stale
assert valid == set()
def test_bucket_named_library_selected_contributes_its_children(
self, file_manager, tmp_path
):
file_manager.set_libraries([{"name": "Seriály", "kind": "series"}])
(tmp_path / "pool" / "Seriály" / "Show A").mkdir(parents=True)
(tmp_path / "pool" / "Seriály" / "Show B").mkdir(parents=True)
_, valid = file_manager.minidlna_prune_targets(
mirror_libs=["Seriály"], mixed_series=[], mixed_series_bucket="Seriály")
assert valid == {"Show A", "Show B"}
def test_mixed_series_folder_names_are_valid(self, file_manager, tmp_path):
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008,
tmdb_id=1396, library="Hlavní")
_, _, mixed_series = file_manager.minidlna_scope()
_, valid = file_manager.minidlna_prune_targets(
mirror_libs=[], mixed_series=mixed_series, mixed_series_bucket="Seriály")
assert valid == {"Breaking Bad (2008) [tmdbid-1396]"}
def test_deselected_mixed_series_drops_out_of_valid(self, file_manager, tmp_path):
"""Simulates re-running after the mixed library is unchecked: the
previously-mirrored series' folder name is no longer valid."""
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008,
tmdb_id=1396, library="Hlavní")
file_manager.set_minidlna_libraries([]) # deselect everything
_, _, mixed_series = file_manager.minidlna_scope()
_, valid = file_manager.minidlna_prune_targets(
mirror_libs=[], mixed_series=mixed_series, mixed_series_bucket="Seriály")
assert mixed_series == []
assert valid == set()
class TestMovieVersions:
"""Multiple versions of one movie (2D+3D, 4K+1080p, …) — flat until a
second file shows up, then grouped into a per-movie folder."""
@pytest.fixture
def temp_global_config(self, tmp_path, monkeypatch):
config_path = tmp_path / "test_config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
@pytest.fixture
def file_manager(self, temp_global_config, tmp_path):
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
def test_flat_movie_is_not_a_group_member(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
assert file_manager.is_movie_group_member(movie) is False
assert file_manager.list_movie_versions(movie) == [movie]
assert file_manager.visible_movies() == [movie]
def test_add_version_promotes_flat_movie_to_a_folder(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]"
assert movie.file_path == base / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
assert v2.file_path == base / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
assert file_manager.is_movie_group_member(movie)
assert file_manager.is_movie_group_member(v2)
assert (tmp_path / "b.mkv").exists() # copied, not moved (move=False default)
def test_add_version_carries_subtitle_along_on_promotion(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
srt = tmp_path / "s.srt"
srt.write_bytes(b"x")
file_manager.attach_subtitle(movie, srt, language="cs")
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]"
assert (base / "Avatar (2009) [tmdbid-19995] - Výchozí.cs.srt").exists()
def test_second_version_added_to_existing_group(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
(tmp_path / "c.mkv").write_bytes(b"4k")
v3 = file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K")
assert len(file_manager.list_movie_versions(movie)) == 3
assert v3.file_path.name == "Avatar (2009) [tmdbid-19995] - 4K.mkv"
def test_add_version_rejects_empty_label(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
with pytest.raises(ValueError):
file_manager.add_movie_version(movie, tmp_path / "a.mkv", " ")
def test_add_version_rejects_duplicate_label(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
(tmp_path / "c.mkv").write_bytes(b"3d-2")
with pytest.raises(FileExistsError):
file_manager.add_movie_version(movie, tmp_path / "c.mkv", "3D")
def test_visible_movies_shows_only_the_primary_version(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
movie.add_tag("Žánr/Sci-Fi")
movie.csfd_url = "https://www.csfd.cz/film/avatar/"
movie.save_metadata()
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
visible = file_manager.visible_movies()
assert len(visible) == 1
assert visible[0].file_path == movie.file_path
assert visible[0].title == "Avatar"
assert visible[0].csfd_url == "https://www.csfd.cz/film/avatar/"
assert {t.full_path for t in visible[0].tags} == {"Žánr/Sci-Fi"}
def test_remove_version_keeps_group_when_more_than_one_remains(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
(tmp_path / "c.mkv").write_bytes(b"4k")
v3 = file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K")
file_manager.remove_movie_version(v3)
assert not v3.file_path.exists()
assert file_manager.is_movie_group_member(movie)
assert file_manager.is_movie_group_member(v2)
assert len(file_manager.list_movie_versions(movie)) == 2
def test_remove_last_extra_version_demotes_to_flat(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
file_manager.remove_movie_version(v2)
assert not file_manager.is_movie_group_member(movie)
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
assert movie.file_path.exists()
assert not (tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]").exists()
def test_removing_primary_migrates_metadata_to_survivor(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
movie.add_tag("Žánr/Sci-Fi")
movie.csfd_url = "https://www.csfd.cz/film/avatar/"
movie.attributes = {"collection_sort": "01"}
movie.save_metadata()
(tmp_path / "b.mkv").write_bytes(b"3d")
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
file_manager.remove_movie_version(movie) # remove the primary
assert v2.title == "Avatar"
assert v2.csfd_url == "https://www.csfd.cz/film/avatar/"
assert v2.attributes == {"collection_sort": "01"}
assert {t.full_path for t in v2.tags} == {"Žánr/Sci-Fi"}
assert not file_manager.is_movie_group_member(v2) # demoted, only one left
def test_remove_version_rejects_ungrouped_movie(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"x")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
with pytest.raises(ValueError):
file_manager.remove_movie_version(movie)
def test_rename_movie_refuses_grouped_movie(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
with pytest.raises(ValueError):
file_manager.rename_movie(movie, "Avatar Reloaded")
def test_move_to_library_refuses_grouped_movie(self, file_manager, tmp_path):
file_manager.set_movie_libraries(["Filmy", "Kids"])
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
with pytest.raises(ValueError):
file_manager.move_to_library(movie, "Kids")
def test_apply_movie_edits_refuses_rename_of_grouped_movie(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
with pytest.raises(ValueError):
file_manager.apply_movie_edits(movie, title="Avatar Reloaded", year=2009, tmdb_id=19995)
def test_apply_movie_edits_allows_metadata_only_edit_of_grouped_movie(
self, file_manager, tmp_path
):
"""Same title/year/tmdb_id → no path change → date/tags/etc still work."""
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
file_manager.apply_movie_edits(
movie, title="Avatar", year=2009, tmdb_id=19995, date="2026-01-01")
assert movie.date == "2026-01-01"
assert file_manager.is_movie_group_member(movie)
def test_rename_to_canonical_skips_grouped_movie(self, file_manager, tmp_path):
(tmp_path / "a.mkv").write_bytes(b"2d")
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
(tmp_path / "b.mkv").write_bytes(b"3d")
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
movie.title = "Avatar Retitled"
assert file_manager.rename_to_canonical(movie) is None
assert movie.title == "Avatar Retitled" # metadata field unaffected
assert file_manager.is_movie_group_member(movie) # file untouched
+27
View File
@@ -942,3 +942,30 @@ class TestMirrorAsIs:
"""Neexistující zdroj nic neudělá""" """Neexistující zdroj nic neudělá"""
manager = HardlinkManager(tmp_path / "out") manager = HardlinkManager(tmp_path / "out")
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0) 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
+104
View File
@@ -0,0 +1,104 @@
"""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
+155
View File
@@ -0,0 +1,155 @@
"""Tests for the movie object and the pool layout it derives (src.core.movie)."""
from pathlib import Path
import pytest
from src.core.movie import (
DEFAULT_VERSION_LABEL,
MediaFile,
Movie,
identity_key,
media_filename,
normalize_3d_label,
)
@pytest.mark.parametrize("label,expected", [
("HSBS", "HSBS"), ("hsbs", "HSBS"), ("3D HSBS", "HSBS"),
("3d.hsbs", "HSBS"), ("3D_FTAB", "FTAB"), ("3d-mvc", "MVC"),
("4K", None), ("Director's Cut", None), ("", None), (None, None),
])
def test_normalize_3d_label(label, expected):
assert normalize_3d_label(label) == expected
class TestMediaFilename:
def test_no_label_is_the_bare_stem(self):
assert media_filename("Matrix (1999)", "", ".mkv", grouped=False) == \
"Matrix (1999).mkv"
def test_3d_label_uses_jellyfins_documented_form(self):
assert media_filename("Avatar (2009)", "3D HSBS", ".mkv", grouped=False) == \
"Avatar (2009).3D.HSBS.mkv"
# same spelling grouped — "." is a valid separator after the folder name
assert media_filename("Avatar (2009)", "hsbs", ".mkv", grouped=True) == \
"Avatar (2009).3D.HSBS.mkv"
def test_plain_label_uses_dash_form(self):
assert media_filename("Avatar (2009)", "4K", ".mkv", grouped=True) == \
"Avatar (2009) - 4K.mkv"
def test_grouped_empty_label_gets_a_default(self):
assert media_filename("Avatar (2009)", "", ".mkv", grouped=True) == \
f"Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv"
class TestIdentityKey:
def test_tmdb_id_wins(self):
assert identity_key(603, "Matrix", "1999") == "tmdb-603"
assert identity_key("603", "cokoli", None) == "tmdb-603"
def test_slug_fallback_without_id(self):
assert identity_key(None, "Matrix", "1999") == "name-matrix-1999"
def test_slug_folds_diacritics(self):
assert identity_key(None, "Pelíšky", "1999") == "name-pelisky-1999"
def test_slug_without_year(self):
assert identity_key(None, "Nějaký film", None) == "name-nejaky-film"
def _movie(**kwargs) -> Movie:
base = dict(key="tmdb-19995", title="Avatar", year="2009", tmdb_id=19995,
library="Filmy")
base.update(kwargs)
return Movie(**base)
class TestMovieShape:
def test_stem_is_the_canonical_form(self):
assert _movie().stem == "Avatar (2009) [tmdbid-19995]"
def test_stem_falls_back_to_title_without_year(self):
assert _movie(year=None, tmdb_id=None).stem == "Avatar"
def test_single_video_is_not_grouped(self):
m = _movie(media=[MediaFile(Path("/pool/Filmy/x.mkv"))])
assert m.is_grouped is False
def test_two_videos_are_grouped(self):
m = _movie(media=[MediaFile(Path("/a.mkv"), "3D HSBS"),
MediaFile(Path("/b.mkv"), "Výchozí")])
assert m.is_grouped is True
def test_primary_prefers_the_plain_version(self):
m = _movie(media=[MediaFile(Path("/a.mkv"), "3D HSBS"),
MediaFile(Path("/b.mkv"), "")])
assert m.primary.label == ""
class TestDesiredLayout:
def test_one_video_stays_flat_in_the_library(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "kdekoli.mkv", "")])
(media, target), = m.desired_layout(tmp_path)
assert target == tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
def test_one_3d_video_keeps_the_3d_tag_flat(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "x.mkv", "3D HSBS")])
(_m, target), = m.desired_layout(tmp_path)
assert target.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
assert target.parent == tmp_path / "Filmy"
def test_two_videos_move_into_the_movie_folder(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "a.mkv", ""),
MediaFile(tmp_path / "b.mkv", "3D HSBS")])
targets = {mf.label: path for mf, path in m.desired_layout(tmp_path)}
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
assert targets[""] == folder / \
f"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
assert targets["3D HSBS"] == folder / "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
def test_layout_follows_a_retitle(self, tmp_path):
"""Renaming the movie is just a different desired layout — the thing
that used to be impossible for a grouped movie."""
m = _movie(title="Avatar: Nová verze",
media=[MediaFile(tmp_path / "a.mkv", "A"),
MediaFile(tmp_path / "b.mkv", "B")])
folders = {path.parent for _mf, path in m.desired_layout(tmp_path)}
assert folders == {
tmp_path / "Filmy" / "Avatar: Nová verze (2009) [tmdbid-19995]"}
def test_layout_follows_a_library_move(self, tmp_path):
m = _movie(library="Kids", media=[MediaFile(tmp_path / "a.mkv", "")])
(_m, target), = m.desired_layout(tmp_path)
assert target.parent == tmp_path / "Kids"
class TestRecordRoundTrip:
def test_to_and_from_record(self, tmp_path):
m = _movie(
media=[MediaFile(tmp_path / "Filmy" / "a.mkv", "3D HSBS")],
csfd_url="https://www.csfd.cz/film/1/", date="2026-01-01",
attributes={"collection_sort": "01"}, meta={"year": "2009"})
record = m.to_record(tmp_path)
assert record["media"] == [
{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}]
back = Movie.from_record("tmdb-19995", record, tmp_path)
assert back.title == "Avatar"
assert back.year == "2009"
assert back.tmdb_id == 19995
assert back.library == "Filmy"
assert back.csfd_url == "https://www.csfd.cz/film/1/"
assert back.attributes == {"collection_sort": "01"}
assert back.meta == {"year": "2009"}
assert back.media[0].path == tmp_path / "Filmy" / "a.mkv"
assert back.media[0].format_3d == "HSBS"
def test_from_record_builds_tags_via_tagmanager(self, tmp_path):
from src.core.tag_manager import TagManager
record = {"title": "Avatar", "media": [], "tags": ["Žánr/Sci-Fi"]}
back = Movie.from_record("tmdb-19995", record, tmp_path, TagManager())
assert {t.full_path for t in back.tags} == {"Žánr/Sci-Fi"}
+39
View File
@@ -2,9 +2,11 @@
import pytest import pytest
from src.core.naming import ( from src.core.naming import (
THREE_D_FORMATS,
canonical_pool_stem, canonical_pool_stem,
is_canonical_pool_stem, is_canonical_pool_stem,
is_valid_year, is_valid_year,
parse_3d_format,
parse_pool_stem, parse_pool_stem,
parse_tmdb_id, parse_tmdb_id,
year_from_filename, year_from_filename,
@@ -79,6 +81,43 @@ def test_is_canonical_pool_stem():
assert not is_canonical_pool_stem("Matrix") assert not is_canonical_pool_stem("Matrix")
def test_canonical_pool_stem_with_3d_format():
assert canonical_pool_stem("Avatar", 2009, 19995, "hsbs") == \
"Avatar (2009) [tmdbid-19995].3D.HSBS"
# upper-cased regardless of input case
assert canonical_pool_stem("Avatar", 2009, None, "fsbs") == "Avatar (2009).3D.FSBS"
# empty/None → no tag at all
assert canonical_pool_stem("Avatar", 2009, None, None) == "Avatar (2009)"
assert canonical_pool_stem("Avatar", 2009, None, "") == "Avatar (2009)"
def test_canonical_pool_stem_rejects_bad_3d_format():
with pytest.raises(ValueError):
canonical_pool_stem("Avatar", 2009, None, "bogus")
@pytest.mark.parametrize("fmt", THREE_D_FORMATS)
def test_parse_3d_format_roundtrip(fmt):
stem = canonical_pool_stem("Avatar", 2009, 19995, fmt)
assert parse_3d_format(stem) == fmt
assert parse_pool_stem(stem) == ("Avatar", 2009)
assert parse_tmdb_id(stem) == 19995
def test_parse_3d_format_case_insensitive_on_read():
assert parse_3d_format("Avatar (2009).3D.hsbs") == "HSBS"
def test_parse_3d_format_absent_is_none():
assert parse_3d_format("Avatar (2009) [tmdbid-19995]") is None
assert parse_3d_format("Avatar (2009)") is None
def test_canonical_pool_stem_3d_without_tmdb_id():
assert canonical_pool_stem("Avatar", 2009, None, "MVC") == "Avatar (2009).3D.MVC"
assert parse_3d_format("Avatar (2009).3D.MVC") == "MVC"
@pytest.mark.parametrize("name,year", [ @pytest.mark.parametrize("name,year", [
("Matrix.1999.1080p.BluRay.mkv", "1999"), ("Matrix.1999.1080p.BluRay.mkv", "1999"),
("Šíleně smutná princezna (1968).mkv", "1968"), ("Šíleně smutná princezna (1968).mkv", "1968"),
+29
View File
@@ -52,6 +52,35 @@ def test_tmdbid_from_tmdbid_tag(tmp_path):
assert read_nfo(video).tmdb_id == 603 assert read_nfo(video).tmdb_id == 603
def test_csfd_url_from_uniqueid_bare_id(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8")
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499/"
def test_csfd_url_from_uniqueid_full_url(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
'<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/'
'</uniqueid></movie>', encoding="utf-8")
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499-matrix/"
def test_csfd_url_from_legacy_csfdid_tag(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8")
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/12345/"
def test_csfd_url_absent_is_none(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><title>Matrix</title></movie>", encoding="utf-8")
assert read_nfo(video).csfd_url is None
def test_find_prefers_sibling_over_movie_nfo(tmp_path): def test_find_prefers_sibling_over_movie_nfo(tmp_path):
folder = tmp_path / "Matrix (1999)" folder = tmp_path / "Matrix (1999)"
folder.mkdir() folder.mkdir()
+144 -23
View File
@@ -1,48 +1,68 @@
import json import json
from src.core.pool_index import PoolIndex, INDEX_FILENAME from src.core.pool_index import PoolIndex, INDEX_FILENAME, INDEX_VERSION
from src.core.file import File from src.core.file import File
from src.core.tag_manager import TagManager from src.core.tag_manager import TagManager
class TestPoolIndex: class TestPoolIndex:
"""Testy pro unifikovaný metadata index poolu""" """Testy pro unifikovaný metadata index poolu (v2 — klíč = identita filmu)"""
def test_empty_index_when_no_file(self, tmp_path): def test_empty_index_when_no_file(self, tmp_path):
index = PoolIndex(tmp_path) index = PoolIndex(tmp_path)
assert index.records == {} assert index.records == {}
assert index.get(tmp_path / "Filmy" / "x.mkv") is None assert index.get_movie("tmdb-603") is None
def test_set_and_get_by_relative_key(self, tmp_path): def test_set_and_get_movie_by_identity(self, tmp_path):
index = PoolIndex(tmp_path) index = PoolIndex(tmp_path)
path = tmp_path / "Filmy" / "Matrix.mkv" index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
index.set(path, {"title": "Matrix"})
assert index.get(path) == {"title": "Matrix"} assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []}
# key is the pool-relative POSIX path assert "tmdb-603" in index.records
assert "Filmy/Matrix.mkv" in index.records
def test_set_persists_to_disk(self, tmp_path): def test_set_persists_to_disk_with_version(self, tmp_path):
index = PoolIndex(tmp_path) index = PoolIndex(tmp_path)
index.set(tmp_path / "Filmy" / "A.mkv", {"title": "A"}) index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8")) on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
assert on_disk["movies"]["Filmy/A.mkv"]["title"] == "A" assert on_disk["version"] == INDEX_VERSION
assert on_disk["movies"]["tmdb-603"]["title"] == "Matrix"
def test_reload_reads_existing_index(self, tmp_path): def test_reload_reads_existing_index(self, tmp_path):
PoolIndex(tmp_path).set(tmp_path / "Filmy" / "A.mkv", {"title": "A"}) PoolIndex(tmp_path).set_movie("tmdb-603", {"title": "Matrix", "media": []})
reloaded = PoolIndex(tmp_path) reloaded = PoolIndex(tmp_path)
assert reloaded.get(tmp_path / "Filmy" / "A.mkv") == {"title": "A"} assert reloaded.get_movie("tmdb-603")["title"] == "Matrix"
def test_delete_removes_record(self, tmp_path): def test_delete_movie_removes_record(self, tmp_path):
index = PoolIndex(tmp_path) index = PoolIndex(tmp_path)
path = tmp_path / "Filmy" / "A.mkv" index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
index.set(path, {"title": "A"}) index.delete_movie("tmdb-603")
index.delete(path)
assert index.get(path) is None assert index.get_movie("tmdb-603") is None
assert PoolIndex(tmp_path).get(path) is None assert PoolIndex(tmp_path).get_movie("tmdb-603") is None
def test_rekey_movie(self, tmp_path):
index = PoolIndex(tmp_path)
index.set_movie("name-matrix-1999", {"title": "Matrix", "media": []})
index.rekey_movie("name-matrix-1999", "tmdb-603")
assert index.get_movie("name-matrix-1999") is None
assert index.get_movie("tmdb-603")["title"] == "Matrix"
def test_find_movie_by_media(self, tmp_path):
index = PoolIndex(tmp_path)
index.set_movie("tmdb-603", {
"title": "Matrix",
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}]})
found = index.find_movie_by_media(tmp_path / "Filmy" / "Matrix.mkv")
assert found is not None
key, record = found
assert key == "tmdb-603"
assert record["title"] == "Matrix"
assert index.find_movie_by_media(tmp_path / "Filmy" / "Jiny.mkv") is None
def test_corrupt_index_loads_empty(self, tmp_path): def test_corrupt_index_loads_empty(self, tmp_path):
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8") (tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
@@ -50,8 +70,89 @@ class TestPoolIndex:
assert index.records == {} assert index.records == {}
class TestV1Migration:
"""Starý path-klíčovaný index se převede na identity-klíčovaný (bez hýbání soubory)"""
def _write_v1(self, tmp_path, movies: dict) -> None:
(tmp_path / INDEX_FILENAME).write_text(
json.dumps({"settings": {"tag_schema": []}, "movies": movies}),
encoding="utf-8")
def test_flat_movie_becomes_identity_keyed_record(self, tmp_path):
self._write_v1(tmp_path, {
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
"title": "Matrix", "tags": ["Žánr/Akční"],
"csfd_url": "https://www.csfd.cz/film/9499/",
"date": "1999-03-31", "added": "2026-01-01T00:00:00",
"attributes": {"collection_sort": "01"},
}})
index = PoolIndex(tmp_path)
record = index.get_movie("tmdb-603")
assert record is not None
assert record["title"] == "Matrix"
assert record["year"] == "1999"
assert record["tmdb_id"] == 603
assert record["library"] == "Filmy"
assert record["media"] == [
{"role": "video", "label": "",
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv"}]
assert record["tags"] == ["Žánr/Akční"]
assert record["csfd_url"] == "https://www.csfd.cz/film/9499/"
assert record["attributes"] == {"collection_sort": "01"}
def test_grouped_versions_merge_into_one_record(self, tmp_path):
base = "Filmy/Avatar (2009) [tmdbid-19995]"
self._write_v1(tmp_path, {
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {"title": "Avatar"},
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {"title": "Avatar"},
})
index = PoolIndex(tmp_path)
assert len(index.all_movies()) == 1
record = index.get_movie("tmdb-19995")
assert {m["label"] for m in record["media"]} == {"Výchozí", "3D HSBS"}
assert record["library"] == "Filmy"
def test_movie_without_tmdbid_gets_slug_key(self, tmp_path):
self._write_v1(tmp_path, {
"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
index = PoolIndex(tmp_path)
assert index.get_movie("name-pelisky-1999") is not None
def test_migration_backs_up_and_bumps_version(self, tmp_path):
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
PoolIndex(tmp_path)
backup = tmp_path / (INDEX_FILENAME + ".v1.bak")
assert backup.exists()
assert "Filmy/A (2000).mkv" in json.loads(backup.read_text(encoding="utf-8"))["movies"]
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
assert on_disk["version"] == INDEX_VERSION
def test_migration_keeps_settings(self, tmp_path):
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
index = PoolIndex(tmp_path)
assert index.get_setting("tag_schema") == []
def test_v2_index_is_not_migrated_again(self, tmp_path):
(tmp_path / INDEX_FILENAME).write_text(json.dumps({
"version": INDEX_VERSION, "settings": {},
"movies": {"tmdb-1": {"title": "X", "media": []}}}), encoding="utf-8")
index = PoolIndex(tmp_path)
assert index.get_movie("tmdb-1")["title"] == "X"
assert not (tmp_path / (INDEX_FILENAME + ".v1.bak")).exists()
class TestFileWithIndex: class TestFileWithIndex:
"""File používá index místo sidecar souboru, je-li injektován""" """File je pohled na jeden media záznam filmu v indexu"""
def test_index_backed_file_writes_no_sidecar(self, tmp_path): def test_index_backed_file_writes_no_sidecar(self, tmp_path):
index = PoolIndex(tmp_path) index = PoolIndex(tmp_path)
@@ -62,7 +163,7 @@ class TestFileWithIndex:
f = File(movie, TagManager(), index=index) f = File(movie, TagManager(), index=index)
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
assert index.get(movie) is not None # record created in index assert index.find_movie_by_media(movie) is not None # record created
assert f.tags == [] # no automatic tags assert f.tags == [] # no automatic tags
def test_index_backed_metadata_persists_across_reload(self, tmp_path): def test_index_backed_metadata_persists_across_reload(self, tmp_path):
@@ -94,4 +195,24 @@ class TestFileWithIndex:
f = File(movie, TagManager(), index=index) f = File(movie, TagManager(), index=index)
f.delete_metadata() f.delete_metadata()
assert index.get(movie) is None assert index.find_movie_by_media(movie) is None
def test_two_versions_share_one_movie_record(self, tmp_path):
"""Obě verze čtou stejná metadata — to je smysl identity klíče."""
index = PoolIndex(tmp_path)
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
folder.mkdir(parents=True)
a = folder / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
b = folder / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
a.write_bytes(b"x")
b.write_bytes(b"y")
tm = TagManager()
fa = File(a, tm, index=index)
fa.title = "Avatar"
fa.add_tag("Žánr/Sci-Fi")
fb = File(b, tm, index=index)
assert len(index.all_movies()) == 1
assert fb.title == "Avatar"
assert "Žánr/Sci-Fi" in {t.full_path for t in fb.tags}
+138 -16
View File
@@ -2,9 +2,12 @@
import pytest import pytest
from src.core.series import ( from src.core.series import (
collect_episode_files,
episode_filename, episode_filename,
is_canonical_episode, is_canonical_episode,
is_season_folder,
parse_episode, parse_episode,
sanitize_title,
scan_series, scan_series,
season_folder_name, season_folder_name,
series_folder_stem, series_folder_stem,
@@ -29,13 +32,29 @@ def test_season_folder_name():
assert season_folder_name(12) == "Season 12" assert season_folder_name(12) == "Season 12"
def test_episode_filename_keeps_original_name(): def test_episode_filename_is_code_plus_series_title():
assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv" assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
# already-canonical stem → no doubled prefix assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \ # release string is NOT kept — only the code + series title
"S01E02 - bb.pilot.hdtv.mkv" assert episode_filename(12, 145, "Breaking Bad", ".mkv") == \
# stem that was only the code → collapses to just the code "S12E145 - Breaking Bad.mkv"
assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv" # empty / unusable title → just the code
assert episode_filename(3, 4, "", ".mkv") == "S03E04.mkv"
assert episode_filename(3, 4, "/", ".mkv") == "S03E04.mkv"
def test_sanitize_title():
assert sanitize_title("Most!") == "Most!"
assert sanitize_title('A: B / C D') == "A B C D"
assert sanitize_title(" trailing. ") == "trailing"
def test_is_season_folder():
assert is_season_folder("Season 01")
assert is_season_folder("Season 1")
assert is_season_folder("Specials")
assert not is_season_folder("Extras")
assert not is_season_folder("Season")
def test_series_folder_stem(): def test_series_folder_stem():
@@ -47,12 +66,15 @@ def test_series_folder_stem():
def test_is_canonical_episode(tmp_path): def test_is_canonical_episode(tmp_path):
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01" folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
folder.mkdir(parents=True) folder.mkdir(parents=True)
good = folder / "S01E02 - bb.pilot.mkv" good = folder / "S01E02 - Breaking Bad.mkv"
good.write_bytes(b"x") good.write_bytes(b"x")
bad = folder / "bb.s01e02.mkv" bad = folder / "bb.s01e02.mkv"
bad.write_bytes(b"x") bad.write_bytes(b"x")
assert is_canonical_episode(good) wrong_body = folder / "S01E02 - old release.mkv"
assert not is_canonical_episode(bad) wrong_body.write_bytes(b"x")
assert is_canonical_episode(good, "Breaking Bad")
assert not is_canonical_episode(bad, "Breaking Bad")
assert not is_canonical_episode(wrong_body, "Breaking Bad")
def test_scan_series(tmp_path): def test_scan_series(tmp_path):
@@ -73,6 +95,26 @@ def test_scan_series(tmp_path):
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2 assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
def test_collect_episode_files_walks_subfolders(tmp_path):
root = tmp_path / "Breaking.Bad.COMPLETE"
(root / "Season 01").mkdir(parents=True)
(root / "Season 02").mkdir(parents=True)
(root / "Season 01" / "BB S01E01.mkv").write_bytes(b"x")
(root / "Season 01" / "BB S01E02.mkv").write_bytes(b"x")
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
(root / "Season 02" / "BB S02E01.avi").write_bytes(b"x")
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
files = collect_episode_files(root)
assert [f.name for f in files] == [
"BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
def test_collect_episode_files_missing_folder_is_empty(tmp_path):
assert collect_episode_files(tmp_path / "nope") == []
class TestFileManagerSeries: class TestFileManagerSeries:
@pytest.fixture @pytest.fixture
def file_manager(self, tmp_path, monkeypatch): def file_manager(self, tmp_path, monkeypatch):
@@ -94,8 +136,8 @@ class TestFileManagerSeries:
assert result["imported"] == 2 assert result["imported"] == 2
base = (tmp_path / "pool" / "Seriály" base = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01") / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
assert (base / "S01E01 - bb.mkv").exists() assert (base / "S01E01 - Breaking Bad.mkv").exists()
assert (base / "S01E02 - bb.mkv").exists() assert (base / "S01E02 - Breaking Bad.mkv").exists()
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path): def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
@@ -106,7 +148,7 @@ class TestFileManagerSeries:
assert result["imported"] == 1 assert result["imported"] == 1
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]" assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01" / "S01E01 - bb.mkv").exists() / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
# a mixed library is scanned for series too # a mixed library is scanned for series too
found = file_manager.load_series() found = file_manager.load_series()
assert [s.title for s in found] == ["Breaking Bad"] assert [s.title for s in found] == ["Breaking Bad"]
@@ -118,6 +160,45 @@ class TestFileManagerSeries:
assert result["imported"] == 0 assert result["imported"] == 0
assert result["skipped_no_code"] == ["random.mkv"] assert result["skipped_no_code"] == ["random.mkv"]
def test_remove_series_deletes_the_folder(self, file_manager, tmp_path):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S02E01.mkv"],
"Breaking Bad", year=2008, tmdb_id=1396)
(ser,) = file_manager.load_series()
assert ser.folder.is_dir()
file_manager.remove_series(ser)
assert not ser.folder.exists()
assert file_manager.load_series() == []
def test_remove_series_refuses_folder_outside_pool(self, file_manager, tmp_path):
from src.core.series import Series
outside = tmp_path / "not-the-pool" / "Show (2020)"
outside.mkdir(parents=True)
bogus = Series(outside, "Show", "2020", None, "Seriály")
with pytest.raises(ValueError):
file_manager.remove_series(bogus)
assert outside.is_dir() # untouched
def test_import_episodes_uses_explicit_codes_override(self, file_manager, tmp_path):
uncoded = tmp_path / "pilot.mkv"
uncoded.write_bytes(b"x")
misparsed = tmp_path / "bb 2008 e07.mkv" # "2008" would parse as 20x08
misparsed.write_bytes(b"x")
result = file_manager.import_episodes(
[uncoded, misparsed], "Breaking Bad", year=2008, tmdb_id=1396,
codes={uncoded: (1, 1), misparsed: (1, 7)})
assert result["imported"] == 2
base = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
assert (base / "S01E01 - Breaking Bad.mkv").exists()
assert (base / "S01E07 - Breaking Bad.mkv").exists()
assert result["skipped_no_code"] == []
def test_rename_episodes_to_canonical(self, file_manager, tmp_path): def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
# a mis-named episode sitting in the wrong place # a mis-named episode sitting in the wrong place
folder = (tmp_path / "pool" / "Seriály" folder = (tmp_path / "pool" / "Seriály"
@@ -129,8 +210,49 @@ class TestFileManagerSeries:
assert len(series_list) == 1 assert len(series_list) == 1
result = file_manager.rename_episodes_to_canonical(series_list[0]) result = file_manager.rename_episodes_to_canonical(series_list[0])
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")] assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists() assert (folder / "Season 01" / "S01E03 - Breaking Bad.mkv").exists()
def test_rename_episodes_to_canonical_carries_subtitle_along(
self, file_manager, tmp_path
):
folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
(folder / "bb.1x03.HDTV.cs.srt").write_bytes(b"x")
result = file_manager.rename_episodes_to_canonical(file_manager.load_series()[0])
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.cs.srt").exists()
assert not (folder / "bb.1x03.HDTV.cs.srt").exists()
def test_rename_all_series_to_canonical_bulk(self, file_manager, tmp_path):
seri = tmp_path / "pool" / "Seriály"
# series A: mis-named episodes in a non-padded season folder
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").mkdir(parents=True)
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1"
/ "fargo.1x01.1080p.WEB.mkv").write_bytes(b"x")
# series B: one good, one without a code
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01").mkdir(parents=True)
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
/ "S01E02 - Most!.mkv").write_bytes(b"x")
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
/ "necoZvlastniho.mkv").write_bytes(b"x")
result = file_manager.rename_all_series_to_canonical()
assert result["series"] == 2
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result["renamed"]
assert (seri / "Fargo (2014) [tmdbid-60622]" / "Season 01"
/ "S01E01 - Fargo.mkv").exists()
# emptied non-padded season folder swept
assert not (seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").exists()
# the already-canonical one is untouched, the code-less one is reported
assert ("Most!", "necoZvlastniho.mkv") in result["skipped_no_code"]
assert (seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
/ "S01E02 - Most!.mkv").exists()
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path): def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
folder = tmp_path / "pool" / "Seriály" / "Wrong Name" folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
@@ -144,5 +266,5 @@ class TestFileManagerSeries:
new_folder = (tmp_path / "pool" / "Seriály" new_folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]") / "Breaking Bad (2008) [tmdbid-1396]")
assert result["folder"] == new_folder assert result["folder"] == new_folder
assert (new_folder / "Season 01" / "S01E01 - old.mkv").exists() assert (new_folder / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
assert not folder.exists() assert not folder.exists()
+161
View File
@@ -0,0 +1,161 @@
"""Tests for external subtitle sidecars (src.core.subtitles)."""
import pytest
from src.core.subtitles import (
SubtitleFile,
attach_subtitle,
find_subtitles,
rename_subtitles_with,
subtitle_filename,
)
def _video(tmp_path, name="Matrix (1999) [tmdbid-603].mkv"):
v = tmp_path / name
v.write_bytes(b"x")
return v
class TestSubtitleFilename:
def test_bare_no_language(self):
assert subtitle_filename("Matrix (1999)", ".srt") == "Matrix (1999).srt"
def test_with_language(self):
assert subtitle_filename("Matrix (1999)", ".srt", "cs") == "Matrix (1999).cs.srt"
def test_with_language_and_flag(self):
assert subtitle_filename("Matrix (1999)", ".srt", "cs", "forced") == \
"Matrix (1999).cs.forced.srt"
def test_uppercase_input_is_lowered(self):
assert subtitle_filename("Matrix (1999)", ".SRT", "CS", "FORCED") == \
"Matrix (1999).cs.forced.srt"
class TestFindSubtitles:
def test_finds_bare_language_and_flagged(self, tmp_path):
video = _video(tmp_path)
(tmp_path / "Matrix (1999) [tmdbid-603].srt").write_bytes(b"x")
(tmp_path / "Matrix (1999) [tmdbid-603].cs.srt").write_bytes(b"x")
(tmp_path / "Matrix (1999) [tmdbid-603].en.forced.srt").write_bytes(b"x")
found = {s.path.name: s for s in find_subtitles(video)}
assert set(found) == {
"Matrix (1999) [tmdbid-603].srt",
"Matrix (1999) [tmdbid-603].cs.srt",
"Matrix (1999) [tmdbid-603].en.forced.srt",
}
assert found["Matrix (1999) [tmdbid-603].srt"].language is None
assert found["Matrix (1999) [tmdbid-603].cs.srt"].language == "cs"
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].language == "en"
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].flag == "forced"
def test_ignores_unrelated_and_overlapping_stem(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix Reloaded.srt").write_bytes(b"x") # overlapping prefix
(tmp_path / "Matrix.nfo").write_bytes(b"x") # not a subtitle ext
(tmp_path / "poster.jpg").write_bytes(b"x")
assert find_subtitles(video) == []
def test_ignores_other_movies_subtitle(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Other Movie.cs.srt").write_bytes(b"x")
assert find_subtitles(video) == []
def test_missing_folder_is_empty(self, tmp_path):
assert find_subtitles(tmp_path / "nope" / "video.mkv") == []
def test_label_property(self):
assert SubtitleFile(path=None, language="cs").label == "cs"
assert SubtitleFile(path=None, language="cs", flag="forced").label == "cs (forced)"
assert SubtitleFile(path=None).label == "?"
class TestAttachSubtitle:
def test_copies_and_names_to_match_video(self, tmp_path):
video = _video(tmp_path)
source = tmp_path / "downloaded.srt"
source.write_bytes(b"subtitle content")
target = attach_subtitle(video, source, language="cs")
assert target == tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
assert target.read_bytes() == b"subtitle content"
assert source.exists() # copy, not move
def test_move_removes_source(self, tmp_path):
video = _video(tmp_path)
source = tmp_path / "downloaded.srt"
source.write_bytes(b"x")
target = attach_subtitle(video, source, language="en", move=True)
assert target.exists()
assert not source.exists()
def test_overwrites_same_language_slot(self, tmp_path):
video = _video(tmp_path)
old = tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
old.write_bytes(b"old")
source = tmp_path / "new.srt"
source.write_bytes(b"new")
target = attach_subtitle(video, source, language="cs")
assert target == old
assert target.read_bytes() == b"new"
def test_rejects_unsupported_extension(self, tmp_path):
video = _video(tmp_path)
source = tmp_path / "notes.txt"
source.write_bytes(b"x")
with pytest.raises(ValueError):
attach_subtitle(video, source)
class TestRenameSubtitlesWith:
def test_moves_every_sidecar_preserving_lang_and_flag(self, tmp_path):
old_video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix.srt").write_bytes(b"x")
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
(tmp_path / "Matrix.en.forced.srt").write_bytes(b"x")
new_video = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
moved = rename_subtitles_with(old_video, new_video)
names = {p.name for p in moved}
assert names == {
"Matrix (1999) [tmdbid-603].srt",
"Matrix (1999) [tmdbid-603].cs.srt",
"Matrix (1999) [tmdbid-603].en.forced.srt",
}
for name in names:
assert (tmp_path / name).exists()
assert not (tmp_path / "Matrix.srt").exists()
def test_moves_across_folders(self, tmp_path):
old_dir = tmp_path / "Filmy"
old_dir.mkdir()
old_video = old_dir / "Matrix.mkv"
old_video.write_bytes(b"x")
(old_dir / "Matrix.cs.srt").write_bytes(b"x")
new_dir = tmp_path / "Kids"
new_dir.mkdir()
new_video = new_dir / "Matrix (1999).mkv"
moved = rename_subtitles_with(old_video, new_video)
assert moved == [new_dir / "Matrix (1999).cs.srt"]
assert (new_dir / "Matrix (1999).cs.srt").exists()
assert not (old_dir / "Matrix.cs.srt").exists()
def test_noop_when_path_unchanged(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
assert rename_subtitles_with(video, video) == []
assert (tmp_path / "Matrix.cs.srt").exists()
def test_no_subtitles_is_noop(self, tmp_path):
old_video = _video(tmp_path, "Matrix.mkv")
new_video = tmp_path / "Matrix (1999).mkv"
assert rename_subtitles_with(old_video, new_video) == []