From c399a6aed35af6f89e49253d0fce3b9b90e25624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Sun, 13 Sep 2026 17:16:48 +0200 Subject: [PATCH] =?UTF-8?q?Add=20movie-keyed=20pool=20index=20with=20multi?= =?UTF-8?q?-version=20movies,=20=C4=8CSFD=20title=20bridge,=20subtitles=20?= =?UTF-8?q?and=20miniDLNA=20library=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 192 +++++++- PROJECT.md | 129 ++++-- src/core/csfd.py | 421 +++++++++++++++++ src/core/file.py | 136 +++++- src/core/file_manager.py | 590 +++++++++++++++++++++-- src/core/hardlink_manager.py | 21 + src/core/legacy_meta.py | 106 +++++ src/core/migrate.py | 1 + src/core/movie.py | 251 ++++++++++ src/core/naming.py | 56 ++- src/core/nfo.py | 25 + src/core/pool_index.py | 145 +++++- src/core/series.py | 57 ++- src/core/subtitles.py | 150 ++++++ src/ui/qt_app.py | 823 +++++++++++++++++++++++++++++++-- tests/test_csfd.py | 255 ++++++++++ tests/test_file.py | 8 +- tests/test_file_manager.py | 654 +++++++++++++++++++++++++- tests/test_hardlink_manager.py | 27 ++ tests/test_legacy_meta.py | 104 +++++ tests/test_movie.py | 155 +++++++ tests/test_naming.py | 39 ++ tests/test_nfo.py | 29 ++ tests/test_pool_index.py | 167 ++++++- tests/test_series.py | 154 +++++- tests/test_subtitles.py | 161 +++++++ 26 files changed, 4635 insertions(+), 221 deletions(-) create mode 100644 src/core/csfd.py create mode 100644 src/core/legacy_meta.py create mode 100644 src/core/movie.py create mode 100644 src/core/subtitles.py create mode 100644 tests/test_csfd.py create mode 100644 tests/test_legacy_meta.py create mode 100644 tests/test_movie.py create mode 100644 tests/test_subtitles.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0344b16..0f017a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,125 @@ Each version entry uses these sections (include only those that apply): ## 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--<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 +- **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 **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 @@ -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. - **Č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*. - 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 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 @@ -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. - **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz** (genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow). -- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext` - (was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The - `SxxEyy` code is stripped from the original before re-applying, so repeated - renames don't double it (`series.episode_body`); `episode_filename` / - `is_canonical_episode` and the series import/rename/edit paths follow. +- **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/ + Season NN/`. The release string is deliberately dropped — identity is the + `SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb + (matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title + collapses to `SxxEyy.ext`. `episode_filename` takes the series title, + `is_canonical_episode(path, series_title)`, and import / rename / edit all + follow (`series.sanitize_title`). ### Fixed - **Movies can live in mixed libraries.** The import/edit/move dialogs and the diff --git a/PROJECT.md b/PROJECT.md index 61a38ff..533dc00 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -66,6 +66,16 @@ movie table, and one-click Filmotéka generation. - **Metadata storage:** one **unified metadata file** for the whole pool (a central index), not per-file sidecars. Justified because Curator owns the pool and files are never moved manually, so it is not exposed to path drift. +- **The record is the movie, files are its children** (index **v2**, + `movie.py`). Keyed by identity (`tmdb-<id>`, else a `name-<slug>` fallback), + each record holds the metadata *and* a `media` list of its files with a free + `label` per version. Chosen after the v1 path-keyed shape made multi-version + movies unworkable: metadata rode on one file, so it had to be rescued when + that file went, and retitling a grouped movie had to be forbidden. With + identity as the key, `Movie.desired_layout()` is a pure function of the + record — **one video → flat, two or more → own folder** — so promote, demote + and rename are the same operation, and an import can recognise "this tmdbid + is already in the pool" instead of silently creating a duplicate. - **Config split by scope:** the global config (`.Curator.!gtag`) holds **app data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir` pointers (which must stay global: the index is found *through* them). Everything @@ -182,11 +192,29 @@ library manager**. Agreed shape: `Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda` (homevideo). Kids are **not** a separate folder — see decisions. - **Naming convention:** - - Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. - - Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext` - (season 0 → `Specials`); the episode keeps its original filename after the - `SxxEyy` code. Episode numbers are recognised from the filename; editable in - the series GUI. + - Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D + movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's + own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the + edit dialog and preserved across every future rename like the tmdbid is. + - **Multiple movie versions** (2D+3D, 4K+1080p, …): identity is + `(title, rok, tmdbid)`, stays a **flat file** at one version. A second + version promotes it to Jellyfin's required per-movie folder — every file + prefixed with the exact folder name — `Title (rok) [tmdbid]/Title (rok) + [tmdbid] - {popisek}.ext`; a 3D version's popisek doubles as its 3D tag + (`3D HSBS`, scanned by Jellyfin the same as the flat `.3D.HSBS` form). + Curator does the promotion/demotion itself (*Upravit film → Verze*); 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 `tmdbid` (yearless title search picks the wrong film, hence the earlier "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) writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall. Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is - kept **only** as an optional CZ-title source for the filename (not for tags/ - matching — it exposes no TMDb/IMDb id, verified). + kept **only** as a title source (not for tags/matching — it exposes no + TMDb/IMDb id, verified): it feeds the **ČSFD → TMDb bridge** (`csfd.py`, + `FileManager.resolve_via_csfd`) — filename → ČSFD search → ČSFD *original* + title + year → TMDb search → tmdbid, more reliable than a raw Czech-title + TMDb search. Used by the import dialog ("Dohledat přes ČSFD") and by migration + for any movie with a stored `csfd_url`. - **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link** - (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, 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; 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) @@ -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]; (5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs + -library organizer (unified `libraries` model with kinds) [done]. -Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs); -**(9) mixed-library scanning + generation** (movies + series in one folder). +library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library +scanning + generation (movies + series in one folder) [done]. +Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs). Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok) [tmdbid].ext`); per-movie folders are a later refinement. @@ -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). - `homevideo` library scanning: load its files as items (flat), no TMDb naming, no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle. + **miniDLNA generation for `homevideo` needs none of this** — it hardcopies the + whole library folder as-is (`mirror_as_is_libraries` **[done]**); this task is + only about GUI import/browsing. - Tests: import with no year/id lands correctly; homevideo library loads. ### Phase 9 — mixed-library scanning + generation -- Scan a `mixed` library by structure: a top-level **single video file = movie**; - a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse - `series.scan_series` for the series part and the flat movie scan for the rest. -- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`, - dispatch by kind); today they read `movie_libraries` / `copyasis_folders`. -- miniDLNA generation + NFO refresh must cover mixed libraries too. -- The library filter / table should surface mixed libraries once loadable. -- Tests: a mixed folder with both a movie and a series loads/generates correctly. +- Scanning **[done]**: a `mixed` library's top-level files load as movies + (`load_pool_movies`), its subfolders as series (`load_series` / + `series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`). + Both are kind-aware over the unified `libraries` list. The library filter / + table already surfaces mixed libraries. +- miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as + usual; series in its subfolders have no genre tags, so they're hardcopied + straight into `output/Seriály/<název>/…` instead + (`FileManager.minidlna_scope` → the mixed-library series list; the mirror + loop in `generate_filmoteka`). NFO refresh needs no separate handling — it + only ever touches movies (`self.filelist`), series never go through NFO/TMDb + tagging, mixed or not. +- Tests: a mixed folder with both a movie and a series loads/generates + correctly **[done]** (`TestMinidlnaScope` in `test_file_manager.py`). ### Later / optional -- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good - when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the - Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the - filename in the GUI. The ČSFD **link** is already stored per movie - (`File.csfd_url`, set at import / in the edit dialog) as a pointer for later — - revisit fetching only if TMDb proves insufficient in practice. -- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files. +- **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was + matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW + solver, film-page + search parsing, `original_title` extraction — the entry in + `.film-names` whose flag matches the country of origin). `resolve_via_csfd` + drives it from the import dialog and from migration. **Not** a return of ČSFD + tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch + "look up ČSFD links for the whole pool" as a migration pre-pass (today the + `csfd_url` is per-movie, set at import / in the edit dialog). +- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat + files — **partially done**: a movie only gets its own folder once it has a + second playable version (see "Multiple movie versions" below); a single-file + movie stays flat as before. - Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO. - Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs path-aware `library_of` (currently single-segment). @@ -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 libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs +- Phase 9 — mixed-library scanning + generation: a `mixed` library's top-level + movies genre-tag as usual, series in its subfolders hardcopy into + `output/Seriály/<název>/…` (no genre tags apply to a series) - Library organizer (`libraries` unified model with kinds movie/series/mixed/ homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived - Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at diff --git a/src/core/csfd.py b/src/core/csfd.py new file mode 100644 index 0000000..3e6d2a6 --- /dev/null +++ b/src/core/csfd.py @@ -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 diff --git a/src/core/file.py b/src/core/file.py index 0113c86..ea1f626 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -33,15 +33,64 @@ class File: # free-form per-movie attributes (e.g. "collection_sort"); usable in # miniDLNA filename templates via name_context. self.attributes: dict[str, str] = {} + # Identity key of the movie record this file belongs to (set on load / + # first save); ``None`` only for an index-less, in-memory File. + self._movie_key: str | None = None self.get_metadata() + def _identity_from_path(self) -> tuple[str, str, str | None, int | None, str]: + """``(key, title, year, tmdb_id, label)`` implied by this file's location. + + A file inside its own ``Title (rok) [tmdbid]`` folder is one version of + that movie — identity comes from the **folder**, and the rest of its own + stem is the version label. A flat file carries both in its own name. + """ + from .movie import identity_key + from .naming import (is_canonical_pool_stem, parse_3d_format, + parse_pool_stem, parse_tmdb_id) + + parent = self.file_path.parent.name + grouped = is_canonical_pool_stem(parent) + identity_stem = parent if grouped else self.file_path.stem + parsed = parse_pool_stem(identity_stem) + year = str(parsed[1]) if parsed else None + tmdb_id = parse_tmdb_id(identity_stem) + title = self.title or (parsed[0] if parsed else self.file_path.stem) + + label = "" + 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: - record = self.index.get(self.file_path) if self.index is not None else None - if record is None: + """Load this file's metadata from the movie record that owns it. + + Since the pool index became identity-keyed (see :mod:`src.core.movie`), + a File is a *view* on one media entry of a movie record — every version + of the same film therefore reads the same title/tags/ČSFD/… A file the + index doesn't know yet joins the movie of the same identity when there + is one (that's how a newly added version inherits the shared metadata + instead of blanking it), otherwise it starts a new record. + """ + if self.index is None: self._init_new_metadata() - self.save_metadata() - else: + return + + found = self.index.find_movie_by_media(self.file_path) + if found is not None: + self._movie_key, record = found self._apply_record(record) + return + + self._init_new_metadata() + key, *_ = self._identity_from_path() + existing = self.index.get_movie(key) + if existing is not None: + self._movie_key = key + self._apply_record(existing) + self.save_metadata() def _init_new_metadata(self) -> None: self.new = True @@ -101,9 +150,42 @@ class File: self.tags.append(tag) def save_metadata(self): - """Persist metadata to the pool index; a no-op for an index-less File.""" - if self.index is not None: - self.index.set(self.file_path, self._build_record()) + """Persist metadata into this file's movie record (no-op without index). + + Writes the shared fields onto the movie record and makes sure the + record lists this file among its media. A file the index has never seen + starts a new record under its own identity key. + """ + if self.index is None: + return + from .movie import ROLE_VIDEO + + derived_key, title, year, tmdb_id, label = self._identity_from_path() + key = self._movie_key or derived_key + record = self.index.get_movie(key) or {} + record.update(self._build_record()) + # The file's actual location is the truth — a move must update this. + record["library"] = self._library_name() + record["title"] = title + record["year"] = year + record["tmdb_id"] = tmdb_id + + # Keep this file listed exactly once, with the label its name implies. + rel = self.index._key(self.file_path) + media = [m for m in record.get("media", []) if m.get("file") != rel] + media.append({"role": ROLE_VIDEO, "label": label, "file": rel}) + record["media"] = media + + self.index.set_movie(key, record) + self._movie_key = key + + def _library_name(self) -> str: + """Top-level pool folder this file sits in (its library), or ``""``.""" + if self.index is None: + return "" + rel = self.index._key(self.file_path) + parts = Path(rel).parts + return parts[0] if len(parts) > 1 else "" def added_timestamp(self) -> float: """Epoch seconds for 'date added' — the stored ``added`` or the file mtime. @@ -169,19 +251,43 @@ class File: self.save_metadata() def delete_metadata(self) -> None: - """Remove this file's metadata from the pool index (no-op without one).""" - if self.index is not None: - self.index.delete(self.file_path) + """Drop this file from its movie record; the last one takes the record. + + Removing one version of a multi-version movie leaves the movie (and its + metadata) in place — only when its last media entry goes does the whole + record go with it. + """ + if self.index is None: + return + found = self.index.find_movie_by_media(self.file_path) + if found is None: + return + key, record = found + rel = self.index._key(self.file_path) + record["media"] = [m for m in record.get("media", []) if m.get("file") != rel] + if record["media"]: + self.index.set_movie(key, record) + else: + self.index.delete_movie(key) def relocate(self, new_path: Path) -> None: - """Point this File at a new path, moving its 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. - Drops the metadata under the old index key and rebinds to the new path; - call ``save_metadata()`` afterwards to write it back. + The physical file must already have been moved/renamed by the caller; + this only repoints the movie record's media entry (and the File), so a + rename never detaches a file from its movie. """ if self.index is not None: - 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.filename = self.file_path.name diff --git a/src/core/file_manager.py b/src/core/file_manager.py index f608d99..8f71c30 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -7,7 +7,10 @@ from .tag_manager import TagManager from .pool_index import PoolIndex from .utils import list_files 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 src.core.config import ( load_global_config, save_global_config, DEFAULT_TAG_SCHEMA @@ -168,6 +171,118 @@ class FileManager: """Names of libraries matching any of ``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 def copyasis_folders(self) -> list[str]: """Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries.""" @@ -334,7 +449,7 @@ class FileManager: Returns a dict with: 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 index_count / disk_count — totals for the summary """ @@ -344,7 +459,14 @@ class FileManager: "missing": [], "untracked": []} 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() for lib in self.libraries: @@ -518,6 +640,7 @@ class FileManager: move: bool = False, on_conflict: str = "suffix", year: int | str | None = None, tmdb_id: int | str | None = None, library: str | None = None, csfd_url: str | None = None, + format_3d: str | None = None, ) -> File | None: """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 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``. + ``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`` the source file is moved into the pool instead, leaving nothing behind. @@ -555,7 +680,7 @@ class FileManager: source = Path(source) safe_title = title.strip() or source.stem 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) target = movies / f"{stem}{source.suffix}" @@ -600,7 +725,9 @@ class FileManager: extension is preserved; ``new_title`` is the bare name without it. 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. """ new_title = new_title.strip() @@ -608,6 +735,10 @@ class FileManager: raise ValueError("Název nesmí být prázdný.") if "/" in new_title or "\\" in new_title: 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 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.") old_path.rename(new_path) + subtitles.rename_subtitles_with(old_path, new_path) file_obj.relocate(new_path) file_obj.title = new_title file_obj.save_metadata() @@ -626,13 +758,15 @@ class FileManager: self.on_files_changed(self.filelist) 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``. Uses the clean title and the year from the movie's metadata (``name_context``: cache → ``Rok`` tag → the current filename). A ``(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. """ ctx = file_obj.name_context() @@ -644,13 +778,14 @@ class FileManager: title = parsed[0] 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) + format_3d = parse_3d_format(file_obj.file_path.stem) if include_3d else None title = title.replace("/", "-").replace("\\", "-").strip() if not (title and is_valid_year(year)): 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: """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 ``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title (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: 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: 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) if new_stem is None: return None @@ -680,6 +820,7 @@ class FileManager: parsed = parse_pool_stem(new_stem) old_path.rename(new_path) + subtitles.rename_subtitles_with(old_path, new_path) file_obj.relocate(new_path) if parsed is not None: file_obj.title = parsed[0] @@ -740,7 +881,9 @@ class FileManager: Moves the file into ``pool/<new_library>/`` and re-indexes it under the 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. """ pool = self.pool_dir @@ -749,6 +892,10 @@ class FileManager: new_library = new_library.strip() if new_library not in self.libraries_accepting_movies: 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 dest_dir = pool / new_library @@ -760,6 +907,7 @@ class FileManager: raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.") 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.save_metadata() @@ -772,13 +920,19 @@ class FileManager: tmdb_id: int | str | None = None, library: str | None = None, date: str | None = None, attributes: dict | None = None, tags: list[str] | None = None, csfd_url: str | None = None, + format_3d: str | None = None, ) -> File: """Apply all editable parameters of a movie at once (rename/move + metadata). - Renames the file to ``Title (rok) [tmdbid].ext`` (year permitting), moves - it into ``library``, and sets ``title`` / ``date`` / ``attributes`` / - ``tags`` (a full ``["Category/Name", …]`` list; NFO-tag provenance is kept - for the ones that remain). No-ops that don't change the path skip the move. + Renames the file to ``Title (rok) [tmdbid].3D.<FORMAT>.ext`` (year/3D + permitting), moves it into ``library``, and sets ``title`` / ``date`` / + ``attributes`` / ``tags`` (a full ``["Category/Name", …]`` list; + 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: FileExistsError: the target name is already taken. @@ -788,18 +942,34 @@ class FileManager: raise RuntimeError("Pool není nastaven.") title = (title or "").strip() or file_obj.file_path.stem 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) lib = ((library or "").strip() or self.library_of(file_obj) or self.movie_libraries[0]) old_path = file_obj.file_path - 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)) - file_obj.relocate(new_path) + + if grouped: + # Identity is locked to the container folder's name; only a + # no-op (title/year/tmdb_id/library all unchanged) is allowed + # here — metadata-only fields below still apply normally. + if stem != old_path.parent.name or lib != self.library_of(file_obj): + 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 if date is not None: @@ -826,6 +996,218 @@ class FileManager: self.on_files_changed(self.filelist) 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( self, files: list[File] | 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, "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( self, files: list[File] | 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. 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``. """ - from . import tmdb + from . import csfd, tmdb from .migrate import MatchProposal, classify targets = files if files is not None else self.filelist proposals: list = [] total = len(targets) + csfd_session = None for i, f in enumerate(targets, 1): if should_cancel is not None and should_cancel(): break @@ -896,14 +1319,28 @@ class FileManager: if parsed is not None: title = parsed[0] 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: candidates = tmdb.search_movie(title, year=year, limit=5) status, chosen = classify(title, year, candidates) 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: 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: on_progress(i, total, f) return proposals @@ -973,17 +1410,21 @@ class FileManager: self, sources: list[Path], title: str, year: int | str | None = None, tmdb_id: int | str | None = None, library: str | None = None, move: bool = False, on_conflict: str = "suffix", + codes: dict[Path, tuple[int, int]] | None = None, ) -> dict: """Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``. - Each source's season/episode is parsed from its filename and it is copied - (or moved) in as ``Series SxxEyy.ext``. Files with no recognisable - ``SxxEyy`` are skipped and reported. + Each source's season/episode comes from ``codes`` (an explicit + ``{path: (season, episode)}`` override set at import time) when present, + 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}``. """ from .series import ( parse_episode, series_folder_stem, season_folder_name, episode_filename) + codes = codes or {} pool = self.pool_dir if pool is None: raise RuntimeError("Pool není nastaven.") @@ -996,21 +1437,21 @@ class FileManager: errors: list[str] = [] for src in sources: src = Path(src) - code = parse_episode(src.name) + code = codes.get(src) or parse_episode(src.name) if code is None: skipped_no_code.append(src.name) continue season, ep = code dest_dir = pool / lib / stem / season_folder_name(season) 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 on_conflict == "skip": continue if on_conflict == "replace": target.unlink() else: # suffix - base = episode_filename(season, ep, src.stem, "") + base = episode_filename(season, ep, title, "") counter = 1 while target.exists(): target = dest_dir / f"{base}_{counter}{src.suffix}" @@ -1027,7 +1468,7 @@ class FileManager: "errors": errors} 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 skipped; a target name already taken is reported as a conflict. @@ -1044,7 +1485,7 @@ class FileManager: continue dest_dir = series.folder / season_folder_name(ep.season) 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: continue if new_path.exists(): @@ -1052,14 +1493,64 @@ class FileManager: continue try: dest_dir.mkdir(parents=True, exist_ok=True) - old_name = ep.path.name - ep.path.rename(new_path) + old_path = ep.path + old_path.rename(new_path) + subtitles.rename_subtitles_with(old_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: conflicts.append((ep.path.name, str(exc))) 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( self, series, *, title: str, year: int | str | None = None, tmdb_id: int | str | None = None, library: str | None = None, @@ -1096,6 +1587,27 @@ class FileManager: else {"renamed": [], "skipped": [], "conflicts": []}) 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): """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" for f in files_objs: diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py index be5d2bb..6516897 100644 --- a/src/core/hardlink_manager.py +++ b/src/core/hardlink_manager.py @@ -19,6 +19,7 @@ Example: """ import os import random +import shutil from collections import defaultdict from pathlib import Path from typing import List, Tuple, Optional, Dict, Set @@ -341,6 +342,26 @@ class HardlinkManager: return success_count, fail_count + def remove_mirror(self, name: str) -> bool: + """Remove ``output_dir/name`` entirely (file or folder tree). + + Used to drop a whole mirrored library (or one entry of the shared + mixed-library-series bucket) when it falls out of the miniDLNA + generation's scope. Mirrors are pure hardlinks, so deleting the folder + loses nothing — it's fully regenerable from the pool. ``name`` may + contain "/" to address one entry inside a bucket folder. + + Returns whether anything was actually removed. + """ + target = self.output_dir / name + if not target.exists(): + return False + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + return True + def _fill_special_folder( self, chosen: List[File], subfolder: str, dry_run: bool ) -> Tuple[int, int]: diff --git a/src/core/legacy_meta.py b/src/core/legacy_meta.py new file mode 100644 index 0000000..62c75e9 --- /dev/null +++ b/src/core/legacy_meta.py @@ -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) diff --git a/src/core/migrate.py b/src/core/migrate.py index 41dbfc2..859d969 100644 --- a/src/core/migrate.py +++ b/src/core/migrate.py @@ -27,6 +27,7 @@ class MatchProposal: chosen: object = None # tmdb.TmdbResult preselected for the user, or None candidates: list = field(default_factory=list) # list[tmdb.TmdbResult] error: str = "" + note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)" def normalize(text: str) -> str: diff --git a/src/core/movie.py b/src/core/movie.py new file mode 100644 index 0000000..4eb8771 --- /dev/null +++ b/src/core/movie.py @@ -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 diff --git a/src/core/naming.py b/src/core/naming.py index 7739cbd..c3ac252 100644 --- a/src/core/naming.py +++ b/src/core/naming.py @@ -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 is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year -lets two same-named films coexist and makes the filename self-describing. These -helpers build, validate and parse the stem (the filename without its extension). +lets two same-named films coexist and makes the filename self-describing. + +A 3D movie additionally carries a ``.3D.<FORMAT>`` tag right before the +extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/): +``Title (YYYY) [tmdbid-N].3D.HSBS.ext``. ``FORMAT`` is one of +:data:`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always +writes it upper-case for a single canonical form. + +These helpers build, validate and parse the stem (the filename without its +extension). """ import re -# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The -# title is non-greedy so a title that itself contains parentheses keeps them: -# "Kurz (special) (2019)" → title "Kurz (special)", year 2019. +# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator +# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab = +# half/full top-and-bottom, mvc = Multiview Video Coding. +THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC") + +# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and +# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that +# itself contains parentheses keeps them: "Kurz (special) (2019)" → title +# "Kurz (special)", year 2019. _POOL_STEM_RE = re.compile( - r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$") + r"^(?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: @@ -21,12 +37,16 @@ def is_valid_year(year: object) -> bool: return bool(re.fullmatch(r"\d{4}", str(year).strip())) -def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str: - """Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given). +def canonical_pool_stem( + title: str, year: object, tmdb_id: object = None, format_3d: object = None, +) -> str: + """Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` / + ``.3D.<FORMAT>`` if given). Raises: - ValueError: the title is empty, the year is not four digits, or a given - ``tmdb_id`` is not a positive integer. + ValueError: the title is empty, the year is not four digits, a given + ``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one + of :data:`THREE_D_FORMATS`. """ title = (title or "").strip() year_s = str(year).strip() @@ -40,6 +60,13 @@ def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str if not id_s.isdigit(): raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.") stem += f" [tmdbid-{id_s}]" + if format_3d is not None and str(format_3d).strip() != "": + fmt = str(format_3d).strip().upper() + if fmt not in THREE_D_FORMATS: + raise ValueError( + f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, " + f"ne „{format_3d}“.") + stem += f".3D.{fmt}" return stem @@ -63,6 +90,15 @@ def parse_tmdb_id(stem: str) -> int | None: return int(match.group("id")) +def parse_3d_format(stem: str) -> str | None: + """Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or + ``None`` if absent.""" + match = _POOL_STEM_RE.match((stem or "").strip()) + if not match or not match.group("format3d"): + return None + return match.group("format3d").upper() + + def is_canonical_pool_stem(stem: str) -> bool: """True when ``stem`` matches the ``Title (YYYY)`` convention.""" return _POOL_STEM_RE.match((stem or "").strip()) is not None diff --git a/src/core/nfo.py b/src/core/nfo.py index b58dde8..a633c48 100644 --- a/src/core/nfo.py +++ b/src/core/nfo.py @@ -26,6 +26,7 @@ class NfoData: countries: list[str] = field(default_factory=list) rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or 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]: """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 +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]: """Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken.""" path = find_nfo(video_path) @@ -98,4 +122,5 @@ def read_nfo(video_path: Path) -> Optional[NfoData]: countries=_texts(root, "country"), rating=_parse_rating(root), tmdb_id=_parse_tmdb_id(root), + csfd_url=_parse_csfd_url(root), ) diff --git a/src/core/pool_index.py b/src/core/pool_index.py index 599b6ed..4caca08 100644 --- a/src/core/pool_index.py +++ b/src/core/pool_index.py @@ -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 (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), -e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines. +Records are keyed by the movie's **identity** (``tmdb-<id>``, or a title/year +slug when there's no id) and own their files — see :mod:`src.core.movie`. The +original **v1** shape keyed a record by one file's pool-relative path, which +made metadata a passenger on that file; :meth:`PoolIndex.load` migrates such an +index in place (backing the old file up first). Migration only rewrites +metadata — no file on disk is moved, because a one-video movie is already in +its v2 position. """ import json +import shutil from pathlib import Path INDEX_FILENAME = ".Curator.!index" +INDEX_VERSION = 2 class PoolIndex: @@ -42,7 +51,11 @@ class PoolIndex: return path.as_posix() 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(): self.records = {} self.settings = {} @@ -55,13 +68,82 @@ class PoolIndex: except (json.JSONDecodeError, OSError): self.records = {} self.settings = {} + return + + if int(data.get("version", 1)) < INDEX_VERSION: + self._migrate_v1_to_v2() + + def _migrate_v1_to_v2(self) -> None: + """Re-key path-keyed records into identity-keyed movie records. + + Each v1 record described one file; several of them can describe one + movie (the interim multi-version layout put them in a shared folder), + so they are merged by identity. Metadata only — nothing is moved on + disk, since a v1 movie's files already sit where v2 wants them. + """ + from .movie import ROLE_VIDEO, identity_key + from .naming import is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id + + migrated: dict[str, dict] = {} + for rel_path, record in self.records.items(): + if not isinstance(record, dict) or "media" in record: + continue # already v2-shaped, leave it alone + path = Path(rel_path) + parts = path.parts + library = parts[0] if len(parts) > 1 else "" + # A file inside its own "Title (rok) [tmdbid]" folder is one + # version of that movie; its own stem carries the " - label". + parent = path.parent.name + grouped = len(parts) > 2 and is_canonical_pool_stem(parent) + identity_stem = parent if grouped else path.stem + + parsed = parse_pool_stem(identity_stem) + title = record.get("title") or (parsed[0] if parsed else path.stem) + year = str(parsed[1]) if parsed else None + tmdb_id = parse_tmdb_id(identity_stem) + label = "" + if grouped and path.stem.startswith(f"{parent} - "): + label = path.stem[len(parent) + 3:] + elif grouped: + label = path.stem[len(parent):].lstrip(". -") or "" + + key = identity_key(tmdb_id, title, year) + entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path} + if key in migrated: + migrated[key]["media"].append(entry) + continue + migrated[key] = { + "title": title, + "year": year, + "tmdb_id": tmdb_id, + "library": library, + "media": [entry], + "tags": record.get("tags", []), + "auto_tags": record.get("auto_tags", record.get("csfd_tags", [])), + "csfd_url": record.get("csfd_url") or record.get("csfd_link"), + "date": record.get("date"), + "added": record.get("added"), + "attributes": record.get("attributes", {}), + } + if record.get("meta"): + migrated[key]["meta"] = record["meta"] + + try: + shutil.copy2(self.index_path, + self.index_path.with_name(self.index_path.name + ".v1.bak")) + except OSError: + pass + self.records = migrated + self.save() def save(self) -> None: """Persist the index to disk.""" self.pool_dir.mkdir(parents=True, exist_ok=True) with open(self.index_path, "w", encoding="utf-8") as f: json.dump( - {"settings": self.settings, "movies": self.records}, + {"version": INDEX_VERSION, + "settings": self.settings, + "movies": self.records}, f, indent=2, ensure_ascii=False, ) @@ -74,16 +156,49 @@ class PoolIndex: self.settings[key] = value self.save() - def get(self, file_path: Path) -> dict | None: - """Return the record for a file, or None if it is not indexed.""" - return self.records.get(self._key(file_path)) + # ------------------------------------------------------------------ + # Movie records (v2 — keyed by identity, own their media files) + # ------------------------------------------------------------------ - def set(self, file_path: Path, data: dict) -> None: - """Upsert a record and persist the index.""" - self.records[self._key(file_path)] = data + def get_movie(self, key: str) -> dict | None: + """Return the movie record under ``key``, or None.""" + return self.records.get(key) + + def set_movie(self, key: str, data: dict) -> None: + """Upsert a movie record and persist the index.""" + self.records[key] = data self.save() - def delete(self, file_path: Path) -> None: - """Remove a record (if present) and persist the index.""" - if self.records.pop(self._key(file_path), None) is not None: + def delete_movie(self, key: str) -> None: + """Remove a movie record (if present) and persist the index.""" + if self.records.pop(key, None) is not None: self.save() + + def rekey_movie(self, old_key: str, new_key: str) -> None: + """Move a record to a new identity key (e.g. its TMDb id was set/fixed).""" + if old_key == new_key or old_key not in self.records: + return + self.records[new_key] = self.records.pop(old_key) + self.save() + + def all_movies(self) -> dict[str, dict]: + """Every movie record, keyed by identity.""" + return dict(self.records) + + def record_for(self, file_path: Path) -> dict | None: + """The movie record that owns ``file_path``, or None if untracked.""" + found = self.find_movie_by_media(file_path) + return found[1] if found is not None else None + + def find_movie_by_media(self, file_path: Path) -> tuple[str, dict] | None: + """``(key, record)`` of the movie owning ``file_path``, or None. + + The reverse lookup a file needs now that records are keyed by identity + rather than by path. + """ + rel = self._key(file_path) + for key, record in self.records.items(): + for media in record.get("media", []): + if media.get("file") == rel: + return key, record + return None diff --git a/src/core/series.py b/src/core/series.py index 6a47ce2..4449bf7 100644 --- a/src/core/series.py +++ b/src/core/series.py @@ -71,31 +71,52 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]: 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: """Folder name for a season — ``Specials`` for 0, else ``Season NN``.""" return "Specials" if season == 0 else f"Season {season:02d}" -def episode_body(stem: str) -> str: - """The descriptive part of an episode filename, minus a leading ``SxxEyy`` code. - - 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 is_season_folder(name: str) -> bool: + """True for a season folder name — ``Specials`` or ``Season <n>`` (any padding).""" + return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None -def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str: - """Canonical episode filename ``SxxEyy - {original name}.ext``. +# Characters not allowed in a filename on the common filesystems. +_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}" - body = episode_body(orig_stem) + body = sanitize_title(series_title) 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 -def is_canonical_episode(path: Path) -> bool: - """True when ``path`` already follows ``SxxEyy - name`` in the right season.""" +def is_canonical_episode(path: Path, series_title: str) -> bool: + """True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season.""" parsed = parse_episode(path.name) if parsed is None: return False season, episode = parsed - expected = episode_filename(season, episode, path.stem, path.suffix) + expected = episode_filename(season, episode, series_title, path.suffix) if path.name != expected: return False return path.parent.name == season_folder_name(season) diff --git a/src/core/subtitles.py b/src/core/subtitles.py new file mode 100644 index 0000000..d725ed4 --- /dev/null +++ b/src/core/subtitles.py @@ -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 diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 134171c..73aa1a7 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -21,7 +21,7 @@ from PySide6.QtWidgets import ( QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog, - QFormLayout, QSpinBox, + QFormLayout, QSpinBox, QListWidget, QListWidgetItem, ) from src.core.file_manager import FileManager @@ -31,7 +31,8 @@ from src.core.tag import Tag from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager from src.core.naming import ( - canonical_pool_stem, is_valid_year, parse_tmdb_id, year_from_filename) + canonical_pool_stem, is_valid_year, parse_3d_format, parse_pool_stem, + parse_tmdb_id, year_from_filename, THREE_D_FORMATS) from src.core import series # Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV @@ -40,6 +41,10 @@ TIPS_SUBFOLDER = "- Tipy dne" # random selection, refreshed each run RECENT_SUBFOLDER = "- Nově přidané" # most recently added movies TIPS_COUNT = 15 # random tips RECENT_COUNT = 10 # newest by date added +# Series found inside a "mixed" library (movies + series in one folder) have no +# genre tags to run through the tag tree — they're hardcopied here instead, +# same as a dedicated series library's copy-as-is mirror. +MIXED_SERIES_SUBFOLDER = "Seriály" class ImportMoviesDialog(QDialog): @@ -63,8 +68,10 @@ class ImportMoviesDialog(QDialog): # Lower-cased canonical stems already present in the pool (collision check) self.existing_names = {n.lower() for n in (existing_names or set())} - # (source path, title field, year field, tmdb-id field) per row - self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit]] = [] + # (source path, title field, year field, tmdb-id field, ČSFD-link field) per row + self._rows: list[ + tuple[Path, QLineEdit, QLineEdit, QLineEdit, QLineEdit] + ] = [] layout = QVBoxLayout(self) @@ -97,6 +104,13 @@ class ImportMoviesDialog(QDialog): "Podle TMDb ID doplní název + rok; bez ID vyhledá podle názvu souboru.") find_btn.clicked.connect(self._autofill_tmdb) add_row.addWidget(find_btn) + csfd_btn = QPushButton("🎬 Dohledat přes ČSFD") + csfd_btn.setToolTip( + "Najde film na ČSFD (podle vyplněného odkazu, jinak podle názvu " + "souboru), vezme z něj původní název + rok a podle nich dohledá " + "TMDb ID. Vhodné, když přímé hledání na TMDb vrací špatný film.") + csfd_btn.clicked.connect(self._autofill_via_csfd) + add_row.addWidget(csfd_btn) add_row.addStretch(1) layout.addLayout(add_row) @@ -107,8 +121,16 @@ class ImportMoviesDialog(QDialog): opts_row.addWidget(self.library_combo) self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)") opts_row.addWidget(self.move_check) + self.skip_no_tmdb_check = QCheckBox("Vynechat řádky bez TMDb ID") + self.skip_no_tmdb_check.setToolTip( + "Při importu přeskočí soubory bez vyplněného TMDb ID " + "(pak ani nevadí chybějící rok). Přeskočené řádky jsou přeškrtnuté.") + self.skip_no_tmdb_check.toggled.connect(self._refresh_skip_marks) + opts_row.addWidget(self.skip_no_tmdb_check) opts_row.addStretch(1) layout.addLayout(opts_row) + # sources dropped by "Vynechat řádky bez TMDb ID" on accept + self._skipped_no_tmdb: list[Path] = [] buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) @@ -144,10 +166,19 @@ class ImportMoviesDialog(QDialog): tmdb_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), tmdb_edit)) tmdb_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) + tmdb_edit.textChanged.connect(lambda _t: self._refresh_skip_marks()) tmdb_edit.textEdited.connect(lambda _t, e=tmdb_edit: self._set_suggested(e, False)) csfd_edit = QLineEdit() - csfd_edit.setPlaceholderText("ČSFD odkaz (volitelné, jen uloží se)") + csfd_edit.setPlaceholderText("ČSFD odkaz (volitelné; uloží se + pro dohledání)") + csfd_edit.textEdited.connect( + lambda _t, e=csfd_edit: self._set_suggested(e, False)) + + # Pre-fill the row from metadata already sitting next to the source file: + # Curator's own pre-index sidecar (`.{name}.!tag`) / an old `.Curator.!index` + # first (authoritative for the ČSFD link), then a Kodi/Jellyfin `.nfo`. + # Everything filled this way is blue (suggested — verify). + self._prefill_row(source, title_edit, year_edit, tmdb_edit, csfd_edit) remove_btn = QPushButton("✕") remove_btn.setFixedWidth(28) @@ -161,12 +192,57 @@ class ImportMoviesDialog(QDialog): self._rows.append((source, title_edit, year_edit, tmdb_edit, csfd_edit)) self._mark_collision(title_edit) + def _prefill_row( + self, source: Path, title_edit: QLineEdit, year_edit: QLineEdit, + tmdb_edit: QLineEdit, csfd_edit: QLineEdit, + ) -> None: + """Seed a new import row from metadata already next to the source file. + + Curator's pre-index layouts win for the ČSFD link (a per-file + ``.{name}.!tag`` sidecar, or an old ``.Curator.!index`` up the tree); a + Kodi/Jellyfin ``.nfo`` fills what they don't carry (notably the TMDb id). + """ + from src.core import legacy_meta, nfo + + def fill(edit: QLineEdit, value: object) -> None: + if value and not edit.text().strip(): + edit.setText(str(value)) + self._set_suggested(edit, True) + + legacy = legacy_meta.read_legacy_meta(source) + if legacy is not None: + fill(csfd_edit, legacy.csfd_url) + fill(year_edit, legacy.year) + if legacy.title: + title_edit.setText(legacy.title) + + data = nfo.read_nfo(source) + if data is not None: + fill(csfd_edit, data.csfd_url) + fill(tmdb_edit, data.tmdb_id) + fill(year_edit, data.year) + if data.title and title_edit.text().strip() == source.stem: + title_edit.setText(data.title) + def _row_of(self, title_edit: QLineEdit): for entry in self._rows: if entry[1] is title_edit: return entry return None + def _refresh_skip_marks(self) -> None: + """Strike through rows that "Vynechat řádky bez TMDb ID" would drop.""" + active = self.skip_no_tmdb_check.isChecked() + for row, (_s, _t, _y, tmdb_edit, _c) in enumerate(self._rows): + item = self.table.item(row, 0) + if item is None: + continue + skipped = active and not tmdb_edit.text().strip() + font = item.font() + font.setStrikeOut(skipped) + font.setItalic(skipped) + item.setFont(font) + def _candidate_stem(self, title: str, year: str, tmdb_id: str) -> str | None: """The canonical 'Title (year) [tmdbid]' stem, or None if incomplete.""" if not (title.strip() and is_valid_year(year)): @@ -192,9 +268,9 @@ class ImportMoviesDialog(QDialog): "co s ním." if clashes else "") def _set_suggested(self, edit: QLineEdit, suggested: bool) -> None: - """Blue tint marks a value proposed by TMDb (not yet confirmed).""" + """Blue tint marks an auto-filled value (TMDb / ČSFD), not yet confirmed.""" edit.setStyleSheet("QLineEdit { color: #2980b9; }" if suggested else "") - edit.setToolTip("Navrženo z TMDb — ověř." if suggested else "") + edit.setToolTip("Navrženo automaticky — ověř." if suggested else "") def _remove_row(self, title_edit: QLineEdit) -> None: """Drop a single file from the import list without restarting the dialog.""" @@ -255,8 +331,94 @@ class ImportMoviesDialog(QDialog): QMessageBox.information( self, "TMDb", f"Doplněno {found} z {len(self._rows)} řádků.") + def _autofill_via_csfd(self) -> None: + """Fill ČSFD-odkaz / TMDb-id / Title / Year per row via the ČSFD bridge. + + Per row: use the ČSFD link if given, otherwise search ČSFD by the cleaned + filename. From the ČSFD film take the original title + year and search + TMDb by those (Czech title as a fallback). Filled values are blue + (suggested — verify). Rows that already have a value keep it. + """ + from src.core import csfd, tmdb + + if not csfd.dependencies_available(): + QMessageBox.warning( + self, "ČSFD", + "Chybí balíčky requests / beautifulsoup4 (spusť poetry install).") + return + + session = csfd.requests.Session() + found = 0 + errors: list[str] = [] + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows: + try: + url = csfd_edit.text().strip() or csfd.find_csfd_url( + csfd.clean_filename_to_query(source.name), session=session) + if not url: + continue + film = csfd.fetch_film(url, session=session) + if not csfd_edit.text().strip(): + csfd_edit.setText(url) + self._set_suggested(csfd_edit, True) + + q_title, q_year = film.tmdb_query + hits = (tmdb.search_movie(q_title, year=q_year, limit=1) + if tmdb.api_available() else []) + if (not hits and film.czech_title + and film.czech_title != q_title and tmdb.api_available()): + hits = tmdb.search_movie( + film.czech_title, year=q_year, limit=1) + hit = hits[0] if hits else None + + if hit is None: + # No TMDb match — still surface the ČSFD title/year. + if q_title: + title_edit.setText(q_title) + if q_year and not year_edit.text().strip(): + year_edit.setText(q_year) + self._set_suggested(year_edit, True) + continue + + found += 1 + if not tmdb_edit.text().strip(): + tmdb_edit.setText(str(hit.tmdb_id)) + self._set_suggested(tmdb_edit, True) + title_edit.setText(hit.title) + if hit.year: + year_edit.setText(hit.year) + self._set_suggested(year_edit, True) + except (csfd.CsfdError, tmdb.TmdbError, OSError) as exc: + errors.append(f"{source.name}: {exc}") + finally: + QApplication.restoreOverrideCursor() + session.close() + + msg = f"Přes ČSFD doplněno {found} z {len(self._rows)} řádků." + if errors: + QMessageBox.warning( + self, "ČSFD", msg + "\n\nChyby:\n" + "\n".join(errors[:5])) + else: + QMessageBox.information(self, "ČSFD", msg) + def accept(self) -> None: # noqa: D401 — Qt override - """Block the import until every row has a title and a four-digit year.""" + """Block the import until every row has a title and a four-digit year. + + With "Vynechat řádky bez TMDb ID" ticked, rows without a TMDb ID are + dropped first (and no longer need a year). + """ + self._skipped_no_tmdb = [] + if self.skip_no_tmdb_check.isChecked(): + kept = [r for r in self._rows if r[3].text().strip()] + self._skipped_no_tmdb = [r[0] for r in self._rows if not r[3].text().strip()] + if not kept: + QMessageBox.warning( + self, "Import", + "Žádný řádek nemá TMDb ID — není co importovat.") + return + self._rows = kept + bad_year: list[str] = [] for source, title_edit, year_edit, _tmdb, _csfd in self._rows: title = title_edit.text().strip() or source.stem @@ -276,6 +438,11 @@ class ImportMoviesDialog(QDialog): def move_files(self) -> bool: return self.move_check.isChecked() + @property + def skipped_no_tmdb(self) -> list[Path]: + """Sources dropped by "Vynechat řádky bez TMDb ID" (valid after accept).""" + return list(self._skipped_no_tmdb) + @property def selected_library(self) -> str: return self.library_combo.currentText() @@ -299,7 +466,7 @@ class ImportEpisodesDialog(QDialog): self.setWindowTitle("Importovat epizody seriálu") self.setMinimumSize(720, 460) self._libraries = libraries or ["Seriály"] - self._rows: list[tuple[Path, QTableWidgetItem]] = [] + self._rows: list[tuple[Path, QLineEdit]] = [] layout = QVBoxLayout(self) @@ -337,7 +504,9 @@ class ImportEpisodesDialog(QDialog): info = QLabel( "Epizody se uloží jako „Název SxxEyy.ext“ do Season složek. " - "Číslo sezóny/epizody se čte z názvu souboru.") + "Číslo sezóny/epizody se předvyplní z názvu souboru — nerozpoznané " + "(nebo špatně určené) dopiš ve sloupci SxxEyy (např. S01E05 / 1x05). " + "„Přidat složku…“ projde i všechny podsložky (Season 01, Season 02, …).") info.setWordWrap(True) layout.addWidget(info) @@ -354,6 +523,12 @@ class ImportEpisodesDialog(QDialog): add_btn = QPushButton("➕ Přidat epizody…") add_btn.clicked.connect(self._add_files) add_row.addWidget(add_btn) + folder_btn = QPushButton("📁 Přidat složku…") + folder_btn.setToolTip( + "Vyber složku seriálu — Curator projde i všechny podsložky " + "a přidá každý video soubor v nich.") + folder_btn.clicked.connect(self._add_folder) + add_row.addWidget(folder_btn) add_row.addStretch(1) layout.addLayout(add_row) @@ -375,20 +550,33 @@ class ImportEpisodesDialog(QDialog): name_item.setFlags(Qt.ItemIsEnabled) self.table.setItem(row, 0, name_item) code = series.parse_episode(source.name) - code_item = QTableWidgetItem( - f"S{code[0]:02d}E{code[1]:02d}" if code else "— nerozpoznáno") - code_item.setFlags(Qt.ItemIsEnabled) - if not code: - code_item.setForeground(Qt.red) - self.table.setItem(row, 1, code_item) + code_edit = QLineEdit(f"S{code[0]:02d}E{code[1]:02d}" if code else "") + code_edit.setPlaceholderText("SxxEyy") + code_edit.setMaximumWidth(120) + code_edit.textChanged.connect(lambda _t, e=code_edit: self._mark_code(e)) + self.table.setCellWidget(row, 1, code_edit) remove_btn = QPushButton("✕") remove_btn.setFixedWidth(28) remove_btn.clicked.connect(lambda _c, p=source: self._remove_row(p)) self.table.setCellWidget(row, 2, remove_btn) - self._rows.append((source, code_item)) + self._rows.append((source, code_edit)) + self._mark_code(code_edit) + + def _mark_code(self, edit: QLineEdit) -> None: + """Colour the SxxEyy field: green = valid, red = unparsable, plain = empty.""" + text = edit.text().strip() + if not text: + edit.setStyleSheet("") + edit.setToolTip("Prázdné — epizoda se přeskočí. Zadej např. S01E05.") + elif series.parse_episode(text) is None: + edit.setStyleSheet("QLineEdit { color: #c0392b; }") + edit.setToolTip("Nerozpoznaný formát — použij S01E05 nebo 1x05.") + else: + edit.setStyleSheet("QLineEdit { color: #27ae60; }") + edit.setToolTip("") def _remove_row(self, source: Path) -> None: - for index, (path, _item) in enumerate(self._rows): + for index, (path, _edit) in enumerate(self._rows): if path is source: self.table.removeRow(index) self._rows.pop(index) @@ -399,6 +587,33 @@ class ImportEpisodesDialog(QDialog): for path in paths: self._append_row(Path(path)) + def _add_folder(self) -> None: + """Add every video file under a picked folder (recursively, subfolders too).""" + folder = QFileDialog.getExistingDirectory(self, "Vyber složku se seriálem") + if not folder: + return + folder_path = Path(folder) + have = {path for path, _ in self._rows} + added = 0 + for path in series.collect_episode_files(folder_path): + if path not in have: + self._append_row(path) + added += 1 + if not added: + QMessageBox.information( + self, "Import epizod", + "Ve složce (ani podsložkách) nejsou žádné nové video soubory.") + return + # Seed the series identity from the folder name if still blank. + if not self.title_edit.text().strip(): + parsed = parse_pool_stem(folder_path.name) + self.title_edit.setText(parsed[0] if parsed else folder_path.name) + if parsed and not self.year_edit.text().strip(): + self.year_edit.setText(str(parsed[1])) + tid = parse_tmdb_id(folder_path.name) + if tid and not self.tmdb_edit.text().strip(): + self.tmdb_edit.setText(str(tid)) + def _autofill_tmdb(self) -> None: from src.core import tmdb if not tmdb.api_available(): @@ -426,9 +641,24 @@ class ImportEpisodesDialog(QDialog): self.year_edit.setText(hit.year) def accept(self) -> None: # noqa: D401 — Qt override + if not self._rows: + QMessageBox.warning( + self, "Seriál", "Přidej aspoň jednu epizodu (soubor nebo složku).") + return if not self.title_edit.text().strip(): QMessageBox.warning(self, "Seriál", "Zadej název seriálu.") return + missing = [p.name for p, edit in self._rows + if series.parse_episode(edit.text().strip()) is None] + if missing: + listing = "\n".join(f" • {n}" for n in missing[:10]) + more = f"\n … a další ({len(missing) - 10})" if len(missing) > 10 else "" + answer = QMessageBox.question( + self, "Chybí číslování", + f"{len(missing)} epizod nemá platné SxxEyy a při importu se " + f"přeskočí:\n{listing}{more}\n\nPokračovat?") + if answer != QMessageBox.Yes: + return super().accept() @property @@ -454,6 +684,20 @@ class ImportEpisodesDialog(QDialog): def sources(self) -> list[Path]: return [path for path, _ in self._rows] + def episode_codes(self) -> dict[Path, tuple[int, int]]: + """Per-file ``(season, episode)`` taken from the SxxEyy column. + + Covers what the user typed / corrected in the dialog; rows whose field + is empty or unparsable are omitted (they fall back to filename parsing + in ``import_episodes`` and are skipped if that fails too). + """ + codes: dict[Path, tuple[int, int]] = {} + for path, edit in self._rows: + parsed = series.parse_episode(edit.text().strip()) + if parsed is not None: + codes[path] = parsed + return codes + class SeriesOverviewDialog(QDialog): """Read-only Series → Season → Episode tree with a per-series rename action.""" @@ -479,6 +723,9 @@ class SeriesOverviewDialog(QDialog): rename_btn = QPushButton("Přejmenovat epizody") rename_btn.clicked.connect(self._rename_selected) btns.addWidget(rename_btn) + remove_btn = QPushButton("Odebrat z poolu…") + remove_btn.clicked.connect(self._remove_selected) + btns.addWidget(remove_btn) btns.addStretch(1) close_btn = QPushButton("Zavřít") close_btn.clicked.connect(self.accept) @@ -504,7 +751,7 @@ class SeriesOverviewDialog(QDialog): snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"]) top.addChild(snode) for ep in eps: - ok = series.is_canonical_episode(ep.path) + ok = series.is_canonical_episode(ep.path, ser.title) enode = QTreeWidgetItem( [f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"]) if not ok: @@ -530,6 +777,24 @@ class SeriesOverviewDialog(QDialog): f"Přejmenováno: {len(result['renamed'])}, " f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.") + def _remove_selected(self) -> None: + idx = self._selected_series_index() + if idx is None: + QMessageBox.information(self, "Seriály", "Vyber seriál.") + return + ser = self._series[idx] + answer = QMessageBox.question( + self, "Odebrat z poolu", + f"Smazat seriál „{ser.title}“ z poolu včetně všech sezón a epizod?") + if answer != QMessageBox.Yes: + return + try: + self.filehandler.remove_series(ser) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Chyba", str(exc)) + return + self._reload() + def _edit_selected(self) -> None: idx = self._selected_series_index() if idx is None: @@ -553,6 +818,51 @@ class SeriesOverviewDialog(QDialog): self._reload() +class VersionLabelDialog(QDialog): + """Pick a label for a new movie version — a 3D formát preset or free text. + + Jellyfin tells versions apart purely by this label text, so a 3D preset + just *is* the label (``3D HSBS``) — same tokens it already scans for in a + flat ``.3D.HSBS`` filename, just placed after " - " instead. + """ + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.setWindowTitle("Popisek verze") + self.setMinimumWidth(360) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel( + "Jak tuhle verzi Jellyfin rozliší od ostatních (uvidí ji v přehrávači " + "jako volbu).")) + + form = QFormLayout() + self.format3d_combo = QComboBox() + self.format3d_combo.addItem("— vlastní popisek —", None) + for fmt in THREE_D_FORMATS: + self.format3d_combo.addItem(f"3D {fmt}", fmt) + self.format3d_combo.currentIndexChanged.connect(self._sync_label) + form.addRow("3D formát:", self.format3d_combo) + + self.label_edit = QLineEdit() + self.label_edit.setPlaceholderText("např. 4K, Director's Cut") + form.addRow("Popisek:", self.label_edit) + layout.addLayout(form) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _sync_label(self) -> None: + fmt = self.format3d_combo.currentData() + self.label_edit.setEnabled(fmt is None) + self.label_edit.setText(f"3D {fmt}" if fmt else "") + + def label(self) -> str: + return self.label_edit.text().strip() + + class MovieEditDialog(QDialog): """One dialog for every editable parameter of a movie.""" @@ -574,13 +884,13 @@ class MovieEditDialog(QDialog): form.addRow("Rok:", self.year_edit) tmdb_row = QHBoxLayout() - self.tmdb_edit = QLineEdit(str(parse_tmdb_id(file_obj.file_path.stem) or "")) + self.tmdb_edit = QLineEdit() self.tmdb_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)) tmdb_row.addWidget(self.tmdb_edit) - find_btn = QPushButton("Dohledat z TMDb") - find_btn.clicked.connect(self._lookup) - tmdb_row.addWidget(find_btn) + self.find_btn = QPushButton("Dohledat z TMDb") + self.find_btn.clicked.connect(self._lookup) + tmdb_row.addWidget(self.find_btn) tmdb_wrap = QWidget() tmdb_wrap.setLayout(tmdb_row) form.addRow("TMDb ID:", tmdb_wrap) @@ -601,7 +911,14 @@ class MovieEditDialog(QDialog): self.csfd_edit = QLineEdit(file_obj.csfd_url or "") self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/… (jen uloží se)") form.addRow("ČSFD odkaz:", self.csfd_edit) + + self.format3d_combo = QComboBox() + self.format3d_combo.addItem("— žádný —", None) + for fmt in THREE_D_FORMATS: + self.format3d_combo.addItem(fmt, fmt) + form.addRow("3D formát:", self.format3d_combo) layout.addLayout(form) + self._refresh_identity_fields() layout.addWidget(QLabel("Atributy (pro šablony názvů, např. collection_sort):")) self.attr_table = QTableWidget(0, 2) @@ -621,11 +938,201 @@ class MovieEditDialog(QDialog): attr_btns.addStretch(1) layout.addLayout(attr_btns) + layout.addWidget(QLabel( + "Titulky (externí soubor vedle videa — Jellyfin je přehraje bez muxování; " + "sledují film i při přejmenování):")) + self.subs_table = QTableWidget(0, 2) + self.subs_table.setHorizontalHeaderLabels(["Soubor", "Jazyk / příznak"]) + self.subs_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.subs_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.subs_table.setMaximumHeight(110) + self._subtitles: list = [] + layout.addWidget(self.subs_table) + subs_btns = QHBoxLayout() + add_sub_btn = QPushButton("➕ Přidat titulky…") + add_sub_btn.clicked.connect(self._add_subtitle) + subs_btns.addWidget(add_sub_btn) + del_sub_btn = QPushButton("➖ Odebrat") + del_sub_btn.clicked.connect(self._remove_subtitle) + subs_btns.addWidget(del_sub_btn) + subs_btns.addStretch(1) + layout.addLayout(subs_btns) + self._reload_subtitles() + + layout.addWidget(QLabel( + "Verze (2D+3D, různá kvalita, …) — Jellyfin je nabídne k výběru. " + "Přidání druhé verze film přesune do vlastní podsložky:")) + self.versions_table = QTableWidget(0, 2) + self.versions_table.setHorizontalHeaderLabels(["Soubor", "Popisek"]) + self.versions_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.versions_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.versions_table.setMaximumHeight(110) + self._versions: list = [] + layout.addWidget(self.versions_table) + ver_btns = QHBoxLayout() + add_ver_btn = QPushButton("➕ Přidat verzi…") + add_ver_btn.clicked.connect(self._add_version) + ver_btns.addWidget(add_ver_btn) + del_ver_btn = QPushButton("➖ Odebrat verzi") + del_ver_btn.clicked.connect(self._remove_version) + ver_btns.addWidget(del_ver_btn) + ver_btns.addStretch(1) + layout.addLayout(ver_btns) + self._reload_versions() + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) + def _reload_subtitles(self) -> None: + self._subtitles = self.filehandler.list_subtitles(self.file_obj) + self.subs_table.setRowCount(0) + for sub in self._subtitles: + row = self.subs_table.rowCount() + self.subs_table.insertRow(row) + self.subs_table.setItem(row, 0, QTableWidgetItem(sub.path.name)) + self.subs_table.setItem(row, 1, QTableWidgetItem(sub.label)) + + def _add_subtitle(self) -> None: + import re + path, _ = QFileDialog.getOpenFileName( + self, "Vyber soubor s titulky", + filter="Titulky (*.srt *.ass *.ssa *.sub *.vtt)") + if not path: + return + language_text, ok = QInputDialog.getText( + self, "Jazyk titulků", + "Kód jazyka (např. cs, en; prázdné = bez tagu):") + if not ok: + return + language: str | None = language_text.strip().lower() or None + if language and not re.fullmatch(r"[a-z]{2,3}", language): + QMessageBox.warning( + self, "Titulky", "Kód jazyka zadej jako 2–3 písmena (např. cs).") + return + forced = QMessageBox.question( + self, "Titulky", "Jsou to vynucené (forced) titulky?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) == QMessageBox.Yes + try: + self.filehandler.attach_subtitle( + self.file_obj, Path(path), language=language, + flag="forced" if forced else None) + except (ValueError, OSError) as exc: + QMessageBox.warning(self, "Titulky", f"Nelze přidat titulky: {exc}") + return + self._reload_subtitles() + + def _remove_subtitle(self) -> None: + row = self.subs_table.currentRow() + if row < 0 or row >= len(self._subtitles): + QMessageBox.information(self, "Titulky", "Vyber titulky k odebrání.") + return + sub = self._subtitles[row] + answer = QMessageBox.question( + self, "Odebrat titulky", f"Smazat soubor „{sub.path.name}“?") + if answer != QMessageBox.Yes: + return + try: + self.filehandler.remove_subtitle(sub.path) + except OSError as exc: + QMessageBox.warning(self, "Titulky", str(exc)) + return + self._reload_subtitles() + + def _refresh_identity_fields(self) -> None: + """(Re)fill TMDb ID and enable/disable the identity fields for the + current grouping state of ``self.file_obj`` — called at open and again + after adding/removing a version, since that can promote or demote it. + """ + grouped = self.filehandler.is_movie_group_member(self.file_obj) + # A grouped movie's own filename carries " - Label", not the plain + # "Title (rok) [tmdbid]" the identity parsers expect — read those from + # the shared container folder's name instead. + identity_stem = (self.file_obj.file_path.parent.name if grouped + else self.file_obj.file_path.stem) + self.tmdb_edit.setText(str(parse_tmdb_id(identity_stem) or "")) + + note = ("Film má víc verzí — název/rok/TMDb ID/knihovna se zatím " + "needitují společně (odeber verzi, nebo uprav soubory ručně)." + if grouped else "") + for w in (self.title_edit, self.year_edit, self.tmdb_edit, + self.find_btn, self.library_combo): + w.setEnabled(not grouped) + w.setToolTip(note) + + current_3d = None if grouped else parse_3d_format(identity_stem) + idx = self.format3d_combo.findData(current_3d) + self.format3d_combo.setCurrentIndex(idx if idx >= 0 else 0) + self.format3d_combo.setEnabled(not grouped) + self.format3d_combo.setToolTip( + "U filmu s víc verzemi patří 3D do popisku té konkrétní verze " + "(viz „Verze“ níže), ne sem." if grouped else + "Jellyfin pozná 3D podle „.3D.<FORMÁT>“ v názvu souboru — " + "HSBS/FSBS = side-by-side (half/full), HTAB/FTAB = top-and-bottom " + "(half/full), MVC = nativní 3D blu-ray stream.") + + def _reload_versions(self) -> None: + self._versions = self.filehandler.list_movie_versions(self.file_obj) + self.versions_table.setRowCount(0) + for version in self._versions: + row = self.versions_table.rowCount() + self.versions_table.insertRow(row) + self.versions_table.setItem(row, 0, QTableWidgetItem(version.file_path.name)) + container = version.file_path.parent.name + label = version.file_path.stem + if label.startswith(f"{container} - "): + label = label[len(container) + 3:] + self.versions_table.setItem(row, 1, QTableWidgetItem(label)) + + def _add_version(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Vyber soubor s další verzí filmu") + if not path: + return + label_dialog = VersionLabelDialog(self) + if label_dialog.exec() != QDialog.Accepted: + return + label = label_dialog.label() + if not label: + QMessageBox.warning(self, "Verze", "Popisek verze nesmí být prázdný.") + return + try: + self.filehandler.add_movie_version(self.file_obj, Path(path), label) + except (ValueError, RuntimeError, FileExistsError) as exc: + QMessageBox.warning(self, "Verze", f"Nelze přidat verzi: {exc}") + return + self._reload_versions() + self._reload_subtitles() + self._refresh_identity_fields() + + def _remove_version(self) -> None: + row = self.versions_table.currentRow() + if row < 0 or row >= len(self._versions): + QMessageBox.information(self, "Verze", "Vyber verzi k odebrání.") + return + if len(self._versions) < 2: + QMessageBox.information(self, "Verze", "Film nemá žádné další verze.") + return + version = self._versions[row] + if version.file_path == self.file_obj.file_path: + QMessageBox.warning( + self, "Verze", + "Nelze odebrat verzi, kterou právě upravuješ v tomto dialogu — " + "zavři ho a zkus to znovu, nebo odeber jinou verzi.") + return + answer = QMessageBox.question( + self, "Odebrat verzi", f"Smazat soubor „{version.file_path.name}“?") + if answer != QMessageBox.Yes: + return + try: + self.filehandler.remove_movie_version(version) + except ValueError as exc: + QMessageBox.warning(self, "Verze", str(exc)) + return + self._reload_versions() + self._reload_subtitles() + self._refresh_identity_fields() + def _add_attr(self, key: str, value: str) -> None: row = self.attr_table.rowCount() self.attr_table.insertRow(row) @@ -697,6 +1204,11 @@ class MovieEditDialog(QDialog): def csfd_url(self) -> str: return self.csfd_edit.text().strip() + @property + def format_3d(self) -> str: + """Selected 3D tag, or ``""`` for "— žádný —" (clears any existing tag).""" + return self.format3d_combo.currentData() or "" + class SeriesEditDialog(QDialog): """Edit a series (title/year/id/library) and each episode's SxxEyy.""" @@ -860,6 +1372,61 @@ class LibraryOrganizerDialog(QDialog): return result +class MinidlnaLibrariesDialog(QDialog): + """Choose which pool libraries feed miniDLNA generation.""" + + def __init__( + self, parent: QWidget, libraries: list[dict], selected: list[str] | None, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Knihovny pro generování miniDLNA") + self.setMinimumSize(420, 360) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel( + "Které knihovny se mají promítnout do miniDLNA stromu (žánry, Tipy " + "dne, Nově přidané, zrcadlené seriály)?")) + warn = QLabel( + "⚠️ Odškrtnutá knihovna: její odkazy ve stromu se při dalším " + "generování odeberou jako zastaralé, dokud ji znovu nezaškrtneš.") + warn.setWordWrap(True) + warn.setStyleSheet("color: #c0392b;") + layout.addWidget(warn) + + self.list = QListWidget() + chosen = set(selected) if selected is not None else None # None = all + for lib in libraries: + item = QListWidgetItem(f"{lib['name']} ({lib['kind']})") + item.setData(Qt.UserRole, lib["name"]) + item.setCheckState( + Qt.Checked if chosen is None or lib["name"] in chosen else Qt.Unchecked) + self.list.addItem(item) + layout.addWidget(self.list) + + quick = QHBoxLayout() + all_btn = QPushButton("Vše") + all_btn.clicked.connect(lambda: self._set_all(Qt.Checked)) + none_btn = QPushButton("Nic") + none_btn.clicked.connect(lambda: self._set_all(Qt.Unchecked)) + quick.addWidget(all_btn) + quick.addWidget(none_btn) + quick.addStretch(1) + layout.addLayout(quick) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _set_all(self, state) -> None: + for i in range(self.list.count()): + self.list.item(i).setCheckState(state) + + def selected_libraries(self) -> list[str]: + return [self.list.item(i).data(Qt.UserRole) for i in range(self.list.count()) + if self.list.item(i).checkState() == Qt.Checked] + + class MigrateDialog(QDialog): """Review TMDb match proposals for an existing pool and apply the ids.""" @@ -892,6 +1459,9 @@ class MigrateDialog(QDialog): for row, p in enumerate(proposals): name_item = QTableWidgetItem(p.current_name) name_item.setFlags(Qt.ItemIsEnabled) + if getattr(p, "note", ""): + name_item.setToolTip(p.note) + name_item.setForeground(Qt.darkBlue) table.setItem(row, 0, name_item) status_item = QTableWidgetItem(self._STATUS.get(p.status, p.status)) status_item.setFlags(Qt.ItemIsEnabled) @@ -1298,8 +1868,14 @@ class QtApp(QMainWindow): series_menu = bar.addMenu("&Seriály") self._add_action(series_menu, "Importovat epizody…", self.import_episodes) self._add_action(series_menu, "Přehled seriálů…", self.show_series) + self._add_action( + series_menu, "Napravit názvy souborů…", self.rename_all_series) + self._add_action( + series_menu, "Odebrat z poolu…", lambda: self.remove_series()) film_menu = bar.addMenu("&miniDLNA") + self._add_action( + film_menu, "Knihovny pro generování…", self.select_minidlna_libraries) self._add_action( film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G") @@ -1546,6 +2122,10 @@ class QtApp(QMainWindow): # Movies: tag (AND) + library + search filter. Series: library + search # (they carry no tags, so a tag filter hides them). tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags()) + # A multi-version movie contributes only its primary version as a row + # — the rest live under "Upravit film → Verze", not as separate rows. + visible_paths = {f.file_path for f in self.filehandler.visible_movies()} + tag_filtered = [f for f in tag_filtered if f.file_path in visible_paths] library = self._selected_library_filter() search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" @@ -1637,6 +2217,9 @@ class QtApp(QMainWindow): if len(series) == 1: menu.addAction( "Upravit seriál…", lambda: self.edit_series(series[0])) + menu.addSeparator() + menu.addAction( + "Odebrat z poolu…", lambda: self.remove_series(series)) else: menu.addAction("Upravit film…", self.edit_movie) menu.addAction("Otevřít", self.open_movies) @@ -1705,6 +2288,17 @@ class QtApp(QMainWindow): if not self.filehandler.movies_dir: QMessageBox.warning(self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool).") return + # NOTE: hit a native SIGSEGV here once (2026-09-13), inside Qt's own + # QFileDialog teardown (QPersistentModelIndex::~QPersistentModelIndex, + # preceded by "QAbstractItemModel::endRemoveRows: Invalid index" from + # QFileSystemModel) — a known long-standing Qt/PySide bug (Qt5 through + # 6.11), not a Curator issue; confirmed via `coredumpctl` + gdb + # backtrace, no Python frame involved. That run's control group showed + # the process launched from inside VS Code's Snap-sandboxed terminal, + # which can block D-Bus access to xdg-desktop-portal and force Qt's + # own (non-native) file dialog — the buggy implementation the trace + # actually shows in use. If this recurs, try launching Curator from a + # plain (non-snap-sandboxed) terminal first. paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory") if not paths: return @@ -1715,7 +2309,13 @@ class QtApp(QMainWindow): if dialog.exec() != QDialog.Accepted: return entries = dialog.entries() + skipped_no_tmdb = dialog.skipped_no_tmdb if not entries: + if skipped_no_tmdb: + QMessageBox.information( + self, "Import", + f"Přeskočeno {len(skipped_no_tmdb)} souborů bez TMDb ID, " + "nic k importu.") return move = dialog.move_files library = dialog.selected_library @@ -1753,6 +2353,8 @@ class QtApp(QMainWindow): verb = "Přesunuto" if move else "Zkopírováno" summary = f"{verb} {len(imported)}/{len(entries)} filmů." + if skipped_no_tmdb: + summary += f" Přeskočeno {len(skipped_no_tmdb)} bez TMDb ID." if errors: QMessageBox.warning( self, "Import dokončen s chybami", @@ -1889,7 +2491,7 @@ class QtApp(QMainWindow): f, title=dialog.title, year=dialog.year or None, tmdb_id=dialog.tmdb_id or None, library=dialog.library, date=dialog.date, attributes=dialog.attributes, - csfd_url=dialog.csfd_url) + csfd_url=dialog.csfd_url, format_3d=dialog.format_3d) except (ValueError, FileExistsError, OSError) as exc: QMessageBox.warning(self, "Úprava selhala", str(exc)) return @@ -1979,21 +2581,22 @@ class QtApp(QMainWindow): self.status.showMessage(summary, 5000) def import_episodes(self) -> None: - """Import episode files into a series (Series/Season NN/Series SxxEyy).""" + """Import episode files into a series (Series/Season NN/Series SxxEyy). + + The dialog opens empty — add episodes file-by-file or pick a whole series + folder (its subfolders are scanned too). + """ if not self.filehandler.pool_dir: QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.") return - paths, _ = QFileDialog.getOpenFileNames(self, "Vyber epizody seriálu") - if not paths: - return dialog = ImportEpisodesDialog( - self, [Path(p) for p in paths], self.filehandler.series_libraries) + self, [], self.filehandler.series_libraries) if dialog.exec() != QDialog.Accepted: return result = self.filehandler.import_episodes( dialog.sources(), dialog.title, year=dialog.year or None, tmdb_id=dialog.tmdb_id or None, library=dialog.library, - move=dialog.move_files) + move=dialog.move_files, codes=dialog.episode_codes()) msg = (f"Naimportováno {result['imported']} epizod. " f"Nerozpoznáno (bez SxxEyy): {len(result['skipped_no_code'])}.") if result["errors"]: @@ -2013,6 +2616,71 @@ class QtApp(QMainWindow): self.reload_series() self.refresh_table() + def rename_all_series(self) -> None: + """Bulk-fix every series' episode filenames to ``SxxEyy - {série}.ext``.""" + if not self.filehandler.pool_dir: + QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.") + return + count = len(self.filehandler.load_series()) + if not count: + QMessageBox.information(self, "Seriály", "Žádné seriály v poolu.") + return + answer = QMessageBox.question( + self, "Napravit názvy souborů", + f"Přejmenovat epizody u {count} seriálů na „SxxEyy - Název " + "seriálu.ext“ a uklidit prázdné Season složky?\n\nEpizody bez " + "rozpoznaného SxxEyy se ponechají a vypíšou.", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if answer != QMessageBox.Yes: + return + + progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, count, self) + progress.setWindowTitle("Napravit názvy souborů seriálů") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + + def on_progress(done: int, total: int, name: str) -> None: + progress.setValue(done) + progress.setLabelText(f"[{done}/{total}] {name}") + QApplication.processEvents() + + result = self.filehandler.rename_all_series_to_canonical( + on_progress=on_progress, should_cancel=progress.wasCanceled) + progress.setValue(count) + self.reload_series() + self.refresh_table() + + renamed = result["renamed"] + skipped = result["skipped_no_code"] + conflicts = result["conflicts"] + swept = result["empty_dirs_removed"] + tail = " (zrušeno)" if result["cancelled"] else "" + summary = (f"Přejmenováno: {len(renamed)}, bez SxxEyy: {len(skipped)}, " + f"kolize: {len(conflicts)}, uklizené složky: {len(swept)}{tail}") + + if not (skipped or conflicts): + QMessageBox.information(self, "Hotovo", summary) + else: + detail: list[str] = [] + if renamed: + detail.append(f"■ Přejmenováno ({len(renamed)}):") + detail += [f" • [{s}] {old} → {new}" for s, old, new in renamed] + detail.append("") + if skipped: + detail.append(f"■ Bez rozpoznaného SxxEyy ({len(skipped)}):") + detail += [f" • [{s}] {n}" for s, n in skipped] + detail.append("") + if conflicts: + detail.append(f"■ Kolize ({len(conflicts)}):") + detail += [f" • [{s}] {n} — {msg}" for s, n, msg in conflicts] + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information) + box.setWindowTitle("Napravit názvy souborů seriálů") + box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") + box.setDetailedText("\n".join(detail)) + box.exec() + self.status.showMessage(summary, 5000) + def migrate_pool(self) -> None: """Match the existing pool against TMDb and assign ids (one-off migration).""" from src.core import tmdb @@ -2098,21 +2766,69 @@ class QtApp(QMainWindow): self.filehandler.filelist.remove(f) self.refresh_table() + def remove_series(self, series_list: list | None = None) -> None: + """Delete whole series folders from the pool (all seasons/episodes).""" + targets = series_list if series_list is not None else self._selected_series() + if not targets: + return + listing = "\n".join(f" • {s.title}" for s in targets[:10]) + more = f"\n … a další ({len(targets) - 10})" if len(targets) > 10 else "" + answer = QMessageBox.question( + self, "Odebrat z poolu", + f"Smazat {len(targets)} seriál(ů) z poolu včetně všech sezón a " + f"epizod?\n{listing}{more}") + if answer != QMessageBox.Yes: + return + for ser in targets: + try: + self.filehandler.remove_series(ser) + except (ValueError, OSError) as exc: + QMessageBox.warning( + self, "Chyba", f"Nelze smazat „{ser.title}“: {exc}") + self.reload_series() + self.refresh_table() + + def select_minidlna_libraries(self) -> None: + """Choose which pool libraries feed miniDLNA generation (persisted).""" + if not self.filehandler.pool_dir: + QMessageBox.information(self, "miniDLNA", "Nejprve nastavte pool.") + return + dialog = MinidlnaLibrariesDialog( + self, self.filehandler.libraries, self.filehandler.minidlna_libraries) + if dialog.exec() != QDialog.Accepted: + return + chosen = dialog.selected_libraries() + all_names = {lib["name"] for lib in self.filehandler.libraries} + # Selecting every library is the same as "all" (None) — no scoping. + self.filehandler.set_minidlna_libraries( + None if set(chosen) == all_names else chosen) + n = len(chosen) + self.status.showMessage( + f"miniDLNA: {n}/{len(all_names)} knihoven vybráno pro generování.", 6000) + def generate_filmoteka(self) -> None: out = self.filehandler.filmoteka_dir if not out: QMessageBox.warning( self, "miniDLNA", "Nejprve nastavte výstupní složku (Pool → Nastavit Filmotéku).") return - files = self.filehandler.filelist - if not files: - QMessageBox.information(self, "miniDLNA", "Pool je prázdný.") + files, mirror_libs, mixed_series = self.filehandler.minidlna_scope() + scoped = self.filehandler.minidlna_libraries is not None + if not files and not mirror_libs and not mixed_series: + QMessageBox.information( + self, "miniDLNA", + "Pool je prázdný." if not scoped else + "Vybrané knihovny neobsahují nic k vygenerování — " + "zkontroluj miniDLNA → „Knihovny pro generování…“.") return # Refresh genres/year from TMDb so the genre tree is current. self.filehandler.refresh_tags_from_tmdb(files) manager = HardlinkManager(out) - reserved = (set(self.filehandler.copyasis_folders) - | {TIPS_SUBFOLDER, RECENT_SUBFOLDER}) + # Every mirrored library (series/homevideo, + the mixed-library series + # bucket) stays reserved — protected from tag-tree cleanup — even when + # this run skips mirroring it. + reserved = (set(self.filehandler.mirror_as_is_libraries) + | {TIPS_SUBFOLDER, RECENT_SUBFOLDER, MIXED_SERIES_SUBFOLDER}) created, create_fail, removed, remove_fail = manager.sync_structure( files, category_roots=self.filehandler.filmoteka_category_roots(), @@ -2122,27 +2838,56 @@ class QtApp(QMainWindow): reserved_subfolders=reserved, ) - # Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked) + # Mirrored libraries (series copy-as-is + homevideo): hardlink each + # whole library folder 1:1 into output/<jeho vlastní název> — neither + # kind has genre tags to build a tree from. pool = self.filehandler.pool_dir mirrored = 0 mirror_fail = 0 - for name in self.filehandler.copyasis_folders: + for name in mirror_libs: src = pool / name if pool else None if src and src.is_dir() and any(src.iterdir()): m_created, m_failed = manager.mirror_as_is(src, name) mirrored += m_created mirror_fail += m_failed + # Series found inside a "mixed" library: no genre tags apply, so just + # hardcopy each one into output/Seriály/<jeho vlastní název> — same + # mechanism as a dedicated series library, scoped to one series' folder. + for ser in mixed_series: + if ser.folder.is_dir(): + m_created, m_failed = manager.mirror_as_is( + ser.folder, f"{MIXED_SERIES_SUBFOLDER}/{ser.folder.name}") + mirrored += m_created + mirror_fail += m_failed + + # Mirrors have no obsolete-link cleanup of their own (unlike the genre + # tree) — a library/series deselected this run would otherwise linger + # in the output forever. Remove exactly what fell out of scope. + stale_libs, valid_bucket = self.filehandler.minidlna_prune_targets( + mirror_libs, mixed_series, MIXED_SERIES_SUBFOLDER) + pruned = sum(1 for name in stale_libs if manager.remove_mirror(name)) + bucket_dir = out / MIXED_SERIES_SUBFOLDER + if bucket_dir.is_dir(): + for entry in list(bucket_dir.iterdir()): + if entry.name not in valid_bucket and manager.remove_mirror( + f"{MIXED_SERIES_SUBFOLDER}/{entry.name}"): + pruned += 1 + # Special folders (pool only; copy-as-is excluded): random tips + newest tips, tips_fail = manager.generate_random_tips( files, count=TIPS_COUNT, subfolder=TIPS_SUBFOLDER) recent, recent_fail = manager.generate_recently_added( files, count=RECENT_COUNT, subfolder=RECENT_SUBFOLDER) + scope_note = ( + f"\n(omezeno na {len(self.filehandler.minidlna_libraries)} knihoven — " + "miniDLNA → „Knihovny pro generování…“)" if scoped else "") + pruned_note = f", odebráno mimo výběr: {pruned}" if pruned else "" msg = ( f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n" - f"Copy-as-is — zrcadleno: {mirrored}\n" - f"Tipy dne — náhodně: {tips}, Nově přidané: {recent}" + f"Zrcadleno (seriály / homevideo): {mirrored}{pruned_note}\n" + f"Tipy dne — náhodně: {tips}, Nově přidané: {recent}{scope_note}" ) failed = create_fail + remove_fail + mirror_fail + tips_fail + recent_fail if failed: diff --git a/tests/test_csfd.py b/tests/test_csfd.py new file mode 100644 index 0000000..d43a535 --- /dev/null +++ b/tests/test_csfd.py @@ -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 diff --git a/tests/test_file.py b/tests/test_file.py index c842465..9c0e729 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -37,7 +37,7 @@ class TestFile: def test_file_metadata_saved_to_index(self, test_file, tag_manager, index): """Metadata jsou zapsána do indexu už při vytvoření.""" file_obj = File(test_file, tag_manager, index=index) - assert index.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): """Žádný .!tag sidecar už nevzniká.""" @@ -57,7 +57,7 @@ class TestFile: file_obj.ignored = True 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["ignored"] is True @@ -78,7 +78,7 @@ class TestFile: file_obj = File(test_file, tag_manager, index=index) file_obj.set_date("2025-12-25") assert file_obj.date == "2025-12-25" - assert index.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): file_obj = File(test_file, tag_manager, index=index) @@ -169,7 +169,7 @@ class TestFile: file_obj.add_tag("Test/Tag") 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 "ignored" in data assert "tags" in data diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index c87442f..5c6f98b 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -208,7 +208,7 @@ class TestPoolManagement: assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv" assert source.exists() # non-destructive copy 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): file_manager.set_pool_dir(tmp_path / "pool") @@ -274,7 +274,7 @@ class TestPoolManagement: # parseable ISO timestamp, persisted in the index from datetime import datetime 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): 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 [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"] # 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["tags"] == [] assert second.tags == [] @@ -367,8 +367,8 @@ class TestPoolManagement: assert not old_path.exists() assert movie.title == "Matrix Reloaded" # 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.get(old_path) is None + assert file_manager.index.record_for(new_path) is not None + assert file_manager.index.record_for(old_path) is None # a fresh manager reading the index sees the renamed file with its tags reloaded = FileManager(TagManager()) 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.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): file_manager.set_pool_dir(tmp_path / "pool") assert file_manager.libraries == [ @@ -529,15 +538,18 @@ class TestPoolManagement: file_manager.set_movie_libraries(["Filmy", "Kids"]) (tmp_path / "a.mkv").write_bytes(b"x") 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") assert file_manager.library_of(movie) == "Kids" assert movie.file_path.exists() - assert file_manager.index.records.get(old_key) is None # old key dropped - new_key = movie.file_path.relative_to(tmp_path / "pool").as_posix() - assert file_manager.index.records.get(new_key) is not None + # The movie keeps its identity record; only its media path + library move. + assert file_manager.index.record_for(old_path) is 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): file_manager.set_pool_dir(tmp_path / "pool") @@ -613,6 +625,42 @@ class TestPoolManagement: 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): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"x") @@ -634,6 +682,19 @@ class TestPoolManagement: assert renamed is not None 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): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "a.mkv").write_bytes(b"a") @@ -734,3 +795,578 @@ class TestPoolManagement: assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"] assert "copyasis_folders" not in file_manager.global_config 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 diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py index e7efd8d..8630c19 100644 --- a/tests/test_hardlink_manager.py +++ b/tests/test_hardlink_manager.py @@ -942,3 +942,30 @@ class TestMirrorAsIs: """Neexistující zdroj nic neudělá""" manager = HardlinkManager(tmp_path / "out") assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0) + + +class TestRemoveMirror: + """Removing a whole mirrored library (or one bucket entry) from the output.""" + + def test_removes_a_whole_mirrored_folder(self, tmp_path): + output = tmp_path / "out" + (output / "Dokumenty" / "sub").mkdir(parents=True) + (output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x") + manager = HardlinkManager(output) + + assert manager.remove_mirror("Dokumenty") is True + assert not (output / "Dokumenty").exists() + + def test_removes_one_bucket_entry_by_compound_name(self, tmp_path): + output = tmp_path / "out" + (output / "Seriály" / "Show A").mkdir(parents=True) + (output / "Seriály" / "Show B").mkdir(parents=True) + manager = HardlinkManager(output) + + assert manager.remove_mirror("Seriály/Show A") is True + assert not (output / "Seriály" / "Show A").exists() + assert (output / "Seriály" / "Show B").exists() # sibling untouched + + def test_missing_target_returns_false(self, tmp_path): + manager = HardlinkManager(tmp_path / "out") + assert manager.remove_mirror("Nope") is False diff --git a/tests/test_legacy_meta.py b/tests/test_legacy_meta.py new file mode 100644 index 0000000..a2121cd --- /dev/null +++ b/tests/test_legacy_meta.py @@ -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 diff --git a/tests/test_movie.py b/tests/test_movie.py new file mode 100644 index 0000000..94de78b --- /dev/null +++ b/tests/test_movie.py @@ -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"} diff --git a/tests/test_naming.py b/tests/test_naming.py index 7c05d44..914c7ee 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -2,9 +2,11 @@ import pytest from src.core.naming import ( + THREE_D_FORMATS, canonical_pool_stem, is_canonical_pool_stem, is_valid_year, + parse_3d_format, parse_pool_stem, parse_tmdb_id, year_from_filename, @@ -79,6 +81,43 @@ def test_is_canonical_pool_stem(): 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", [ ("Matrix.1999.1080p.BluRay.mkv", "1999"), ("Šíleně smutná princezna (1968).mkv", "1968"), diff --git a/tests/test_nfo.py b/tests/test_nfo.py index 19c538f..92b20cb 100644 --- a/tests/test_nfo.py +++ b/tests/test_nfo.py @@ -52,6 +52,35 @@ def test_tmdbid_from_tmdbid_tag(tmp_path): 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", encoding="utf-8") + assert read_nfo(video).csfd_url is None + + def test_find_prefers_sibling_over_movie_nfo(tmp_path): folder = tmp_path / "Matrix (1999)" folder.mkdir() diff --git a/tests/test_pool_index.py b/tests/test_pool_index.py index 9397704..92cce8e 100644 --- a/tests/test_pool_index.py +++ b/tests/test_pool_index.py @@ -1,48 +1,68 @@ 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.tag_manager import TagManager 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): index = PoolIndex(tmp_path) 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) - path = tmp_path / "Filmy" / "Matrix.mkv" - index.set(path, {"title": "Matrix"}) + index.set_movie("tmdb-603", {"title": "Matrix", "media": []}) - assert index.get(path) == {"title": "Matrix"} - # key is the pool-relative POSIX path - assert "Filmy/Matrix.mkv" in index.records + assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []} + assert "tmdb-603" 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.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")) - 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): - 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) - 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) - path = tmp_path / "Filmy" / "A.mkv" - index.set(path, {"title": "A"}) - index.delete(path) + index.set_movie("tmdb-603", {"title": "Matrix", "media": []}) + index.delete_movie("tmdb-603") - assert index.get(path) is None - assert PoolIndex(tmp_path).get(path) is None + assert index.get_movie("tmdb-603") 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): (tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8") @@ -50,8 +70,89 @@ class TestPoolIndex: 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: - """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): index = PoolIndex(tmp_path) @@ -62,7 +163,7 @@ class TestFileWithIndex: f = File(movie, TagManager(), index=index) 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 def test_index_backed_metadata_persists_across_reload(self, tmp_path): @@ -94,4 +195,24 @@ class TestFileWithIndex: f = File(movie, TagManager(), index=index) 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} diff --git a/tests/test_series.py b/tests/test_series.py index 794132d..be336cd 100644 --- a/tests/test_series.py +++ b/tests/test_series.py @@ -2,9 +2,12 @@ import pytest from src.core.series import ( + collect_episode_files, episode_filename, is_canonical_episode, + is_season_folder, parse_episode, + sanitize_title, scan_series, season_folder_name, series_folder_stem, @@ -29,13 +32,29 @@ def test_season_folder_name(): assert season_folder_name(12) == "Season 12" -def test_episode_filename_keeps_original_name(): - assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv" - # already-canonical stem → no doubled prefix - assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \ - "S01E02 - bb.pilot.hdtv.mkv" - # stem that was only the code → collapses to just the code - assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv" +def test_episode_filename_is_code_plus_series_title(): + assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv" + assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv" + # release string is NOT kept — only the code + series title + assert episode_filename(12, 145, "Breaking Bad", ".mkv") == \ + "S12E145 - Breaking Bad.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(): @@ -47,12 +66,15 @@ def test_series_folder_stem(): def test_is_canonical_episode(tmp_path): folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01" folder.mkdir(parents=True) - good = folder / "S01E02 - bb.pilot.mkv" + good = folder / "S01E02 - Breaking Bad.mkv" good.write_bytes(b"x") bad = folder / "bb.s01e02.mkv" bad.write_bytes(b"x") - assert is_canonical_episode(good) - assert not is_canonical_episode(bad) + wrong_body = folder / "S01E02 - old release.mkv" + 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): @@ -73,6 +95,26 @@ def test_scan_series(tmp_path): 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: @pytest.fixture def file_manager(self, tmp_path, monkeypatch): @@ -94,8 +136,8 @@ class TestFileManagerSeries: assert result["imported"] == 2 base = (tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01") - assert (base / "S01E01 - bb.mkv").exists() - assert (base / "S01E02 - bb.mkv").exists() + assert (base / "S01E01 - Breaking Bad.mkv").exists() + assert (base / "S01E02 - Breaking Bad.mkv").exists() def test_import_episodes_into_mixed_library(self, file_manager, tmp_path): file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) @@ -106,7 +148,7 @@ class TestFileManagerSeries: assert result["imported"] == 1 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 found = file_manager.load_series() assert [s.title for s in found] == ["Breaking Bad"] @@ -118,6 +160,45 @@ class TestFileManagerSeries: assert result["imported"] == 0 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): # a mis-named episode sitting in the wrong place folder = (tmp_path / "pool" / "Seriály" @@ -129,8 +210,49 @@ class TestFileManagerSeries: assert len(series_list) == 1 result = file_manager.rename_episodes_to_canonical(series_list[0]) - assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")] - assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists() + assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")] + 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): folder = tmp_path / "pool" / "Seriály" / "Wrong Name" @@ -144,5 +266,5 @@ class TestFileManagerSeries: new_folder = (tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]") 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() diff --git a/tests/test_subtitles.py b/tests/test_subtitles.py new file mode 100644 index 0000000..d55abf5 --- /dev/null +++ b/tests/test_subtitles.py @@ -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) == []