Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping
This commit is contained in:
+186
-6
@@ -21,7 +21,125 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The pool index is keyed by the movie, not by a file path** (index **v2**,
|
||||||
|
`src/core/movie.py`). A record used to describe one *file*, so a movie's
|
||||||
|
metadata was a passenger on one particular file — which is why the "primary
|
||||||
|
version" had to carry the tags, why removing it needed a metadata-rescue
|
||||||
|
hack, and why renaming a multi-version movie had to be disabled outright.
|
||||||
|
Now the record **is** the movie (`tmdb-603`, or a diacritics-folded
|
||||||
|
`name-<title>-<year>` slug without an id) and owns its files:
|
||||||
|
```json
|
||||||
|
"tmdb-19995": {"title": "Avatar", "year": "2009", "tmdb_id": 19995,
|
||||||
|
"library": "Filmy", "tags": [...], "csfd_url": …,
|
||||||
|
"media": [{"role": "video", "label": "", "file": "…"},
|
||||||
|
{"role": "video", "label": "3D HSBS", "file": "…"}]}
|
||||||
|
```
|
||||||
|
Every version of a film therefore reads and writes **the same** metadata —
|
||||||
|
adding a version inherits it, removing any version (including the one shown
|
||||||
|
in the table) leaves it intact, and the rescue hack (plus the interim
|
||||||
|
`File.is_primary_version` flag) is gone. A newly seen file also joins an
|
||||||
|
existing record of the same identity instead of blanking its metadata.
|
||||||
|
`Movie.desired_layout` derives where the files belong from the record alone
|
||||||
|
(one video → flat in the library, two or more → its own
|
||||||
|
`Title (rok) [tmdbid]/` folder; a 3D label keeps Jellyfin's documented
|
||||||
|
`.3D.<FORMAT>` spelling in both layouts, other labels use `" - label"`) —
|
||||||
|
**not wired in yet**: `add_movie_version` / `remove_movie_version` still lay
|
||||||
|
files out themselves, and the edit dialog doesn't apply a recomputed layout
|
||||||
|
on rename. A move now updates the record's `library` as well.
|
||||||
|
`check_data_consistency` compares disk against the records' media paths.
|
||||||
|
`File` is now a *view* on one media entry of a record (same public
|
||||||
|
interface); `PoolIndex` gained `get_movie`/`set_movie`/`delete_movie`/
|
||||||
|
`rekey_movie`/`all_movies`/`find_movie_by_media`/`record_for`, and
|
||||||
|
**migrates a v1 index on first open** — metadata only, **no file is moved**
|
||||||
|
(a v1 movie already sits where v2 wants it), with the old index copied to
|
||||||
|
`.Curator.!index.v1.bak` first.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Multiple versions of one movie** (Jellyfin's own "multiple versions" —
|
||||||
|
2D+3D, 4K+1080p, Director's Cut, …). A movie is identified by its
|
||||||
|
`(title, year, tmdbid)` and stays a **flat file** while it has exactly one
|
||||||
|
playable version (today's layout, unchanged). Adding a second version
|
||||||
|
**promotes it automatically** into its own folder — Jellyfin's required
|
||||||
|
shape, every file prefixed with the exact folder name:
|
||||||
|
```
|
||||||
|
Title (rok) [tmdbid]/
|
||||||
|
Title (rok) [tmdbid] - Výchozí.ext
|
||||||
|
Title (rok) [tmdbid] - 3D HSBS.ext
|
||||||
|
```
|
||||||
|
*Upravit film* gets a **„Verze"** list — **"➕ Přidat verzi…"** (pick a file,
|
||||||
|
then a small `VersionLabelDialog`: a **3D formát** preset — its label becomes
|
||||||
|
exactly `3D <FORMÁT>`, the same tokens Jellyfin scans for in a flat
|
||||||
|
`.3D.<FORMÁT>` filename — or free text for a non-3D version like `4K` /
|
||||||
|
`Director's Cut`) and **"➖ Odebrat verzi"** (removing down to one version
|
||||||
|
**demotes back to flat** automatically). The table still shows **one row
|
||||||
|
per movie** — a group's other versions live only in that list, not as
|
||||||
|
separate rows (`FileManager.visible_movies`; the row shown is the plain /
|
||||||
|
`Výchozí` version, `primary_version`). All versions share **one metadata
|
||||||
|
record** (see *Changed → index v2*), so a new version inherits the title/
|
||||||
|
tags/ČSFD/atributy and removing any version — even the one shown in the table
|
||||||
|
— leaves them intact. Renaming/moving/retitling a grouped movie isn't
|
||||||
|
supported yet (those fields are disabled in the edit dialog; remove the
|
||||||
|
version or edit the files directly). `FileManager.is_movie_group_member` /
|
||||||
|
`list_movie_versions` / `primary_version` / `add_movie_version` /
|
||||||
|
`remove_movie_version`. Scoped to `movie`-kind libraries for now —
|
||||||
|
`mixed`-library grouped movies are a follow-up (their subfolders are still
|
||||||
|
read as series only).
|
||||||
|
- **3D movie tagging** (`naming.py`, Jellyfin's own convention —
|
||||||
|
https://jellyfin.org/docs/general/server/media/movies/). *Upravit film* gets
|
||||||
|
a **„3D formát"** dropdown (žádný / HSBS / FSBS / HTAB / FTAB / MVC),
|
||||||
|
pre-filled from the current filename; picking one renames the pool file to
|
||||||
|
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`. The tag is preserved across every
|
||||||
|
future rename (editing other fields, "Přejmenovat dle metadat") the same way
|
||||||
|
`[tmdbid-N]` already is — `canonical_pool_stem` / `parse_3d_format` /
|
||||||
|
`THREE_D_FORMATS`; `apply_movie_edits(format_3d=…)` (omitted = keep, `""` =
|
||||||
|
clear). This is for a movie that's **only ever going to have one file** —
|
||||||
|
deliberately **not** exposed in the import dialog: importing a second file
|
||||||
|
for a movie already in the pool that way would create a plain, ungrouped
|
||||||
|
duplicate instead of a proper version (see "Multiple movie versions" above,
|
||||||
|
which is the right tool for 2D+3D of the same film). `import_movie` still
|
||||||
|
takes `format_3d=…` as a building block, just with no UI wired to it.
|
||||||
|
- **miniDLNA hardcopies `homevideo` libraries too.** A `homevideo` library
|
||||||
|
(no TMDb id, arbitrary internal structure) has no genre tags to build a tree
|
||||||
|
from — same reasoning as series — so it's now mirrored whole into
|
||||||
|
`output/<název knihovny>` alongside the series copy-as-is mirrors
|
||||||
|
(`FileManager.mirror_as_is_libraries` = `series` + `homevideo`; the
|
||||||
|
series-only `copyasis_folders` used by the library organizer is unchanged).
|
||||||
|
Respects the per-library miniDLNA selection.
|
||||||
|
- **miniDLNA covers series inside `mixed` libraries.** A `mixed` library's
|
||||||
|
top-level files are movies (genre-tagged as usual); any **series in its
|
||||||
|
subfolders** now get hardcopied into `output/Seriály/<vlastní název>/…` —
|
||||||
|
no genre tags apply to a series, so it bypasses the tag tree entirely, same
|
||||||
|
mechanism as a dedicated series library's copy-as-is mirror but scoped to one
|
||||||
|
series' own folder. Respects the per-library miniDLNA selection below.
|
||||||
|
`FileManager.minidlna_scope` now returns a third element, the in-scope
|
||||||
|
mixed-library `series.Series` list.
|
||||||
|
- **miniDLNA: choose which libraries generate.** *miniDLNA → "Knihovny pro
|
||||||
|
generování…"* — a checklist of the pool's libraries (persisted in the pool
|
||||||
|
index, default = all, matching the previous behaviour). "Vygenerovat miniDLNA
|
||||||
|
strom" then only includes movies from the checked libraries in the genre tree
|
||||||
|
/ Tipy dne / Nově přidané, and only mirrors checked series/homevideo
|
||||||
|
libraries — the dialog warns up front that unchecking one removes its output.
|
||||||
|
A movie library's genre-tree links are swept as obsolete the normal way; a
|
||||||
|
mirrored (series/homevideo) library or a mixed-library series has **no**
|
||||||
|
obsolete-link mechanism of its own, so deselecting one is handled explicitly:
|
||||||
|
`FileManager.minidlna_prune_targets` works out what fell out of scope (the
|
||||||
|
whole-library output folder, or — since a mixed-library series and a
|
||||||
|
same-named real library can share one output bucket like `Seriály` — just the
|
||||||
|
stale entries in it) and `HardlinkManager.remove_mirror` deletes it.
|
||||||
|
`FileManager.minidlna_libraries` / `set_minidlna_libraries` / `minidlna_scope`.
|
||||||
|
- **External subtitle management for movies** (`src/core/subtitles.py`). Jellyfin
|
||||||
|
plays an external `.srt`/`.ass`/`.ssa`/`.sub`/`.vtt` natively when it shares the
|
||||||
|
video's filename stem (`Title (rok) [tmdbid].cs.srt`, optionally `.forced`/
|
||||||
|
`.sdh`/`.default`) — no muxing needed. *Upravit film* lists a movie's attached
|
||||||
|
subtitles and can **"➕ Přidat titulky…"** (pick a file, tag its language,
|
||||||
|
mark forced; copied in and named to match) or remove one. Subtitles now
|
||||||
|
**follow the movie through every rename/move** (`rename_movie`,
|
||||||
|
`apply_movie_edits`, `move_to_library`, `rename_to_canonical`, and
|
||||||
|
`rename_episodes_to_canonical` for series) instead of silently detaching —
|
||||||
|
`subtitles.rename_subtitles_with`. `FileManager.list_subtitles` /
|
||||||
|
`attach_subtitle` / `remove_subtitle`. Presence is read live from disk (like
|
||||||
|
NFO), nothing is cached in the pool index.
|
||||||
- **Movies and series in one list.** The main table lists both as rows with a
|
- **Movies and series in one list.** The main table lists both as rows with a
|
||||||
**Typ** column (Film / Seriál); size is the file size for a movie and the sum of
|
**Typ** column (Film / Seriál); size is the file size for a movie and the sum of
|
||||||
the episodes for a series. Double-click (or the context menu) opens the right
|
the episodes for a series. Double-click (or the context menu) opens the right
|
||||||
@@ -52,7 +170,67 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
imported into a mixed library and show up in the series overview/editor.
|
imported into a mixed library and show up in the series overview/editor.
|
||||||
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
|
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
|
||||||
import (a column in the movie import dialog) and editable in *Upravit film*.
|
import (a column in the movie import dialog) and editable in *Upravit film*.
|
||||||
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD.
|
- **ČSFD → TMDb title bridge** (`src/core/csfd.py`, lean revival of the removed
|
||||||
|
scraper). A direct TMDb search on a Czech distribution title is unreliable
|
||||||
|
(remakes, localized names); ČSFD indexes Czech release names well **and** lists
|
||||||
|
each film's *original* title, so the flow is: filename → ČSFD search → ČSFD
|
||||||
|
film page → original title + year → TMDb search → tmdbid.
|
||||||
|
- Import dialog: a second button **"🎬 Dohledat přes ČSFD"** next to "Dohledat
|
||||||
|
z TMDb". Per row it uses the filled ČSFD odkaz, else searches ČSFD by the
|
||||||
|
cleaned filename; fills ČSFD odkaz / TMDb ID / Název / Rok (blue = suggested).
|
||||||
|
- Migration: a movie carrying a `csfd_url` is resolved through ČSFD first, so
|
||||||
|
`match_pool_to_tmdb` searches on the original title + year (the proposal is
|
||||||
|
tinted blue with a *"přes ČSFD: …"* tooltip). `FileManager.resolve_via_csfd`.
|
||||||
|
- Search is scoped to the *Filmy* section and rejects two-segment episode
|
||||||
|
hrefs (a bare-title search otherwise returned a series episode); a yearful
|
||||||
|
query that misses is retried without the year. The original title is only
|
||||||
|
taken from a `.film-names` entry whose flag matches the country of origin —
|
||||||
|
a Czech film (which lists only foreign alt-titles there) keeps its `<h1>`
|
||||||
|
title instead of a random Slovenian/English one.
|
||||||
|
- Scope: a title→tmdbid bridge only — **no ČSFD tags/genres return** (those
|
||||||
|
stay TMDb-via-Jellyfin/NFO). ČSFD's Anubis proof-of-work wall is solved
|
||||||
|
(SHA-256, stdlib) and replayed over a shared session so a batch pays it once.
|
||||||
|
- **Import rows pre-filled from metadata next to the source file.** When a picked
|
||||||
|
video sits next to metadata from an earlier Curator/Tagger setup, the import
|
||||||
|
row is seeded on open (no network) — **ČSFD odkaz**, Název, Rok (blue =
|
||||||
|
suggested, verify):
|
||||||
|
- `src/core/legacy_meta.py` — reads Curator's **pre-index** layouts: a per-file
|
||||||
|
`.{filename}.!tag` JSON sidecar (Tagger / early Curator; key `csfd_link`),
|
||||||
|
else a `.Curator.!index` found walking up the tree (matched by relative key,
|
||||||
|
then by unique basename). These win for the ČSFD link.
|
||||||
|
- `nfo.read_nfo` also parses a ČSFD id/URL from `<uniqueid type="csfd">` (bare
|
||||||
|
id or full URL) / legacy `<csfdid>` / `<csfd>` (`nfo.NfoData.csfd_url`), and
|
||||||
|
fills what the sidecar/index don't carry (notably the TMDb id).
|
||||||
|
- `ImportMoviesDialog._prefill_row`.
|
||||||
|
- **Import: "Vynechat řádky bez TMDb ID".** A checkbox in the import dialog that
|
||||||
|
drops every row without a TMDb ID on OK — they are skipped (and no longer need
|
||||||
|
a year). Rows that would be skipped are shown struck-through/italic as the box
|
||||||
|
is ticked; the import summary reports how many were skipped
|
||||||
|
(`ImportMoviesDialog.skipped_no_tmdb`).
|
||||||
|
- **Episode import: "📁 Přidat složku…".** The episode-import dialog can now take
|
||||||
|
a whole series folder and scans it **recursively** (all subfolders — `Season
|
||||||
|
01`, `Season 02`, …), adding every video file it finds (sidecars skipped);
|
||||||
|
season/episode still parsed per file. A canonical folder name
|
||||||
|
(`Title (rok) [tmdbid-N]`) also pre-fills the series title / year / TMDb ID.
|
||||||
|
The dialog now opens empty (add files or a folder from inside it).
|
||||||
|
`series.collect_episode_files`, `ImportEpisodesDialog._add_folder`.
|
||||||
|
- **Episode import: editable SxxEyy per row.** The `SxxEyy` column is now an
|
||||||
|
editable field, pre-filled from the filename and colour-coded (green valid /
|
||||||
|
red unparsable / plain empty). Fill in an unrecognised episode (or fix a
|
||||||
|
mis-parsed one) right in the import dialog — `S01E05` / `1x05` both work; OK
|
||||||
|
warns before importing if any row still has no valid code.
|
||||||
|
`ImportEpisodesDialog.episode_codes` feeds a new `codes=` override on
|
||||||
|
`FileManager.import_episodes`.
|
||||||
|
- **Remove a series from the pool.** *Seriály → "Odebrat z poolu…"*, the table
|
||||||
|
context menu (series-only selection, one or more), and a button in *Přehled
|
||||||
|
seriálů* delete a whole series folder — every season and episode — after a
|
||||||
|
confirm. `FileManager.remove_series` (refuses a folder outside the pool).
|
||||||
|
- **Bulk-fix series episode filenames.** *Seriály → "Napravit názvy souborů…"*
|
||||||
|
goes over every series in the pool and renames each episode to
|
||||||
|
**`SxxEyy - {název seriálu}.ext`** in the right `Season NN` / `Specials`
|
||||||
|
folder, then removes the season folders emptied by the moves. Episodes with no
|
||||||
|
recognisable `SxxEyy` are left in place and listed (fix them in the series
|
||||||
|
editor). `FileManager.rename_all_series_to_canonical`.
|
||||||
- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool
|
- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool
|
||||||
s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by
|
s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by
|
||||||
title + year and proposes an id, filling a missing year from TMDb. Conservative
|
title + year and proposes an id, filling a missing year from TMDb. Conservative
|
||||||
@@ -71,11 +249,13 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
Jellyfin to write NFO before the genre tree is populated.
|
Jellyfin to write NFO before the genre tree is populated.
|
||||||
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**
|
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**
|
||||||
(genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
|
(genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
|
||||||
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
|
- **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/
|
||||||
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
|
Season NN/`. The release string is deliberately dropped — identity is the
|
||||||
`SxxEyy` code is stripped from the original before re-applying, so repeated
|
`SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb
|
||||||
renames don't double it (`series.episode_body`); `episode_filename` /
|
(matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title
|
||||||
`is_canonical_episode` and the series import/rename/edit paths follow.
|
collapses to `SxxEyy.ext`. `episode_filename` takes the series title,
|
||||||
|
`is_canonical_episode(path, series_title)`, and import / rename / edit all
|
||||||
|
follow (`series.sanitize_title`).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
||||||
|
|||||||
+102
-27
@@ -66,6 +66,16 @@ movie table, and one-click Filmotéka generation.
|
|||||||
- **Metadata storage:** one **unified metadata file** for the whole pool (a
|
- **Metadata storage:** one **unified metadata file** for the whole pool (a
|
||||||
central index), not per-file sidecars. Justified because Curator owns the pool
|
central index), not per-file sidecars. Justified because Curator owns the pool
|
||||||
and files are never moved manually, so it is not exposed to path drift.
|
and files are never moved manually, so it is not exposed to path drift.
|
||||||
|
- **The record is the movie, files are its children** (index **v2**,
|
||||||
|
`movie.py`). Keyed by identity (`tmdb-<id>`, else a `name-<slug>` fallback),
|
||||||
|
each record holds the metadata *and* a `media` list of its files with a free
|
||||||
|
`label` per version. Chosen after the v1 path-keyed shape made multi-version
|
||||||
|
movies unworkable: metadata rode on one file, so it had to be rescued when
|
||||||
|
that file went, and retitling a grouped movie had to be forbidden. With
|
||||||
|
identity as the key, `Movie.desired_layout()` is a pure function of the
|
||||||
|
record — **one video → flat, two or more → own folder** — so promote, demote
|
||||||
|
and rename are the same operation, and an import can recognise "this tmdbid
|
||||||
|
is already in the pool" instead of silently creating a duplicate.
|
||||||
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
|
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
|
||||||
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
|
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
|
||||||
pointers (which must stay global: the index is found *through* them). Everything
|
pointers (which must stay global: the index is found *through* them). Everything
|
||||||
@@ -182,11 +192,29 @@ library manager**. Agreed shape:
|
|||||||
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
|
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
|
||||||
(homevideo). Kids are **not** a separate folder — see decisions.
|
(homevideo). Kids are **not** a separate folder — see decisions.
|
||||||
- **Naming convention:**
|
- **Naming convention:**
|
||||||
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form.
|
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D
|
||||||
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext`
|
movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's
|
||||||
(season 0 → `Specials`); the episode keeps its original filename after the
|
own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the
|
||||||
`SxxEyy` code. Episode numbers are recognised from the filename; editable in
|
edit dialog and preserved across every future rename like the tmdbid is.
|
||||||
the series GUI.
|
- **Multiple movie versions** (2D+3D, 4K+1080p, …): identity is
|
||||||
|
`(title, rok, tmdbid)`, stays a **flat file** at one version. A second
|
||||||
|
version promotes it to Jellyfin's required per-movie folder — every file
|
||||||
|
prefixed with the exact folder name — `Title (rok) [tmdbid]/Title (rok)
|
||||||
|
[tmdbid] - {popisek}.ext`; a 3D version's popisek doubles as its 3D tag
|
||||||
|
(`3D HSBS`, scanned by Jellyfin the same as the flat `.3D.HSBS` form).
|
||||||
|
Curator does the promotion/demotion itself (*Upravit film → Verze*); the
|
||||||
|
table always shows one row per movie (`visible_movies`). Shared metadata
|
||||||
|
lives on one **primary** version (`File.is_primary_version`), migrated if
|
||||||
|
that one is ever removed. **Not yet supported:** renaming/moving/retitling
|
||||||
|
a grouped movie (remove the version or edit files directly), and grouped
|
||||||
|
movies inside `mixed` libraries (scoped to `movie`-kind for now — a mixed
|
||||||
|
library's subfolders are still read as series only).
|
||||||
|
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {série}.ext`
|
||||||
|
(season 0 → `Specials`). The episode filename is the code + the series title
|
||||||
|
only — the release string is dropped (identity is the code; Jellyfin fetches
|
||||||
|
episode metadata from TMDb via the code + `[tmdbid]`). Episode numbers are
|
||||||
|
recognised from the filename and editable at import / in the series GUI;
|
||||||
|
*Seriály → "Napravit názvy souborů…"* bulk-fixes the whole pool.
|
||||||
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
|
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
|
||||||
`tmdbid` (yearless title search picks the wrong film, hence the earlier
|
`tmdbid` (yearless title search picks the wrong film, hence the earlier
|
||||||
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
|
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
|
||||||
@@ -195,15 +223,45 @@ library manager**. Agreed shape:
|
|||||||
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
|
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
|
||||||
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
||||||
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
|
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
|
||||||
kept **only** as an optional CZ-title source for the filename (not for tags/
|
kept **only** as a title source (not for tags/matching — it exposes no
|
||||||
matching — it exposes no TMDb/IMDb id, verified).
|
TMDb/IMDb id, verified): it feeds the **ČSFD → TMDb bridge** (`csfd.py`,
|
||||||
|
`FileManager.resolve_via_csfd`) — filename → ČSFD search → ČSFD *original*
|
||||||
|
title + year → TMDb search → tmdbid, more reliable than a raw Czech-title
|
||||||
|
TMDb search. Used by the import dialog ("Dohledat přes ČSFD") and by migration
|
||||||
|
for any movie with a stored `csfd_url`.
|
||||||
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
|
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
|
||||||
(CZ-title source/override); filename hand-editable before/after import.
|
(drives the ČSFD→TMDb bridge, and stored as a pointer); filename hand-editable
|
||||||
|
before/after import. The import row is pre-filled on open (no network) from
|
||||||
|
metadata already next to the source file: Curator's **pre-index** layouts —
|
||||||
|
a `.{filename}.!tag` sidecar or an old `.Curator.!index` up the tree
|
||||||
|
(`legacy_meta.read_legacy_meta`, authoritative for the ČSFD link) — then a
|
||||||
|
Kodi/Jellyfin `.nfo` (`nfo.read_nfo`, also reads a `csfd` `uniqueid` /
|
||||||
|
`<csfdid>`, and supplies the TMDb id).
|
||||||
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
|
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
|
||||||
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
|
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
|
||||||
`naming.py`), not from ČSFD tags.
|
`naming.py`), not from ČSFD tags. **Scoped per library** (*miniDLNA →
|
||||||
|
"Knihovny pro generování…"*, `minidlna_libraries` in the pool index, default
|
||||||
|
= all) — lets a library (e.g. Kids) be excluded from the dumb-DLNA output
|
||||||
|
entirely; a movie library's genre-tree links are swept as obsolete the normal
|
||||||
|
way when it's excluded. A `series` **or** `homevideo`-kind library has no
|
||||||
|
genre tags to build a tree from (homevideo has no TMDb id either), so both
|
||||||
|
hardcopy whole into `output/<jejich vlastní název>` (`mirror_as_is_libraries`)
|
||||||
|
— this works today independent of phase 8 (homevideo needs no GUI
|
||||||
|
import/browse support to be mirrored). Mirrors have **no obsolete-link
|
||||||
|
mechanism of their own**, so excluding one is pruned explicitly
|
||||||
|
(`FileManager.minidlna_prune_targets` + `HardlinkManager.remove_mirror`) —
|
||||||
|
including the shared `output/Seriály` bucket a mixed-library series and a
|
||||||
|
same-named real library can both write into, reconciled per-entry so pruning
|
||||||
|
one never erases the other's still-valid content.
|
||||||
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
||||||
key + URL in `.env`. NFO on disk stays authoritative.
|
key + URL in `.env`. NFO on disk stays authoritative.
|
||||||
|
- **Subtitles stay external, never muxed** (`subtitles.py`): a `.srt`/`.ass`/
|
||||||
|
`.ssa`/`.sub`/`.vtt` sharing the video's stem (+ optional `.lang`/`.forced`/
|
||||||
|
`.sdh`/`.default`) plays natively in Jellyfin — no re-encode, multiple
|
||||||
|
languages side by side, easy to replace. Presence is read live from disk
|
||||||
|
(like NFO), not cached in the index. The one thing Curator owns: a subtitle
|
||||||
|
**follows its video through every rename/move** so it never silently
|
||||||
|
detaches (every rename/move path calls `subtitles.rename_subtitles_with`).
|
||||||
|
|
||||||
### Decisions (locked)
|
### Decisions (locked)
|
||||||
|
|
||||||
@@ -231,9 +289,9 @@ physical partitions + import-into / move-between [done]; (3) Jellyfin API — sc
|
|||||||
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
|
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
|
||||||
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
|
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
|
||||||
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
|
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
|
||||||
library organizer (unified `libraries` model with kinds) [done].
|
library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library
|
||||||
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs);
|
scanning + generation (movies + series in one folder) [done].
|
||||||
**(9) mixed-library scanning + generation** (movies + series in one folder).
|
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs).
|
||||||
|
|
||||||
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
||||||
[tmdbid].ext`); per-movie folders are a later refinement.
|
[tmdbid].ext`); per-movie folders are a later refinement.
|
||||||
@@ -249,28 +307,42 @@ Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
|||||||
skip the TMDb requirement; `movie` keeps requiring a year (as today).
|
skip the TMDb requirement; `movie` keeps requiring a year (as today).
|
||||||
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
|
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
|
||||||
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
|
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
|
||||||
|
**miniDLNA generation for `homevideo` needs none of this** — it hardcopies the
|
||||||
|
whole library folder as-is (`mirror_as_is_libraries` **[done]**); this task is
|
||||||
|
only about GUI import/browsing.
|
||||||
- Tests: import with no year/id lands correctly; homevideo library loads.
|
- Tests: import with no year/id lands correctly; homevideo library loads.
|
||||||
|
|
||||||
### Phase 9 — mixed-library scanning + generation
|
### Phase 9 — mixed-library scanning + generation
|
||||||
|
|
||||||
- Scan a `mixed` library by structure: a top-level **single video file = movie**;
|
- Scanning **[done]**: a `mixed` library's top-level files load as movies
|
||||||
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse
|
(`load_pool_movies`), its subfolders as series (`load_series` /
|
||||||
`series.scan_series` for the series part and the flat movie scan for the rest.
|
`series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`).
|
||||||
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`,
|
Both are kind-aware over the unified `libraries` list. The library filter /
|
||||||
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`.
|
table already surfaces mixed libraries.
|
||||||
- miniDLNA generation + NFO refresh must cover mixed libraries too.
|
- miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as
|
||||||
- The library filter / table should surface mixed libraries once loadable.
|
usual; series in its subfolders have no genre tags, so they're hardcopied
|
||||||
- Tests: a mixed folder with both a movie and a series loads/generates correctly.
|
straight into `output/Seriály/<název>/…` instead
|
||||||
|
(`FileManager.minidlna_scope` → the mixed-library series list; the mirror
|
||||||
|
loop in `generate_filmoteka`). NFO refresh needs no separate handling — it
|
||||||
|
only ever touches movies (`self.filelist`), series never go through NFO/TMDb
|
||||||
|
tagging, mixed or not.
|
||||||
|
- Tests: a mixed folder with both a movie and a series loads/generates
|
||||||
|
correctly **[done]** (`TestMinidlnaScope` in `test_file_manager.py`).
|
||||||
|
|
||||||
### Later / optional
|
### Later / optional
|
||||||
|
|
||||||
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good
|
- **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was
|
||||||
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the
|
matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW
|
||||||
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the
|
solver, film-page + search parsing, `original_title` extraction — the entry in
|
||||||
filename in the GUI. The ČSFD **link** is already stored per movie
|
`.film-names` whose flag matches the country of origin). `resolve_via_csfd`
|
||||||
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later —
|
drives it from the import dialog and from migration. **Not** a return of ČSFD
|
||||||
revisit fetching only if TMDb proves insufficient in practice.
|
tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch
|
||||||
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
|
"look up ČSFD links for the whole pool" as a migration pre-pass (today the
|
||||||
|
`csfd_url` is per-movie, set at import / in the edit dialog).
|
||||||
|
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat
|
||||||
|
files — **partially done**: a movie only gets its own folder once it has a
|
||||||
|
second playable version (see "Multiple movie versions" below); a single-file
|
||||||
|
movie stays flat as before.
|
||||||
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
|
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
|
||||||
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
|
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
|
||||||
path-aware `library_of` (currently single-segment).
|
path-aware `library_of` (currently single-segment).
|
||||||
@@ -280,6 +352,9 @@ Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
|
|||||||
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
|
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
|
||||||
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
|
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
|
||||||
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
|
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
|
||||||
|
- Phase 9 — mixed-library scanning + generation: a `mixed` library's top-level
|
||||||
|
movies genre-tag as usual, series in its subfolders hardcopy into
|
||||||
|
`output/Seriály/<název>/…` (no genre tags apply to a series)
|
||||||
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
|
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
|
||||||
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
|
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
|
||||||
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"""ČSFD.cz scraper — a *title bridge* to TMDb.
|
||||||
|
|
||||||
|
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
|
||||||
|
distribution title is unreliable (remakes, localized names, "weird" hits).
|
||||||
|
ČSFD, on the other hand, indexes Czech release names well **and** lists a
|
||||||
|
film's *original* title. So the bridge is::
|
||||||
|
|
||||||
|
filename → ČSFD search → ČSFD film page → original title + year
|
||||||
|
→ TMDb search → tmdbid
|
||||||
|
|
||||||
|
This module only scrapes ČSFD; the TMDb half lives in
|
||||||
|
:mod:`src.core.file_manager` (:meth:`FileManager.resolve_via_csfd`). Nothing
|
||||||
|
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
|
||||||
|
|
||||||
|
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
|
||||||
|
request gets a 200 with a JS challenge instead of the movie. We detect it,
|
||||||
|
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
|
||||||
|
request through a shared :class:`requests.Session` (so a batch pays the PoW
|
||||||
|
cost only once).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_HAS_DEPS = True
|
||||||
|
except ImportError: # pragma: no cover - exercised only without the extras
|
||||||
|
_HAS_DEPS = False
|
||||||
|
requests = None # type: ignore
|
||||||
|
BeautifulSoup = None # type: ignore
|
||||||
|
|
||||||
|
CSFD_BASE_URL = "https://www.csfd.cz"
|
||||||
|
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "cs,en;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Anubis anti-bot wall: a plain GET returns a 200 challenge page, not the movie.
|
||||||
|
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
|
||||||
|
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
||||||
|
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
|
||||||
|
ANUBIS_MAX_NONCE = 50_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class CsfdError(RuntimeError):
|
||||||
|
"""A ČSFD lookup could not be completed (deps missing, network, anti-bot)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CsfdFilm:
|
||||||
|
"""The slice of a ČSFD film page Curator needs to reach a TMDb id."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
csfd_id: Optional[int] = None
|
||||||
|
czech_title: str = ""
|
||||||
|
original_title: str = ""
|
||||||
|
year: Optional[str] = None # four-digit string, or None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tmdb_query(self) -> tuple[str, Optional[str]]:
|
||||||
|
"""``(title, year)`` best suited for a TMDb search — original title first."""
|
||||||
|
return (self.original_title or self.czech_title, self.year)
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies_available() -> bool:
|
||||||
|
"""True when ``requests`` + ``beautifulsoup4`` are importable."""
|
||||||
|
return _HAS_DEPS
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dependencies() -> None:
|
||||||
|
if not _HAS_DEPS:
|
||||||
|
raise CsfdError(
|
||||||
|
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' "
|
||||||
|
"(poetry install je doplní)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_csfd_id(url: str) -> Optional[int]:
|
||||||
|
"""Pull the numeric film id out of a ČSFD URL, or ``None``."""
|
||||||
|
match = re.search(r"/film/(\d+)", url)
|
||||||
|
return int(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Anubis proof-of-work (ported verbatim from the pre-Jellyfin csfd.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _extract_json_blob(html: str, element_id: str):
|
||||||
|
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
|
||||||
|
match = re.search(
|
||||||
|
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
|
||||||
|
html,
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(1))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]:
|
||||||
|
"""Brute-force the Anubis proof-of-work.
|
||||||
|
|
||||||
|
Mirrors the bundled ``sha256-purejs`` worker: find the smallest ``nonce``
|
||||||
|
such that ``sha256(random_data + str(nonce))`` has ``difficulty`` leading
|
||||||
|
zero nibbles. Returns ``(hash_hex, nonce, elapsed_ms)``.
|
||||||
|
"""
|
||||||
|
full_zero_bytes = difficulty // 2
|
||||||
|
needs_half_byte = difficulty % 2 != 0
|
||||||
|
start = time.monotonic()
|
||||||
|
for nonce in range(ANUBIS_MAX_NONCE):
|
||||||
|
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
|
||||||
|
if any(digest[i] != 0 for i in range(full_zero_bytes)):
|
||||||
|
continue
|
||||||
|
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
|
||||||
|
continue
|
||||||
|
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
return digest.hex(), nonce, elapsed_ms
|
||||||
|
raise CsfdError(
|
||||||
|
f"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_anubis_challenge(session, html: str, url: str):
|
||||||
|
"""Solve the Anubis challenge in ``html`` and return the real page response."""
|
||||||
|
payload = _extract_json_blob(html, "anubis_challenge")
|
||||||
|
if not payload:
|
||||||
|
raise CsfdError("ČSFD anti-bot stránka bez čitelné Anubis challenge.")
|
||||||
|
|
||||||
|
rules = payload.get("rules", {})
|
||||||
|
challenge = payload.get("challenge", {})
|
||||||
|
random_data = challenge.get("randomData")
|
||||||
|
difficulty = int(rules.get("difficulty", 1))
|
||||||
|
if not random_data:
|
||||||
|
raise CsfdError("Anubis challenge neobsahuje randomData.")
|
||||||
|
|
||||||
|
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
|
||||||
|
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
|
||||||
|
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
|
||||||
|
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
|
||||||
|
|
||||||
|
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
|
||||||
|
response = session.get(
|
||||||
|
pass_url,
|
||||||
|
params={
|
||||||
|
"id": challenge.get("id"),
|
||||||
|
"response": hash_hex,
|
||||||
|
"nonce": nonce,
|
||||||
|
"redir": url,
|
||||||
|
"elapsedTime": elapsed_ms,
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||||
|
raise CsfdError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _get_page(session, url: str):
|
||||||
|
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
|
||||||
|
response = session.get(url, headers=HEADERS, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
if ANUBIS_CHALLENGE_MARKER in response.text:
|
||||||
|
response = _solve_anubis_challenge(session, response.text, url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_json_ld(soup) -> tuple[str, Optional[str]]:
|
||||||
|
"""``(name, year)`` from the film page's JSON-LD (name is the Czech title)."""
|
||||||
|
for script in soup.find_all("script", type="application/ld+json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(script.string or "")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = next((d for d in data if d.get("@type") == "Movie"), None)
|
||||||
|
if not isinstance(data, dict) or data.get("@type") != "Movie":
|
||||||
|
continue
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
year: Optional[str] = None
|
||||||
|
for key in ("dateCreated", "datePublished"):
|
||||||
|
match = re.search(r"(19|20)\d{2}", str(data.get(key) or ""))
|
||||||
|
if match:
|
||||||
|
year = match.group(0)
|
||||||
|
break
|
||||||
|
return name, year
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""``(origin_country, year)`` from the ``.origin`` line.
|
||||||
|
|
||||||
|
ČSFD separates values with inline bullet ``<span>`` elements (no commas),
|
||||||
|
e.g. ``USA • 1999 • 136 min``. For a co-production only the first country
|
||||||
|
is returned — enough to match it against a name's flag.
|
||||||
|
"""
|
||||||
|
elem = soup.select_one(".origin")
|
||||||
|
if not elem:
|
||||||
|
return None, None
|
||||||
|
raw = elem.get_text(separator="|", strip=True)
|
||||||
|
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
|
||||||
|
country: Optional[str] = None
|
||||||
|
year: Optional[str] = None
|
||||||
|
for token in tokens:
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if year is None and re.fullmatch(r"(19|20)\d{2}", token):
|
||||||
|
year = token
|
||||||
|
continue
|
||||||
|
if (country is None and not token[0].isdigit()
|
||||||
|
and re.search(r"[^\W\d_]", token)):
|
||||||
|
country = token.split("/")[0].strip()
|
||||||
|
return country, year
|
||||||
|
|
||||||
|
|
||||||
|
# ČSFD flags older Czech films "Československo" while ``.origin`` may read "Česko".
|
||||||
|
_CZ_ORIGIN_ALIASES = {"česko", "československo"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]:
|
||||||
|
"""The film's original title from ``.film-names`` — or ``None`` to use the CZ title.
|
||||||
|
|
||||||
|
Each ``<li>`` carries a country/language flag (``img.flag`` ``title``) and a
|
||||||
|
title. The original title is the entry whose flag matches the **country of
|
||||||
|
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
|
||||||
|
Czech title here when it equals the ``<h1>``, so a Czech film has only
|
||||||
|
foreign/festival alt-titles in this list (``[angličtina] Cosy Dens``,
|
||||||
|
``[Slovinsko] Samotarji``) — picking one of those would be wrong, so we
|
||||||
|
return ``None`` and let the caller fall back to the Czech ``<h1>`` title.
|
||||||
|
"""
|
||||||
|
names: list[tuple[str, str]] = []
|
||||||
|
for li in soup.select(".film-names li"):
|
||||||
|
flag = li.select_one("img.flag")
|
||||||
|
country = str(flag.get("title") or "").strip() if flag else ""
|
||||||
|
for junk in li.select("span, img"):
|
||||||
|
junk.extract()
|
||||||
|
text = " ".join(li.get_text(" ").split()).strip(" ·")
|
||||||
|
if text:
|
||||||
|
names.append((country, text))
|
||||||
|
if not names or not origin_country:
|
||||||
|
return None
|
||||||
|
wanted = origin_country.casefold()
|
||||||
|
for country, text in names:
|
||||||
|
here = country.casefold()
|
||||||
|
if here == wanted or (wanted in _CZ_ORIGIN_ALIASES
|
||||||
|
and here in _CZ_ORIGIN_ALIASES):
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_film(url: str, session=None) -> CsfdFilm:
|
||||||
|
"""Fetch a ČSFD film page → :class:`CsfdFilm` (Czech + original title, year).
|
||||||
|
|
||||||
|
Pass a shared ``requests.Session`` to reuse the Anubis auth cookie across a
|
||||||
|
batch. Raises :class:`CsfdError` on a network / anti-bot / parse failure.
|
||||||
|
"""
|
||||||
|
_check_dependencies()
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
html = _get_page(session, url).text
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CsfdError(f"Nelze načíst ČSFD stránku: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
czech_title, year = _parse_json_ld(soup)
|
||||||
|
origin_country, origin_year = _parse_origin(soup)
|
||||||
|
year = year or origin_year
|
||||||
|
if not czech_title:
|
||||||
|
header = soup.select_one(".film-header-name h1")
|
||||||
|
czech_title = header.get_text(strip=True) if header else ""
|
||||||
|
original_title = _parse_original_title(soup, origin_country) or czech_title
|
||||||
|
return CsfdFilm(
|
||||||
|
url=url,
|
||||||
|
csfd_id=_extract_csfd_id(url),
|
||||||
|
czech_title=czech_title,
|
||||||
|
original_title=original_title,
|
||||||
|
year=str(year) if year else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _films_section(soup):
|
||||||
|
"""The ``<section>`` holding the *Filmy* results, or the whole document.
|
||||||
|
|
||||||
|
ČSFD's search page groups hits into ``Filmy`` / ``Seriály`` / ``Tvůrci`` /
|
||||||
|
``Uživatelé`` sections; ``a.film-title-name`` matches in all of them, so
|
||||||
|
without scoping a bare-title search happily returns a series episode.
|
||||||
|
"""
|
||||||
|
for section in soup.find_all("section"):
|
||||||
|
header = section.select_one(".box-header h2") or section.find("h2")
|
||||||
|
if header and header.get_text(strip=True).casefold() == "filmy":
|
||||||
|
return section
|
||||||
|
return soup
|
||||||
|
|
||||||
|
|
||||||
|
def _is_film_href(href: str) -> bool:
|
||||||
|
"""True for a film URL ``/film/<id>-slug/`` — a series episode has two segments
|
||||||
|
(``/film/<series>/<episode>/``) and is rejected."""
|
||||||
|
parts = [p for p in href.split("/") if p and p != "prehled"]
|
||||||
|
return len(parts) == 2 and parts[0] == "film"
|
||||||
|
|
||||||
|
|
||||||
|
def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]:
|
||||||
|
"""Search ČSFD for ``query``; film hits only (no series/episodes), best first."""
|
||||||
|
_check_dependencies()
|
||||||
|
if not query.strip():
|
||||||
|
return []
|
||||||
|
search_url = f"{CSFD_SEARCH_URL}?{urlencode({'q': query.strip()})}"
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = requests.Session()
|
||||||
|
try:
|
||||||
|
html = _get_page(session, search_url).text
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise CsfdError(f"ČSFD hledání selhalo: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
films: list[CsfdFilm] = []
|
||||||
|
for anchor in _films_section(soup).select("a.film-title-name"):
|
||||||
|
href = str(anchor.get("href") or "")
|
||||||
|
if not _is_film_href(href):
|
||||||
|
continue
|
||||||
|
films.append(CsfdFilm(
|
||||||
|
url=urljoin(CSFD_BASE_URL, href),
|
||||||
|
csfd_id=_extract_csfd_id(href),
|
||||||
|
czech_title=anchor.get_text(strip=True),
|
||||||
|
))
|
||||||
|
if len(films) >= limit:
|
||||||
|
break
|
||||||
|
return films
|
||||||
|
|
||||||
|
|
||||||
|
def find_csfd_url(query: str, session=None) -> Optional[str]:
|
||||||
|
"""Return the first ČSFD film URL matching ``query``, or ``None``.
|
||||||
|
|
||||||
|
ČSFD's full-text search treats a trailing year as a search term (not a
|
||||||
|
filter) and can miss on ``"Title 2006"`` while ``"Title"`` hits — so a
|
||||||
|
yearful query with no result is retried without the year.
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
return None
|
||||||
|
query = query.strip()
|
||||||
|
films = search_films(query, limit=1, session=session)
|
||||||
|
if not films:
|
||||||
|
without_year = re.sub(r"\s+(?:19|20)\d{2}$", "", query)
|
||||||
|
if without_year != query:
|
||||||
|
films = search_films(without_year, limit=1, session=session)
|
||||||
|
return films[0].url if films else None
|
||||||
|
|
||||||
|
|
||||||
|
# Release-name tokens that mark the end of the actual title in a filename.
|
||||||
|
_RELEASE_MARKERS = {
|
||||||
|
"bluray", "blu-ray", "brrip", "bdrip", "bdremux", "remux", "webrip", "web",
|
||||||
|
"web-dl", "webdl", "hdtv", "dvdrip", "dvd", "dvd5", "dvd9", "hdrip", "cam",
|
||||||
|
"ts", "tc", "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx",
|
||||||
|
"aac", "ac3", "eac3", "dts", "dd5", "ddp5", "truehd", "atmos", "flac",
|
||||||
|
"10bit", "8bit", "hdr", "hdr10", "dolby", "sdr", "proper", "repack",
|
||||||
|
"extended", "unrated", "remastered", "imax", "multi", "dual", "complete",
|
||||||
|
"internal", "limited", "uncut",
|
||||||
|
}
|
||||||
|
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
||||||
|
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_filename_to_query(filename: str) -> str:
|
||||||
|
"""Turn a (possibly release-named) filename into a ČSFD search query.
|
||||||
|
|
||||||
|
Strips the path/extension, splits on common separators and keeps the words
|
||||||
|
before the first release marker (year, resolution, codec, source, …). The
|
||||||
|
detected year is appended back as a disambiguator::
|
||||||
|
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||||
|
"""
|
||||||
|
stem = Path(filename).stem
|
||||||
|
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
|
||||||
|
|
||||||
|
title_words: list[str] = []
|
||||||
|
year: Optional[str] = None
|
||||||
|
for token in tokens:
|
||||||
|
bare = token.strip("()[]{}")
|
||||||
|
if _YEAR_RE.match(bare):
|
||||||
|
year = bare
|
||||||
|
break
|
||||||
|
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
|
||||||
|
break
|
||||||
|
title_words.append(token)
|
||||||
|
|
||||||
|
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
|
||||||
|
return f"{title} {year}".strip() if year else title
|
||||||
+121
-15
@@ -33,15 +33,64 @@ class File:
|
|||||||
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
# free-form per-movie attributes (e.g. "collection_sort"); usable in
|
||||||
# miniDLNA filename templates via name_context.
|
# miniDLNA filename templates via name_context.
|
||||||
self.attributes: dict[str, str] = {}
|
self.attributes: dict[str, str] = {}
|
||||||
|
# Identity key of the movie record this file belongs to (set on load /
|
||||||
|
# first save); ``None`` only for an index-less, in-memory File.
|
||||||
|
self._movie_key: str | None = None
|
||||||
self.get_metadata()
|
self.get_metadata()
|
||||||
|
|
||||||
|
def _identity_from_path(self) -> tuple[str, str, str | None, int | None, str]:
|
||||||
|
"""``(key, title, year, tmdb_id, label)`` implied by this file's location.
|
||||||
|
|
||||||
|
A file inside its own ``Title (rok) [tmdbid]`` folder is one version of
|
||||||
|
that movie — identity comes from the **folder**, and the rest of its own
|
||||||
|
stem is the version label. A flat file carries both in its own name.
|
||||||
|
"""
|
||||||
|
from .movie import identity_key
|
||||||
|
from .naming import (is_canonical_pool_stem, parse_3d_format,
|
||||||
|
parse_pool_stem, parse_tmdb_id)
|
||||||
|
|
||||||
|
parent = self.file_path.parent.name
|
||||||
|
grouped = is_canonical_pool_stem(parent)
|
||||||
|
identity_stem = parent if grouped else self.file_path.stem
|
||||||
|
parsed = parse_pool_stem(identity_stem)
|
||||||
|
year = str(parsed[1]) if parsed else None
|
||||||
|
tmdb_id = parse_tmdb_id(identity_stem)
|
||||||
|
title = self.title or (parsed[0] if parsed else self.file_path.stem)
|
||||||
|
|
||||||
|
label = ""
|
||||||
|
if grouped and self.file_path.stem.startswith(f"{parent} - "):
|
||||||
|
label = self.file_path.stem[len(parent) + 3:]
|
||||||
|
elif parse_3d_format(self.file_path.stem):
|
||||||
|
label = parse_3d_format(self.file_path.stem) or ""
|
||||||
|
return identity_key(tmdb_id, title, year), title, year, tmdb_id, label
|
||||||
|
|
||||||
def get_metadata(self) -> None:
|
def get_metadata(self) -> None:
|
||||||
record = self.index.get(self.file_path) if self.index is not None else None
|
"""Load this file's metadata from the movie record that owns it.
|
||||||
if record is None:
|
|
||||||
|
Since the pool index became identity-keyed (see :mod:`src.core.movie`),
|
||||||
|
a File is a *view* on one media entry of a movie record — every version
|
||||||
|
of the same film therefore reads the same title/tags/ČSFD/… A file the
|
||||||
|
index doesn't know yet joins the movie of the same identity when there
|
||||||
|
is one (that's how a newly added version inherits the shared metadata
|
||||||
|
instead of blanking it), otherwise it starts a new record.
|
||||||
|
"""
|
||||||
|
if self.index is None:
|
||||||
self._init_new_metadata()
|
self._init_new_metadata()
|
||||||
self.save_metadata()
|
return
|
||||||
else:
|
|
||||||
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is not None:
|
||||||
|
self._movie_key, record = found
|
||||||
self._apply_record(record)
|
self._apply_record(record)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._init_new_metadata()
|
||||||
|
key, *_ = self._identity_from_path()
|
||||||
|
existing = self.index.get_movie(key)
|
||||||
|
if existing is not None:
|
||||||
|
self._movie_key = key
|
||||||
|
self._apply_record(existing)
|
||||||
|
self.save_metadata()
|
||||||
|
|
||||||
def _init_new_metadata(self) -> None:
|
def _init_new_metadata(self) -> None:
|
||||||
self.new = True
|
self.new = True
|
||||||
@@ -101,9 +150,42 @@ class File:
|
|||||||
self.tags.append(tag)
|
self.tags.append(tag)
|
||||||
|
|
||||||
def save_metadata(self):
|
def save_metadata(self):
|
||||||
"""Persist metadata to the pool index; a no-op for an index-less File."""
|
"""Persist metadata into this file's movie record (no-op without index).
|
||||||
if self.index is not None:
|
|
||||||
self.index.set(self.file_path, self._build_record())
|
Writes the shared fields onto the movie record and makes sure the
|
||||||
|
record lists this file among its media. A file the index has never seen
|
||||||
|
starts a new record under its own identity key.
|
||||||
|
"""
|
||||||
|
if self.index is None:
|
||||||
|
return
|
||||||
|
from .movie import ROLE_VIDEO
|
||||||
|
|
||||||
|
derived_key, title, year, tmdb_id, label = self._identity_from_path()
|
||||||
|
key = self._movie_key or derived_key
|
||||||
|
record = self.index.get_movie(key) or {}
|
||||||
|
record.update(self._build_record())
|
||||||
|
# The file's actual location is the truth — a move must update this.
|
||||||
|
record["library"] = self._library_name()
|
||||||
|
record["title"] = title
|
||||||
|
record["year"] = year
|
||||||
|
record["tmdb_id"] = tmdb_id
|
||||||
|
|
||||||
|
# Keep this file listed exactly once, with the label its name implies.
|
||||||
|
rel = self.index._key(self.file_path)
|
||||||
|
media = [m for m in record.get("media", []) if m.get("file") != rel]
|
||||||
|
media.append({"role": ROLE_VIDEO, "label": label, "file": rel})
|
||||||
|
record["media"] = media
|
||||||
|
|
||||||
|
self.index.set_movie(key, record)
|
||||||
|
self._movie_key = key
|
||||||
|
|
||||||
|
def _library_name(self) -> str:
|
||||||
|
"""Top-level pool folder this file sits in (its library), or ``""``."""
|
||||||
|
if self.index is None:
|
||||||
|
return ""
|
||||||
|
rel = self.index._key(self.file_path)
|
||||||
|
parts = Path(rel).parts
|
||||||
|
return parts[0] if len(parts) > 1 else ""
|
||||||
|
|
||||||
def added_timestamp(self) -> float:
|
def added_timestamp(self) -> float:
|
||||||
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
|
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
|
||||||
@@ -169,19 +251,43 @@ class File:
|
|||||||
self.save_metadata()
|
self.save_metadata()
|
||||||
|
|
||||||
def delete_metadata(self) -> None:
|
def delete_metadata(self) -> None:
|
||||||
"""Remove this file's metadata from the pool index (no-op without one)."""
|
"""Drop this file from its movie record; the last one takes the record.
|
||||||
if self.index is not None:
|
|
||||||
self.index.delete(self.file_path)
|
Removing one version of a multi-version movie leaves the movie (and its
|
||||||
|
metadata) in place — only when its last media entry goes does the whole
|
||||||
|
record go with it.
|
||||||
|
"""
|
||||||
|
if self.index is None:
|
||||||
|
return
|
||||||
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is None:
|
||||||
|
return
|
||||||
|
key, record = found
|
||||||
|
rel = self.index._key(self.file_path)
|
||||||
|
record["media"] = [m for m in record.get("media", []) if m.get("file") != rel]
|
||||||
|
if record["media"]:
|
||||||
|
self.index.set_movie(key, record)
|
||||||
|
else:
|
||||||
|
self.index.delete_movie(key)
|
||||||
|
|
||||||
def relocate(self, new_path: Path) -> None:
|
def relocate(self, new_path: Path) -> None:
|
||||||
"""Point this File at a new path, moving its metadata along.
|
"""Point this File at a new path, moving its media entry along.
|
||||||
|
|
||||||
The physical file must already have been moved/renamed by the caller.
|
The physical file must already have been moved/renamed by the caller;
|
||||||
Drops the metadata under the old index key and rebinds to the new path;
|
this only repoints the movie record's media entry (and the File), so a
|
||||||
call ``save_metadata()`` afterwards to write it back.
|
rename never detaches a file from its movie.
|
||||||
"""
|
"""
|
||||||
if self.index is not None:
|
if self.index is not None:
|
||||||
self.index.delete(self.file_path)
|
found = self.index.find_movie_by_media(self.file_path)
|
||||||
|
if found is not None:
|
||||||
|
key, record = found
|
||||||
|
old_rel = self.index._key(self.file_path)
|
||||||
|
new_rel = self.index._key(Path(new_path))
|
||||||
|
for media in record.get("media", []):
|
||||||
|
if media.get("file") == old_rel:
|
||||||
|
media["file"] = new_rel
|
||||||
|
self.index.set_movie(key, record)
|
||||||
|
self._movie_key = key
|
||||||
self.file_path = Path(new_path)
|
self.file_path = Path(new_path)
|
||||||
self.filename = self.file_path.name
|
self.filename = self.file_path.name
|
||||||
|
|
||||||
|
|||||||
+544
-32
@@ -7,7 +7,10 @@ from .tag_manager import TagManager
|
|||||||
from .pool_index import PoolIndex
|
from .pool_index import PoolIndex
|
||||||
from .utils import list_files
|
from .utils import list_files
|
||||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
|
from .naming import (
|
||||||
|
canonical_pool_stem, is_canonical_pool_stem, is_valid_year, parse_3d_format,
|
||||||
|
parse_pool_stem, parse_tmdb_id)
|
||||||
|
from . import subtitles
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
from src.core.config import (
|
from src.core.config import (
|
||||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||||
@@ -168,6 +171,118 @@ class FileManager:
|
|||||||
"""Names of libraries matching any of ``kinds``."""
|
"""Names of libraries matching any of ``kinds``."""
|
||||||
return [lib["name"] for lib in self.libraries if lib["kind"] in kinds]
|
return [lib["name"] for lib in self.libraries if lib["kind"] in kinds]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def minidlna_libraries(self) -> list[str] | None:
|
||||||
|
"""Libraries included in miniDLNA generation, or ``None`` for "all" (default).
|
||||||
|
|
||||||
|
Persisted in the pool index (it describes this pool, not app state) via
|
||||||
|
*miniDLNA → "Knihovny pro generování…"*. Names no longer in
|
||||||
|
:attr:`libraries` are dropped when read, so a renamed/removed library
|
||||||
|
can't silently wedge the selection.
|
||||||
|
"""
|
||||||
|
idx = self._ensure_index()
|
||||||
|
raw = idx.get_setting("minidlna_libraries", None) if idx is not None else None
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
known = {lib["name"] for lib in self.libraries}
|
||||||
|
return [n for n in raw if n in known]
|
||||||
|
|
||||||
|
def set_minidlna_libraries(self, names: list[str] | None) -> None:
|
||||||
|
"""Restrict miniDLNA generation to ``names``; ``None`` resets to "all"."""
|
||||||
|
self._set_setting_raw(
|
||||||
|
"minidlna_libraries", None if names is None else list(dict.fromkeys(names)))
|
||||||
|
|
||||||
|
def _set_setting_raw(self, key: str, value) -> None:
|
||||||
|
"""Persist an arbitrary pool-index setting (no legacy-default fallback)."""
|
||||||
|
idx = self._ensure_index()
|
||||||
|
if idx is None:
|
||||||
|
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
||||||
|
idx.set_setting(key, value)
|
||||||
|
|
||||||
|
def minidlna_scope(self) -> tuple[list[File], list[str], list]:
|
||||||
|
"""``(movies, mirrored library names, mixed-library series)`` for miniDLNA.
|
||||||
|
|
||||||
|
Narrowed to :attr:`minidlna_libraries` when set — movies by
|
||||||
|
:meth:`library_of`, mirrored libraries by name, mixed-library series by
|
||||||
|
their library — otherwise the whole pool (today's behaviour). Excluding
|
||||||
|
a movie library here means its genre-tree links (and its share of "Tipy
|
||||||
|
dne" / "Nově přidané") are swept as obsolete on the next generation, not
|
||||||
|
merely skipped — the caller should say so before generating.
|
||||||
|
|
||||||
|
The **mirrored libraries** are ``series`` (copy-as-is) *and*
|
||||||
|
``homevideo`` kinds — neither has genre tags to build a tree from
|
||||||
|
(homevideo carries no TMDb id either), so both are hardcopied whole
|
||||||
|
into ``output/<library name>`` instead of going through the tag tree.
|
||||||
|
|
||||||
|
A ``mixed`` library isn't itself mirrored (its top-level files are
|
||||||
|
movies, tagged into the genre tree like any other), but any **series
|
||||||
|
sitting in its subfolders** have no genre tags to speak of — those are
|
||||||
|
returned separately so the caller can hardcopy them too (e.g. into
|
||||||
|
``output/Seriály/<jejich vlastní název>``).
|
||||||
|
"""
|
||||||
|
selected = self.minidlna_libraries
|
||||||
|
mixed_libs = set(self.libraries_of_kind("mixed"))
|
||||||
|
if selected is not None:
|
||||||
|
mixed_libs &= set(selected)
|
||||||
|
mixed_series = (
|
||||||
|
[s for s in self.load_series() if s.library in mixed_libs]
|
||||||
|
if mixed_libs else [])
|
||||||
|
|
||||||
|
mirror_libs = self.mirror_as_is_libraries
|
||||||
|
if selected is None:
|
||||||
|
return list(self.filelist), mirror_libs, mixed_series
|
||||||
|
chosen = set(selected)
|
||||||
|
movies = [f for f in self.filelist if self.library_of(f) in chosen]
|
||||||
|
mirrors = [name for name in mirror_libs if name in chosen]
|
||||||
|
return movies, mirrors, mixed_series
|
||||||
|
|
||||||
|
def minidlna_prune_targets(
|
||||||
|
self, mirror_libs: list[str], mixed_series: list, mixed_series_bucket: str,
|
||||||
|
) -> tuple[list[str], set[str]]:
|
||||||
|
"""What miniDLNA output to remove for mirrors that fell out of scope.
|
||||||
|
|
||||||
|
``mirror_libs`` / ``mixed_series`` come from :meth:`minidlna_scope` (the
|
||||||
|
*current* run's selection); ``mixed_series_bucket`` is the shared output
|
||||||
|
folder mixed-library series hardcopy into (e.g. ``"Seriály"``) — the same
|
||||||
|
name a real ``series``/``homevideo`` library may also use, so the two
|
||||||
|
sources are reconciled together rather than one blindly wiping the other.
|
||||||
|
|
||||||
|
Returns ``(stale_libraries, valid_bucket_children)``:
|
||||||
|
|
||||||
|
- ``stale_libraries`` — mirrored-library output folders (``output/<name>``)
|
||||||
|
to delete **entirely**: every :attr:`mirror_as_is_libraries` name no
|
||||||
|
longer in ``mirror_libs``, except one that shares the bucket's name
|
||||||
|
(reconciled via the second element instead).
|
||||||
|
- ``valid_bucket_children`` — basenames that must remain directly under
|
||||||
|
``output/<mixed_series_bucket>`` this run: the in-scope mixed-library
|
||||||
|
series' own folder names, plus — when a real library named
|
||||||
|
``mixed_series_bucket`` is itself in scope — that library's current
|
||||||
|
subfolders. Anything else found there is stale.
|
||||||
|
"""
|
||||||
|
in_scope = set(mirror_libs)
|
||||||
|
stale = [name for name in self.mirror_as_is_libraries
|
||||||
|
if name not in in_scope and name != mixed_series_bucket]
|
||||||
|
|
||||||
|
valid = {ser.folder.name for ser in mixed_series}
|
||||||
|
if mixed_series_bucket in in_scope:
|
||||||
|
pool = self.pool_dir
|
||||||
|
src = pool / mixed_series_bucket if pool else None
|
||||||
|
if src and src.is_dir():
|
||||||
|
valid |= {p.name for p in src.iterdir() if p.is_dir()}
|
||||||
|
return stale, valid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mirror_as_is_libraries(self) -> list[str]:
|
||||||
|
"""Libraries hardcopied 1:1 into miniDLNA — ``series`` and ``homevideo``.
|
||||||
|
|
||||||
|
Neither has genre tags to build a tree from (homevideo has no TMDb id
|
||||||
|
either), so both mirror whole into ``output/<library name>`` instead of
|
||||||
|
going through the tag tree. A superset of :attr:`copyasis_folders`
|
||||||
|
(which the library organizer UI still calls "copy-as-is" and keeps
|
||||||
|
scoped to kind ``series`` only).
|
||||||
|
"""
|
||||||
|
return self.libraries_of_kind("series", "homevideo")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def copyasis_folders(self) -> list[str]:
|
def copyasis_folders(self) -> list[str]:
|
||||||
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
|
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
|
||||||
@@ -334,7 +449,7 @@ class FileManager:
|
|||||||
|
|
||||||
Returns a dict with:
|
Returns a dict with:
|
||||||
ok — False if the pool isn't set (with 'error')
|
ok — False if the pool isn't set (with 'error')
|
||||||
missing — index keys whose file no longer exists on disk
|
missing — indexed media files that no longer exist on disk
|
||||||
untracked — pool files (pool-relative) with no index entry
|
untracked — pool files (pool-relative) with no index entry
|
||||||
index_count / disk_count — totals for the summary
|
index_count / disk_count — totals for the summary
|
||||||
"""
|
"""
|
||||||
@@ -344,7 +459,14 @@ class FileManager:
|
|||||||
"missing": [], "untracked": []}
|
"missing": [], "untracked": []}
|
||||||
|
|
||||||
index = self.index or PoolIndex(pool)
|
index = self.index or PoolIndex(pool)
|
||||||
index_keys = set(index.records.keys())
|
# Records are keyed by movie identity, so the paths to compare are the
|
||||||
|
# media entries listed inside them.
|
||||||
|
index_keys = {
|
||||||
|
media["file"]
|
||||||
|
for record in index.all_movies().values()
|
||||||
|
for media in record.get("media", [])
|
||||||
|
if media.get("file")
|
||||||
|
}
|
||||||
|
|
||||||
disk_keys = set()
|
disk_keys = set()
|
||||||
for lib in self.libraries:
|
for lib in self.libraries:
|
||||||
@@ -518,6 +640,7 @@ class FileManager:
|
|||||||
move: bool = False, on_conflict: str = "suffix",
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
year: int | str | None = None, tmdb_id: int | str | None = None,
|
year: int | str | None = None, tmdb_id: int | str | None = None,
|
||||||
library: str | None = None, csfd_url: str | None = None,
|
library: str | None = None, csfd_url: str | None = None,
|
||||||
|
format_3d: str | None = None,
|
||||||
) -> File | None:
|
) -> File | None:
|
||||||
"""Bring a video file into a movie library and index its metadata.
|
"""Bring a video file into a movie library and index its metadata.
|
||||||
|
|
||||||
@@ -527,6 +650,8 @@ class FileManager:
|
|||||||
``tmdb_id`` when known — the form Jellyfin matches on); without a year it
|
``tmdb_id`` when known — the form Jellyfin matches on); without a year it
|
||||||
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
||||||
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
|
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
|
||||||
|
``format_3d`` (one of :data:`naming.THREE_D_FORMATS`) appends the
|
||||||
|
``.3D.<FORMAT>`` tag right away, when the file is already a 3D rip.
|
||||||
|
|
||||||
By default the original is **copied** (non-destructive). With ``move=True``
|
By default the original is **copied** (non-destructive). With ``move=True``
|
||||||
the source file is moved into the pool instead, leaving nothing behind.
|
the source file is moved into the pool instead, leaving nothing behind.
|
||||||
@@ -555,7 +680,7 @@ class FileManager:
|
|||||||
source = Path(source)
|
source = Path(source)
|
||||||
safe_title = title.strip() or source.stem
|
safe_title = title.strip() or source.stem
|
||||||
has_year = year is not None and str(year).strip() != ""
|
has_year = year is not None and str(year).strip() != ""
|
||||||
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
|
stem = (canonical_pool_stem(safe_title, year, tmdb_id, format_3d) if has_year
|
||||||
else safe_title)
|
else safe_title)
|
||||||
target = movies / f"{stem}{source.suffix}"
|
target = movies / f"{stem}{source.suffix}"
|
||||||
|
|
||||||
@@ -600,7 +725,9 @@ class FileManager:
|
|||||||
extension is preserved; ``new_title`` is the bare name without it.
|
extension is preserved; ``new_title`` is the bare name without it.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: empty name or a name containing a path separator.
|
ValueError: empty name, a name containing a path separator, or
|
||||||
|
``file_obj`` is one version of a multi-version movie (renaming
|
||||||
|
just one file would orphan its siblings).
|
||||||
FileExistsError: another pooled file already uses that name.
|
FileExistsError: another pooled file already uses that name.
|
||||||
"""
|
"""
|
||||||
new_title = new_title.strip()
|
new_title = new_title.strip()
|
||||||
@@ -608,6 +735,10 @@ class FileManager:
|
|||||||
raise ValueError("Název nesmí být prázdný.")
|
raise ValueError("Název nesmí být prázdný.")
|
||||||
if "/" in new_title or "\\" in new_title:
|
if "/" in new_title or "\\" in new_title:
|
||||||
raise ValueError("Název nesmí obsahovat lomítka.")
|
raise ValueError("Název nesmí obsahovat lomítka.")
|
||||||
|
if self.is_movie_group_member(file_obj):
|
||||||
|
raise ValueError(
|
||||||
|
"Film s více verzemi zatím nelze takhle přejmenovat — "
|
||||||
|
"odeber verzi nebo uprav soubory ve složce filmu ručně.")
|
||||||
|
|
||||||
old_path = file_obj.file_path
|
old_path = file_obj.file_path
|
||||||
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
|
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
|
||||||
@@ -618,6 +749,7 @@ class FileManager:
|
|||||||
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
||||||
|
|
||||||
old_path.rename(new_path)
|
old_path.rename(new_path)
|
||||||
|
subtitles.rename_subtitles_with(old_path, new_path)
|
||||||
file_obj.relocate(new_path)
|
file_obj.relocate(new_path)
|
||||||
file_obj.title = new_title
|
file_obj.title = new_title
|
||||||
file_obj.save_metadata()
|
file_obj.save_metadata()
|
||||||
@@ -626,13 +758,15 @@ class FileManager:
|
|||||||
self.on_files_changed(self.filelist)
|
self.on_files_changed(self.filelist)
|
||||||
return file_obj
|
return file_obj
|
||||||
|
|
||||||
def _canonical_name_for(self, file_obj: File) -> str | None:
|
def _canonical_name_for(self, file_obj: File, *, include_3d: bool = True) -> str | None:
|
||||||
"""The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
|
"""The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
|
||||||
|
|
||||||
Uses the clean title and the year from the movie's metadata
|
Uses the clean title and the year from the movie's metadata
|
||||||
(``name_context``: cache → ``Rok`` tag → the current filename). A
|
(``name_context``: cache → ``Rok`` tag → the current filename). A
|
||||||
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
|
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
|
||||||
an existing ``[tmdbid-N]`` on the current filename is preserved. Returns
|
an existing ``[tmdbid-N]`` / ``.3D.<FORMAT>`` on the current filename is
|
||||||
|
preserved (``include_3d=False`` drops the 3D tag — used for a movie-group
|
||||||
|
**folder** name, where 3D becomes a per-version label instead). Returns
|
||||||
``None`` when no four-digit year is known.
|
``None`` when no four-digit year is known.
|
||||||
"""
|
"""
|
||||||
ctx = file_obj.name_context()
|
ctx = file_obj.name_context()
|
||||||
@@ -644,13 +778,14 @@ class FileManager:
|
|||||||
title = parsed[0]
|
title = parsed[0]
|
||||||
year = year or str(parsed[1])
|
year = year or str(parsed[1])
|
||||||
|
|
||||||
# Keep the TMDb id already in the pool filename, if any.
|
# Keep the TMDb id / 3D tag already in the pool filename, if any.
|
||||||
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
|
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
|
||||||
|
format_3d = parse_3d_format(file_obj.file_path.stem) if include_3d else None
|
||||||
|
|
||||||
title = title.replace("/", "-").replace("\\", "-").strip()
|
title = title.replace("/", "-").replace("\\", "-").strip()
|
||||||
if not (title and is_valid_year(year)):
|
if not (title and is_valid_year(year)):
|
||||||
return None
|
return None
|
||||||
return canonical_pool_stem(title, year, tmdb_id)
|
return canonical_pool_stem(title, year, tmdb_id, format_3d)
|
||||||
|
|
||||||
def rename_to_canonical(self, file_obj: File) -> File | None:
|
def rename_to_canonical(self, file_obj: File) -> File | None:
|
||||||
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
||||||
@@ -658,15 +793,20 @@ class FileManager:
|
|||||||
The year comes from the movie's metadata (NFO cache, falling back to a
|
The year comes from the movie's metadata (NFO cache, falling back to a
|
||||||
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
|
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
|
||||||
(without the year). Does not fire ``on_files_changed`` — the caller
|
(without the year). Does not fire ``on_files_changed`` — the caller
|
||||||
refreshes once after a batch.
|
refreshes once after a batch. A movie with multiple versions is skipped
|
||||||
|
(``None``) — renaming one version alone would orphan its siblings; use
|
||||||
|
the version tools in *Upravit film* instead.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The file when renamed (or already canonical); ``None`` when no
|
The file when renamed (or already canonical); ``None`` when no
|
||||||
four-digit year is known, so no canonical name can be built.
|
four-digit year is known (or the movie has multiple versions), so
|
||||||
|
no canonical name can be built.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileExistsError: another pooled file already uses that name.
|
FileExistsError: another pooled file already uses that name.
|
||||||
"""
|
"""
|
||||||
|
if self.is_movie_group_member(file_obj):
|
||||||
|
return None
|
||||||
new_stem = self._canonical_name_for(file_obj)
|
new_stem = self._canonical_name_for(file_obj)
|
||||||
if new_stem is None:
|
if new_stem is None:
|
||||||
return None
|
return None
|
||||||
@@ -680,6 +820,7 @@ class FileManager:
|
|||||||
|
|
||||||
parsed = parse_pool_stem(new_stem)
|
parsed = parse_pool_stem(new_stem)
|
||||||
old_path.rename(new_path)
|
old_path.rename(new_path)
|
||||||
|
subtitles.rename_subtitles_with(old_path, new_path)
|
||||||
file_obj.relocate(new_path)
|
file_obj.relocate(new_path)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
file_obj.title = parsed[0]
|
file_obj.title = parsed[0]
|
||||||
@@ -740,7 +881,9 @@ class FileManager:
|
|||||||
|
|
||||||
Moves the file into ``pool/<new_library>/`` and re-indexes it under the
|
Moves the file into ``pool/<new_library>/`` and re-indexes it under the
|
||||||
new key. Raises:
|
new key. Raises:
|
||||||
ValueError: the target isn't a known movie library.
|
ValueError: the target isn't a known movie library, or ``file_obj``
|
||||||
|
is one version of a multi-version movie (moving just one file
|
||||||
|
would orphan its siblings).
|
||||||
FileExistsError: a file of that name already sits in the target.
|
FileExistsError: a file of that name already sits in the target.
|
||||||
"""
|
"""
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
@@ -749,6 +892,10 @@ class FileManager:
|
|||||||
new_library = new_library.strip()
|
new_library = new_library.strip()
|
||||||
if new_library not in self.libraries_accepting_movies:
|
if new_library not in self.libraries_accepting_movies:
|
||||||
raise ValueError(f"Neznámá knihovna „{new_library}“.")
|
raise ValueError(f"Neznámá knihovna „{new_library}“.")
|
||||||
|
if self.is_movie_group_member(file_obj):
|
||||||
|
raise ValueError(
|
||||||
|
"Film s více verzemi zatím nelze takhle přesunout — "
|
||||||
|
"odeber verzi, nebo přesuň celou složku filmu ručně.")
|
||||||
|
|
||||||
old_path = file_obj.file_path
|
old_path = file_obj.file_path
|
||||||
dest_dir = pool / new_library
|
dest_dir = pool / new_library
|
||||||
@@ -760,6 +907,7 @@ class FileManager:
|
|||||||
raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.")
|
raise FileExistsError(f"V knihovně „{new_library}“ už „{old_path.name}“ je.")
|
||||||
|
|
||||||
shutil.move(str(old_path), str(new_path))
|
shutil.move(str(old_path), str(new_path))
|
||||||
|
subtitles.rename_subtitles_with(old_path, new_path)
|
||||||
file_obj.relocate(new_path) # drops the old index key, rebinds to the new
|
file_obj.relocate(new_path) # drops the old index key, rebinds to the new
|
||||||
file_obj.save_metadata()
|
file_obj.save_metadata()
|
||||||
|
|
||||||
@@ -772,13 +920,19 @@ class FileManager:
|
|||||||
tmdb_id: int | str | None = None, library: str | None = None,
|
tmdb_id: int | str | None = None, library: str | None = None,
|
||||||
date: str | None = None, attributes: dict | None = None,
|
date: str | None = None, attributes: dict | None = None,
|
||||||
tags: list[str] | None = None, csfd_url: str | None = None,
|
tags: list[str] | None = None, csfd_url: str | None = None,
|
||||||
|
format_3d: str | None = None,
|
||||||
) -> File:
|
) -> File:
|
||||||
"""Apply all editable parameters of a movie at once (rename/move + metadata).
|
"""Apply all editable parameters of a movie at once (rename/move + metadata).
|
||||||
|
|
||||||
Renames the file to ``Title (rok) [tmdbid].ext`` (year permitting), moves
|
Renames the file to ``Title (rok) [tmdbid].3D.<FORMAT>.ext`` (year/3D
|
||||||
it into ``library``, and sets ``title`` / ``date`` / ``attributes`` /
|
permitting), moves it into ``library``, and sets ``title`` / ``date`` /
|
||||||
``tags`` (a full ``["Category/Name", …]`` list; NFO-tag provenance is kept
|
``attributes`` / ``tags`` (a full ``["Category/Name", …]`` list;
|
||||||
for the ones that remain). No-ops that don't change the path skip the move.
|
NFO-tag provenance is kept for the ones that remain). No-ops that don't
|
||||||
|
change the path skip the move.
|
||||||
|
|
||||||
|
``format_3d`` follows :func:`naming.canonical_pool_stem`: omitted
|
||||||
|
(``None``) keeps whatever 3D tag the file already has, an empty string
|
||||||
|
clears it, one of :data:`naming.THREE_D_FORMATS` sets it.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileExistsError: the target name is already taken.
|
FileExistsError: the target name is already taken.
|
||||||
@@ -788,17 +942,33 @@ class FileManager:
|
|||||||
raise RuntimeError("Pool není nastaven.")
|
raise RuntimeError("Pool není nastaven.")
|
||||||
title = (title or "").strip() or file_obj.file_path.stem
|
title = (title or "").strip() or file_obj.file_path.stem
|
||||||
year_s = str(year).strip() if year not in (None, "") else ""
|
year_s = str(year).strip() if year not in (None, "") else ""
|
||||||
stem = (canonical_pool_stem(title, year_s, tmdb_id)
|
grouped = self.is_movie_group_member(file_obj)
|
||||||
|
if format_3d is None:
|
||||||
|
format_3d = None if grouped else parse_3d_format(file_obj.file_path.stem)
|
||||||
|
stem = (canonical_pool_stem(title, year_s, tmdb_id, format_3d)
|
||||||
if is_valid_year(year_s) else title)
|
if is_valid_year(year_s) else title)
|
||||||
lib = ((library or "").strip()
|
lib = ((library or "").strip()
|
||||||
or self.library_of(file_obj) or self.movie_libraries[0])
|
or self.library_of(file_obj) or self.movie_libraries[0])
|
||||||
old_path = file_obj.file_path
|
old_path = file_obj.file_path
|
||||||
|
|
||||||
|
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}"
|
new_path = pool / lib / f"{stem}{old_path.suffix}"
|
||||||
if new_path != old_path:
|
if new_path != old_path:
|
||||||
if new_path.exists():
|
if new_path.exists():
|
||||||
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.")
|
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.")
|
||||||
new_path.parent.mkdir(parents=True, exist_ok=True)
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.move(str(old_path), str(new_path))
|
shutil.move(str(old_path), str(new_path))
|
||||||
|
subtitles.rename_subtitles_with(old_path, new_path)
|
||||||
file_obj.relocate(new_path)
|
file_obj.relocate(new_path)
|
||||||
|
|
||||||
file_obj.title = title
|
file_obj.title = title
|
||||||
@@ -826,6 +996,218 @@ class FileManager:
|
|||||||
self.on_files_changed(self.filelist)
|
self.on_files_changed(self.filelist)
|
||||||
return file_obj
|
return file_obj
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Movie versions (Jellyfin "multiple versions": 2D + 3D, 4K + 1080p, …)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# A movie is identified by (title, year, tmdbid) — the canonical stem. As
|
||||||
|
# long as it has exactly one video file, it stays flat:
|
||||||
|
# pool/<Library>/Title (rok) [tmdbid].ext
|
||||||
|
# The moment a second version is added, Curator promotes it to its own
|
||||||
|
# folder (Jellyfin's own "multiple versions" convention — every file in it
|
||||||
|
# must start with the folder's exact name):
|
||||||
|
# pool/<Library>/Title (rok) [tmdbid]/
|
||||||
|
# Title (rok) [tmdbid] - Výchozí.ext
|
||||||
|
# Title (rok) [tmdbid] - 3D HSBS.ext
|
||||||
|
# Shared metadata (tags, ČSFD odkaz, datum, atributy) lives on whichever
|
||||||
|
# version is alphabetically first (Jellyfin's own "default version" rule —
|
||||||
|
# `visible_movies` shows only that one in the table). Renaming/moving/
|
||||||
|
# retitling a grouped movie is not supported yet (see the guards in
|
||||||
|
# `rename_movie` / `move_to_library` / `apply_movie_edits`) — remove a
|
||||||
|
# version (auto-demotes back to flat when one is left) or edit the files
|
||||||
|
# directly if you need that.
|
||||||
|
|
||||||
|
def is_movie_group_member(self, file_obj: File) -> bool:
|
||||||
|
"""True when ``file_obj`` is one version inside its own per-movie folder.
|
||||||
|
|
||||||
|
Only recognised for ``movie``-kind libraries in this first cut — a
|
||||||
|
``mixed`` library's subfolders are always treated as series.
|
||||||
|
"""
|
||||||
|
pool = self.pool_dir
|
||||||
|
lib = self.library_of(file_obj)
|
||||||
|
if pool is None or lib is None or lib not in self.libraries_of_kind("movie"):
|
||||||
|
return False
|
||||||
|
parent = file_obj.file_path.parent
|
||||||
|
return parent != pool / lib and is_canonical_pool_stem(parent.name)
|
||||||
|
|
||||||
|
def list_movie_versions(self, file_obj: File) -> list[File]:
|
||||||
|
"""Every version sharing ``file_obj``'s movie-group folder, name-sorted.
|
||||||
|
|
||||||
|
A single-element list (just ``file_obj``) when it isn't grouped.
|
||||||
|
"""
|
||||||
|
if not self.is_movie_group_member(file_obj):
|
||||||
|
return [file_obj]
|
||||||
|
folder = file_obj.file_path.parent
|
||||||
|
versions = []
|
||||||
|
for p in sorted(folder.iterdir()):
|
||||||
|
if (not p.is_file() or p.suffix.lower() in SIDECAR_SUFFIXES
|
||||||
|
or p.name.endswith(METADATA_SUFFIXES)):
|
||||||
|
continue
|
||||||
|
existing = next((f for f in self.filelist if f.file_path == p), None)
|
||||||
|
versions.append(existing or File(p, self.tagmanager, index=self.index))
|
||||||
|
return versions
|
||||||
|
|
||||||
|
def primary_version(self, versions: list[File]) -> File:
|
||||||
|
"""Which version represents the movie in the table.
|
||||||
|
|
||||||
|
Metadata is shared by the whole movie record now, so this is purely a
|
||||||
|
display choice: the plain/unlabelled file first, then the one labelled
|
||||||
|
:data:`movie.DEFAULT_VERSION_LABEL`, then alphabetically. Unrelated to
|
||||||
|
which version Jellyfin plays by default (its own, alphabetical rule).
|
||||||
|
"""
|
||||||
|
from .movie import DEFAULT_VERSION_LABEL
|
||||||
|
|
||||||
|
def rank(f: File) -> tuple[int, str]:
|
||||||
|
label = f._identity_from_path()[4]
|
||||||
|
order = 0 if not label else (1 if label == DEFAULT_VERSION_LABEL else 2)
|
||||||
|
return order, f.file_path.name.casefold()
|
||||||
|
|
||||||
|
return min(versions, key=rank)
|
||||||
|
|
||||||
|
def visible_movies(self) -> list[File]:
|
||||||
|
"""``filelist`` collapsed to one row per movie for the table.
|
||||||
|
|
||||||
|
Ungrouped files pass through unchanged; a movie-group folder
|
||||||
|
contributes only its :meth:`primary_version` — the rest are reachable
|
||||||
|
via *Upravit film → Verze*, not as separate rows.
|
||||||
|
"""
|
||||||
|
seen_folders: set[Path] = set()
|
||||||
|
result: list[File] = []
|
||||||
|
for f in self.filelist:
|
||||||
|
if not self.is_movie_group_member(f):
|
||||||
|
result.append(f)
|
||||||
|
continue
|
||||||
|
folder = f.file_path.parent
|
||||||
|
if folder in seen_folders:
|
||||||
|
continue
|
||||||
|
seen_folders.add(folder)
|
||||||
|
result.append(self.primary_version(self.list_movie_versions(f)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def add_movie_version(
|
||||||
|
self, file_obj: File, source: Path, label: str, *,
|
||||||
|
move: bool = False, existing_label: str = "Výchozí",
|
||||||
|
) -> File:
|
||||||
|
"""Add ``source`` as another playable version of ``file_obj``'s movie.
|
||||||
|
|
||||||
|
Promotes a flat movie to its own per-movie folder on the first extra
|
||||||
|
version — the pre-existing file is moved in as
|
||||||
|
``<folder> - {existing_label}.ext`` (only on this first promotion) and
|
||||||
|
``source`` lands as ``<folder> - {label}.ext``. A movie already grouped
|
||||||
|
just gets one more file in the same folder. Shared metadata stays
|
||||||
|
wherever it already was; the new version starts with none of its own
|
||||||
|
(attach subtitles / a 3D formát to it separately if needed).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: no pool, or ``file_obj`` has no valid title+year yet.
|
||||||
|
ValueError: ``label`` is empty.
|
||||||
|
FileExistsError: a version of that name already exists.
|
||||||
|
"""
|
||||||
|
label = (label or "").strip()
|
||||||
|
if not label:
|
||||||
|
raise ValueError("Popisek verze nesmí být prázdný.")
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
source = Path(source)
|
||||||
|
|
||||||
|
stem: str | None
|
||||||
|
if self.is_movie_group_member(file_obj):
|
||||||
|
folder = file_obj.file_path.parent
|
||||||
|
stem = folder.name
|
||||||
|
else:
|
||||||
|
stem = self._canonical_name_for(file_obj, include_3d=False)
|
||||||
|
if stem is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Film nemá platný název/rok — nejde založit složku verzí.")
|
||||||
|
lib = self.library_of(file_obj) or self.movie_libraries[0]
|
||||||
|
folder = pool / lib / stem
|
||||||
|
if folder.exists():
|
||||||
|
raise FileExistsError(f"Složka „{stem}“ už v poolu existuje.")
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
old_path = file_obj.file_path
|
||||||
|
existing_target = folder / f"{stem} - {existing_label}{old_path.suffix}"
|
||||||
|
shutil.move(str(old_path), str(existing_target))
|
||||||
|
subtitles.rename_subtitles_with(old_path, existing_target)
|
||||||
|
file_obj.relocate(existing_target)
|
||||||
|
file_obj.save_metadata()
|
||||||
|
|
||||||
|
target = folder / f"{stem} - {label}{source.suffix}"
|
||||||
|
if target.exists():
|
||||||
|
raise FileExistsError(f"Verze „{label}“ už existuje.")
|
||||||
|
if move:
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
new_version = File(target, self.tagmanager, index=self.index)
|
||||||
|
new_version.added = datetime.now().isoformat(timespec="seconds")
|
||||||
|
new_version.save_metadata()
|
||||||
|
self.filelist.append(new_version)
|
||||||
|
if self.on_files_changed:
|
||||||
|
self.on_files_changed(self.filelist)
|
||||||
|
return new_version
|
||||||
|
|
||||||
|
def remove_movie_version(self, file_obj: File) -> None:
|
||||||
|
"""Remove one version (+ its subtitles) from a movie's version group.
|
||||||
|
|
||||||
|
When exactly one version is left afterward, the movie is demoted back
|
||||||
|
to a flat file — moved out to the library root under the bare
|
||||||
|
canonical stem — and the now-empty folder is removed. Metadata needs no
|
||||||
|
rescuing: it lives on the movie record, not on any one version, so
|
||||||
|
removing even the version shown in the table leaves it untouched.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: ``file_obj`` isn't part of a version group (nothing to
|
||||||
|
remove it *from* — a lone movie has no "versions" to drop).
|
||||||
|
"""
|
||||||
|
if not self.is_movie_group_member(file_obj):
|
||||||
|
raise ValueError("Film nemá žádné další verze k odebrání.")
|
||||||
|
folder = file_obj.file_path.parent
|
||||||
|
file_obj.delete_metadata()
|
||||||
|
for sub in subtitles.find_subtitles(file_obj.file_path):
|
||||||
|
sub.path.unlink()
|
||||||
|
file_obj.file_path.unlink()
|
||||||
|
if file_obj in self.filelist:
|
||||||
|
self.filelist.remove(file_obj)
|
||||||
|
|
||||||
|
remaining = [
|
||||||
|
p for p in folder.iterdir()
|
||||||
|
if p.is_file() and p.suffix.lower() not in SIDECAR_SUFFIXES
|
||||||
|
and not p.name.endswith(METADATA_SUFFIXES)]
|
||||||
|
if len(remaining) == 1:
|
||||||
|
sole = remaining[0]
|
||||||
|
sole_file = next((f for f in self.filelist if f.file_path == sole), None)
|
||||||
|
new_path = folder.parent / f"{folder.name}{sole.suffix}"
|
||||||
|
sole.rename(new_path)
|
||||||
|
subtitles.rename_subtitles_with(sole, new_path)
|
||||||
|
if sole_file is not None:
|
||||||
|
sole_file.relocate(new_path)
|
||||||
|
sole_file.save_metadata()
|
||||||
|
shutil.rmtree(folder, ignore_errors=True)
|
||||||
|
|
||||||
|
if self.on_files_changed:
|
||||||
|
self.on_files_changed(self.filelist)
|
||||||
|
|
||||||
|
def list_subtitles(self, file_obj: File) -> list:
|
||||||
|
"""External subtitle sidecars currently sitting next to a movie."""
|
||||||
|
return subtitles.find_subtitles(file_obj.file_path)
|
||||||
|
|
||||||
|
def attach_subtitle(
|
||||||
|
self, file_obj: File, source: Path, *, language: str | None = None,
|
||||||
|
flag: str | None = None, move: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Copy (or move) a picked subtitle file next to ``file_obj``, correctly named.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: ``source`` isn't a recognised subtitle format.
|
||||||
|
"""
|
||||||
|
return subtitles.attach_subtitle(
|
||||||
|
file_obj.file_path, source, language=language, flag=flag, move=move)
|
||||||
|
|
||||||
|
def remove_subtitle(self, subtitle_path: Path) -> None:
|
||||||
|
"""Delete one attached subtitle file."""
|
||||||
|
Path(subtitle_path).unlink()
|
||||||
|
|
||||||
def refresh_tags_from_tmdb(
|
def refresh_tags_from_tmdb(
|
||||||
self, files: list[File] | None = None,
|
self, files: list[File] | None = None,
|
||||||
on_progress: Callable[[int, int, File], None] | None = None,
|
on_progress: Callable[[int, int, File], None] | None = None,
|
||||||
@@ -867,6 +1249,43 @@ class FileManager:
|
|||||||
return {"ok": True, "updated": updated, "without_id": without_id,
|
return {"ok": True, "updated": updated, "without_id": without_id,
|
||||||
"errors": errors}
|
"errors": errors}
|
||||||
|
|
||||||
|
def resolve_via_csfd(
|
||||||
|
self, *, csfd_url: str | None = None, query: str | None = None,
|
||||||
|
session: object | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""ČSFD → TMDb bridge for one movie.
|
||||||
|
|
||||||
|
With ``csfd_url`` set, reads that ČSFD film; otherwise searches ČSFD for
|
||||||
|
``query`` (a cleaned filename) and takes the top hit. The ČSFD original
|
||||||
|
title + year then drive a TMDb search — far more reliable than a raw
|
||||||
|
Czech-title search. Pass a shared ``requests.Session`` to reuse the
|
||||||
|
Anubis auth cookie across a batch.
|
||||||
|
|
||||||
|
Returns ``{"csfd_url", "film": CsfdFilm | None, "tmdb": TmdbResult | None}``.
|
||||||
|
Best-effort: a network / anti-bot / no-match failure is a ``None`` in the
|
||||||
|
result, not an exception.
|
||||||
|
"""
|
||||||
|
from . import csfd, tmdb
|
||||||
|
result: dict = {"csfd_url": csfd_url, "film": None, "tmdb": None}
|
||||||
|
try:
|
||||||
|
url = csfd_url
|
||||||
|
if not url and query:
|
||||||
|
url = csfd.find_csfd_url(query, session=session)
|
||||||
|
if not url:
|
||||||
|
return result
|
||||||
|
result["csfd_url"] = url
|
||||||
|
film = csfd.fetch_film(url, session=session)
|
||||||
|
result["film"] = film
|
||||||
|
title, year = film.tmdb_query
|
||||||
|
if title and tmdb.api_available():
|
||||||
|
hits = tmdb.search_movie(title, year=year, limit=1)
|
||||||
|
if not hits and film.czech_title and film.czech_title != title:
|
||||||
|
hits = tmdb.search_movie(film.czech_title, year=year, limit=1)
|
||||||
|
result["tmdb"] = hits[0] if hits else None
|
||||||
|
except (csfd.CsfdError, tmdb.TmdbError, OSError):
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
def match_pool_to_tmdb(
|
def match_pool_to_tmdb(
|
||||||
self, files: list[File] | None = None,
|
self, files: list[File] | None = None,
|
||||||
on_progress: Callable[[int, int, File], None] | None = None,
|
on_progress: Callable[[int, int, File], None] | None = None,
|
||||||
@@ -875,14 +1294,18 @@ class FileManager:
|
|||||||
"""Propose a TMDb id for every movie that doesn't have one yet.
|
"""Propose a TMDb id for every movie that doesn't have one yet.
|
||||||
|
|
||||||
Searches TMDb by each movie's title + year and classifies the result
|
Searches TMDb by each movie's title + year and classifies the result
|
||||||
(see :func:`migrate.classify`). Movies whose filename already carries a
|
(see :func:`migrate.classify`). A movie that carries a **ČSFD link**
|
||||||
|
(``File.csfd_url``) is first resolved through ČSFD so the search runs on
|
||||||
|
the *original* title + year — the pure Czech-title search picks the wrong
|
||||||
|
film for remakes/localized names. Movies whose filename already carries a
|
||||||
``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``.
|
``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``.
|
||||||
"""
|
"""
|
||||||
from . import tmdb
|
from . import csfd, tmdb
|
||||||
from .migrate import MatchProposal, classify
|
from .migrate import MatchProposal, classify
|
||||||
targets = files if files is not None else self.filelist
|
targets = files if files is not None else self.filelist
|
||||||
proposals: list = []
|
proposals: list = []
|
||||||
total = len(targets)
|
total = len(targets)
|
||||||
|
csfd_session = None
|
||||||
for i, f in enumerate(targets, 1):
|
for i, f in enumerate(targets, 1):
|
||||||
if should_cancel is not None and should_cancel():
|
if should_cancel is not None and should_cancel():
|
||||||
break
|
break
|
||||||
@@ -896,14 +1319,28 @@ class FileManager:
|
|||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
title = parsed[0]
|
title = parsed[0]
|
||||||
year = str(ctx.get("year") or "").strip() or None
|
year = str(ctx.get("year") or "").strip() or None
|
||||||
|
|
||||||
|
note = ""
|
||||||
|
if f.csfd_url and csfd.dependencies_available():
|
||||||
|
if csfd_session is None and csfd.requests is not None:
|
||||||
|
csfd_session = csfd.requests.Session()
|
||||||
|
try:
|
||||||
|
film = csfd.fetch_film(f.csfd_url, session=csfd_session)
|
||||||
|
except csfd.CsfdError:
|
||||||
|
film = None
|
||||||
|
if film is not None and (film.original_title or film.czech_title):
|
||||||
|
title = film.original_title or film.czech_title
|
||||||
|
year = film.year or year
|
||||||
|
note = f"přes ČSFD: {title}" + (f" ({film.year})" if film.year else "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
candidates = tmdb.search_movie(title, year=year, limit=5)
|
candidates = tmdb.search_movie(title, year=year, limit=5)
|
||||||
status, chosen = classify(title, year, candidates)
|
status, chosen = classify(title, year, candidates)
|
||||||
proposals.append(MatchProposal(
|
proposals.append(MatchProposal(
|
||||||
f, f.filename, title, year, status, chosen, candidates))
|
f, f.filename, title, year, status, chosen, candidates, note=note))
|
||||||
except tmdb.TmdbError as exc:
|
except tmdb.TmdbError as exc:
|
||||||
proposals.append(MatchProposal(
|
proposals.append(MatchProposal(
|
||||||
f, f.filename, title, year, "error", None, [], str(exc)))
|
f, f.filename, title, year, "error", None, [], str(exc), note))
|
||||||
if on_progress is not None:
|
if on_progress is not None:
|
||||||
on_progress(i, total, f)
|
on_progress(i, total, f)
|
||||||
return proposals
|
return proposals
|
||||||
@@ -973,17 +1410,21 @@ class FileManager:
|
|||||||
self, sources: list[Path], title: str, year: int | str | None = None,
|
self, sources: list[Path], title: str, year: int | str | None = None,
|
||||||
tmdb_id: int | str | None = None, library: str | None = None,
|
tmdb_id: int | str | None = None, library: str | None = None,
|
||||||
move: bool = False, on_conflict: str = "suffix",
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
|
codes: dict[Path, tuple[int, int]] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
|
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
|
||||||
|
|
||||||
Each source's season/episode is parsed from its filename and it is copied
|
Each source's season/episode comes from ``codes`` (an explicit
|
||||||
(or moved) in as ``Series SxxEyy.ext``. Files with no recognisable
|
``{path: (season, episode)}`` override set at import time) when present,
|
||||||
``SxxEyy`` are skipped and reported.
|
otherwise it is parsed from the filename. It is then copied (or moved) in
|
||||||
|
as ``Series SxxEyy.ext``. Files with neither an override nor a
|
||||||
|
recognisable ``SxxEyy`` are skipped and reported.
|
||||||
|
|
||||||
Returns ``{imported, skipped_no_code, errors}``.
|
Returns ``{imported, skipped_no_code, errors}``.
|
||||||
"""
|
"""
|
||||||
from .series import (
|
from .series import (
|
||||||
parse_episode, series_folder_stem, season_folder_name, episode_filename)
|
parse_episode, series_folder_stem, season_folder_name, episode_filename)
|
||||||
|
codes = codes or {}
|
||||||
pool = self.pool_dir
|
pool = self.pool_dir
|
||||||
if pool is None:
|
if pool is None:
|
||||||
raise RuntimeError("Pool není nastaven.")
|
raise RuntimeError("Pool není nastaven.")
|
||||||
@@ -996,21 +1437,21 @@ class FileManager:
|
|||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for src in sources:
|
for src in sources:
|
||||||
src = Path(src)
|
src = Path(src)
|
||||||
code = parse_episode(src.name)
|
code = codes.get(src) or parse_episode(src.name)
|
||||||
if code is None:
|
if code is None:
|
||||||
skipped_no_code.append(src.name)
|
skipped_no_code.append(src.name)
|
||||||
continue
|
continue
|
||||||
season, ep = code
|
season, ep = code
|
||||||
dest_dir = pool / lib / stem / season_folder_name(season)
|
dest_dir = pool / lib / stem / season_folder_name(season)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
target = dest_dir / episode_filename(season, ep, src.stem, src.suffix)
|
target = dest_dir / episode_filename(season, ep, title, src.suffix)
|
||||||
if target.exists():
|
if target.exists():
|
||||||
if on_conflict == "skip":
|
if on_conflict == "skip":
|
||||||
continue
|
continue
|
||||||
if on_conflict == "replace":
|
if on_conflict == "replace":
|
||||||
target.unlink()
|
target.unlink()
|
||||||
else: # suffix
|
else: # suffix
|
||||||
base = episode_filename(season, ep, src.stem, "")
|
base = episode_filename(season, ep, title, "")
|
||||||
counter = 1
|
counter = 1
|
||||||
while target.exists():
|
while target.exists():
|
||||||
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
||||||
@@ -1027,7 +1468,7 @@ class FileManager:
|
|||||||
"errors": errors}
|
"errors": errors}
|
||||||
|
|
||||||
def rename_episodes_to_canonical(self, series) -> dict:
|
def rename_episodes_to_canonical(self, series) -> dict:
|
||||||
"""Rename a series' episodes to ``Series SxxEyy.ext`` in the right season.
|
"""Rename a series' episodes to ``SxxEyy - {series title}.ext`` in its season.
|
||||||
|
|
||||||
``series`` is a ``series.Series``. Episodes with no recognisable code are
|
``series`` is a ``series.Series``. Episodes with no recognisable code are
|
||||||
skipped; a target name already taken is reported as a conflict.
|
skipped; a target name already taken is reported as a conflict.
|
||||||
@@ -1044,7 +1485,7 @@ class FileManager:
|
|||||||
continue
|
continue
|
||||||
dest_dir = series.folder / season_folder_name(ep.season)
|
dest_dir = series.folder / season_folder_name(ep.season)
|
||||||
new_path = dest_dir / episode_filename(
|
new_path = dest_dir / episode_filename(
|
||||||
ep.season, ep.episode, ep.path.stem, ep.path.suffix)
|
ep.season, ep.episode, series.title, ep.path.suffix)
|
||||||
if new_path == ep.path:
|
if new_path == ep.path:
|
||||||
continue
|
continue
|
||||||
if new_path.exists():
|
if new_path.exists():
|
||||||
@@ -1052,14 +1493,64 @@ class FileManager:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
old_name = ep.path.name
|
old_path = ep.path
|
||||||
ep.path.rename(new_path)
|
old_path.rename(new_path)
|
||||||
|
subtitles.rename_subtitles_with(old_path, new_path)
|
||||||
ep.path = new_path
|
ep.path = new_path
|
||||||
renamed.append((old_name, new_path.name))
|
renamed.append((old_path.name, new_path.name))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
conflicts.append((ep.path.name, str(exc)))
|
conflicts.append((ep.path.name, str(exc)))
|
||||||
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
||||||
|
|
||||||
|
def rename_all_series_to_canonical(
|
||||||
|
self,
|
||||||
|
on_progress: Callable[[int, int, str], None] | None = None,
|
||||||
|
should_cancel: Callable[[], bool] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Canonicalise every series' episode filenames across the pool.
|
||||||
|
|
||||||
|
Iterates all series (``series`` + ``mixed`` libraries); per series it
|
||||||
|
renames each episode to ``SxxEyy - {series title}.ext`` in the right
|
||||||
|
``Season NN`` / ``Specials`` folder (:meth:`rename_episodes_to_canonical`)
|
||||||
|
and then removes any season folder left empty by the moves. Episodes with
|
||||||
|
no recognisable ``SxxEyy`` are left in place and listed.
|
||||||
|
|
||||||
|
Returns ``{series, renamed:[(series, old, new)],
|
||||||
|
skipped_no_code:[(series, name)], conflicts:[(series, name, msg)],
|
||||||
|
empty_dirs_removed:[str], cancelled}``.
|
||||||
|
"""
|
||||||
|
from .series import is_season_folder
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
all_series = self.load_series()
|
||||||
|
total = len(all_series)
|
||||||
|
renamed: list[tuple[str, str, str]] = []
|
||||||
|
skipped: list[tuple[str, str]] = []
|
||||||
|
conflicts: list[tuple[str, str, str]] = []
|
||||||
|
empty_removed: list[str] = []
|
||||||
|
cancelled = False
|
||||||
|
for i, ser in enumerate(all_series, 1):
|
||||||
|
if should_cancel is not None and should_cancel():
|
||||||
|
cancelled = True
|
||||||
|
break
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(i, total, ser.title)
|
||||||
|
res = self.rename_episodes_to_canonical(ser)
|
||||||
|
renamed += [(ser.title, old, new) for old, new in res["renamed"]]
|
||||||
|
skipped += [(ser.title, name) for name in res["skipped"]]
|
||||||
|
conflicts += [(ser.title, name, msg) for name, msg in res["conflicts"]]
|
||||||
|
for sub in sorted(p for p in ser.folder.iterdir() if p.is_dir()):
|
||||||
|
if is_season_folder(sub.name) and not any(sub.iterdir()):
|
||||||
|
try:
|
||||||
|
sub.rmdir()
|
||||||
|
empty_removed.append(str(sub.relative_to(pool)))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"series": total, "renamed": renamed, "skipped_no_code": skipped,
|
||||||
|
"conflicts": conflicts, "empty_dirs_removed": empty_removed,
|
||||||
|
"cancelled": cancelled}
|
||||||
|
|
||||||
def apply_series_edits(
|
def apply_series_edits(
|
||||||
self, series, *, title: str, year: int | str | None = None,
|
self, series, *, title: str, year: int | str | None = None,
|
||||||
tmdb_id: int | str | None = None, library: str | None = None,
|
tmdb_id: int | str | None = None, library: str | None = None,
|
||||||
@@ -1096,6 +1587,27 @@ class FileManager:
|
|||||||
else {"renamed": [], "skipped": [], "conflicts": []})
|
else {"renamed": [], "skipped": [], "conflicts": []})
|
||||||
return {"folder": new_folder, "episodes": result}
|
return {"folder": new_folder, "episodes": result}
|
||||||
|
|
||||||
|
def remove_series(self, series) -> None:
|
||||||
|
"""Delete a whole series folder from the pool (every season and episode).
|
||||||
|
|
||||||
|
Series carry no index metadata (they are scanned from disk), so removing
|
||||||
|
one is just deleting its folder. Refuses a folder that is not inside the
|
||||||
|
pool as a safety check.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: the series folder is outside the pool.
|
||||||
|
"""
|
||||||
|
pool = self.pool_dir
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Pool není nastaven.")
|
||||||
|
folder = Path(series.folder)
|
||||||
|
try:
|
||||||
|
folder.resolve().relative_to(pool.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Složka „{folder}“ není v poolu.")
|
||||||
|
if folder.is_dir():
|
||||||
|
shutil.rmtree(folder)
|
||||||
|
|
||||||
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||||
for f in files_objs:
|
for f in files_objs:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Example:
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import shutil
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple, Optional, Dict, Set
|
from typing import List, Tuple, Optional, Dict, Set
|
||||||
@@ -341,6 +342,26 @@ class HardlinkManager:
|
|||||||
|
|
||||||
return success_count, fail_count
|
return success_count, fail_count
|
||||||
|
|
||||||
|
def remove_mirror(self, name: str) -> bool:
|
||||||
|
"""Remove ``output_dir/name`` entirely (file or folder tree).
|
||||||
|
|
||||||
|
Used to drop a whole mirrored library (or one entry of the shared
|
||||||
|
mixed-library-series bucket) when it falls out of the miniDLNA
|
||||||
|
generation's scope. Mirrors are pure hardlinks, so deleting the folder
|
||||||
|
loses nothing — it's fully regenerable from the pool. ``name`` may
|
||||||
|
contain "/" to address one entry inside a bucket folder.
|
||||||
|
|
||||||
|
Returns whether anything was actually removed.
|
||||||
|
"""
|
||||||
|
target = self.output_dir / name
|
||||||
|
if not target.exists():
|
||||||
|
return False
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
else:
|
||||||
|
target.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
def _fill_special_folder(
|
def _fill_special_folder(
|
||||||
self, chosen: List[File], subfolder: str, dry_run: bool
|
self, chosen: List[File], subfolder: str, dry_run: bool
|
||||||
) -> Tuple[int, int]:
|
) -> Tuple[int, int]:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -27,6 +27,7 @@ class MatchProposal:
|
|||||||
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
||||||
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
|
||||||
|
|
||||||
|
|
||||||
def normalize(text: str) -> str:
|
def normalize(text: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""The movie object — metadata first, files second.
|
||||||
|
|
||||||
|
A movie is identified by ``(title, year, tmdb_id)`` and owns **its files**, not
|
||||||
|
the other way round. That inversion is the point: the pool index used to key a
|
||||||
|
record by a file path, so metadata was a passenger on one particular file — and
|
||||||
|
everything about multi-version movies got awkward (which file "holds" the tags,
|
||||||
|
why renaming a grouped movie had to be blocked, why an import could silently
|
||||||
|
create a duplicate instead of a version).
|
||||||
|
|
||||||
|
Here the record is the movie; each playable file is a :class:`MediaFile` under
|
||||||
|
it, told apart by a free-text ``label`` (``3D HSBS``, ``4K``, ``Director's
|
||||||
|
Cut``, …). Where those files *live* is then a pure function of the record —
|
||||||
|
:meth:`Movie.desired_layout` — so promoting to a folder, demoting back, or
|
||||||
|
renaming the whole thing are all the same operation: recompute and move what
|
||||||
|
doesn't match.
|
||||||
|
|
||||||
|
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
|
||||||
|
|
||||||
|
one video <Library>/Title (rok) [tmdbid-N].ext
|
||||||
|
two or more <Library>/Title (rok) [tmdbid-N]/
|
||||||
|
Title (rok) [tmdbid-N] - Výchozí.ext
|
||||||
|
Title (rok) [tmdbid-N].3D.HSBS.ext
|
||||||
|
|
||||||
|
Every file in a movie folder must start with the folder's exact name; both
|
||||||
|
``" - label"`` and ``".3D.FMT"`` are valid separators, so a 3D file keeps the
|
||||||
|
documented ``.3D.<FORMAT>`` spelling whether it sits flat or grouped.
|
||||||
|
Subtitles are not media entries — they ride along with their video by filename
|
||||||
|
stem (see :mod:`src.core.subtitles`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
|
||||||
|
|
||||||
|
# Roles a media file can have. Only playable video for now — artwork stays
|
||||||
|
# Jellyfin's job (it writes poster/fanart into the folder itself).
|
||||||
|
ROLE_VIDEO = "video"
|
||||||
|
|
||||||
|
# A grouped movie needs a label on every file (all of Jellyfin's documented
|
||||||
|
# examples label every version); this is what an unlabelled one gets.
|
||||||
|
DEFAULT_VERSION_LABEL = "Výchozí"
|
||||||
|
|
||||||
|
# "HSBS" / "hsbs" / "3D HSBS" / "3d.hsbs" / "3D_HSBS" → "HSBS"
|
||||||
|
_LABEL_3D_RE = re.compile(
|
||||||
|
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_3d_label(label: object) -> Optional[str]:
|
||||||
|
"""The 3D format a version label denotes (upper-case), or ``None``.
|
||||||
|
|
||||||
|
Lets a plain label double as the 3D marker — ``"3D HSBS"`` typed in the
|
||||||
|
version dialog and ``"hsbs"`` picked from a dropdown mean the same thing.
|
||||||
|
"""
|
||||||
|
match = _LABEL_3D_RE.match(str(label or "").strip())
|
||||||
|
return match.group(1).upper() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str:
|
||||||
|
"""Filename for one media file of a movie whose canonical stem is ``stem``.
|
||||||
|
|
||||||
|
- no label → ``stem.ext`` (a lone, plain video)
|
||||||
|
- 3D label → ``stem.3D.<FORMAT>.ext`` (Jellyfin's documented 3D form)
|
||||||
|
- anything else → ``stem - label.ext``
|
||||||
|
|
||||||
|
``grouped`` only decides whether an empty label is allowed: inside a movie
|
||||||
|
folder every file gets one, so it falls back to :data:`DEFAULT_VERSION_LABEL`.
|
||||||
|
"""
|
||||||
|
label = (label or "").strip()
|
||||||
|
if grouped and not label:
|
||||||
|
label = DEFAULT_VERSION_LABEL
|
||||||
|
if not label:
|
||||||
|
return f"{stem}{ext}"
|
||||||
|
fmt = normalize_3d_label(label)
|
||||||
|
if fmt:
|
||||||
|
return f"{stem}.3D.{fmt}{ext}"
|
||||||
|
return f"{stem} - {label}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def identity_key(tmdb_id: object = None, title: str = "", year: object = None) -> str:
|
||||||
|
"""The pool-index key for a movie — ``tmdb-<id>``, else a title/year slug.
|
||||||
|
|
||||||
|
Keying by identity (rather than by file path) is what lets a second file of
|
||||||
|
the same film find its movie instead of becoming a separate entry.
|
||||||
|
"""
|
||||||
|
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
|
||||||
|
if id_s.isdigit():
|
||||||
|
return f"tmdb-{int(id_s)}"
|
||||||
|
raw = f"{(title or '').strip()} {str(year or '').strip()}".strip().lower()
|
||||||
|
# Fold diacritics so "Pelíšky" and "Pelisky" can't become two movies.
|
||||||
|
raw = "".join(c for c in unicodedata.normalize("NFD", raw)
|
||||||
|
if not unicodedata.combining(c))
|
||||||
|
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
|
||||||
|
return f"name-{slug or 'bez-nazvu'}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaFile:
|
||||||
|
"""One file belonging to a movie (today always a playable video)."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
label: str = ""
|
||||||
|
role: str = ROLE_VIDEO
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_3d(self) -> Optional[str]:
|
||||||
|
"""The 3D format this version's label denotes, if any."""
|
||||||
|
return normalize_3d_label(self.label)
|
||||||
|
|
||||||
|
def to_record(self, pool_dir: Path) -> dict:
|
||||||
|
"""Serialise for the pool index (path stored pool-relative, POSIX)."""
|
||||||
|
try:
|
||||||
|
rel = Path(self.path).relative_to(pool_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
rel = Path(self.path).as_posix()
|
||||||
|
return {"role": self.role, "label": self.label, "file": rel}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, data: dict, pool_dir: Path) -> "MediaFile":
|
||||||
|
return cls(
|
||||||
|
path=pool_dir / str(data.get("file", "")),
|
||||||
|
label=str(data.get("label", "") or ""),
|
||||||
|
role=str(data.get("role") or ROLE_VIDEO),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Movie:
|
||||||
|
"""A film in the pool: its identity, its metadata, and its media files."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
title: str = ""
|
||||||
|
year: Optional[str] = None
|
||||||
|
tmdb_id: Optional[int] = None
|
||||||
|
library: str = ""
|
||||||
|
media: list[MediaFile] = field(default_factory=list)
|
||||||
|
|
||||||
|
# --- metadata that used to live on the "primary" File ---
|
||||||
|
tags: list = field(default_factory=list)
|
||||||
|
auto_tag_paths: set[str] = field(default_factory=set)
|
||||||
|
csfd_url: Optional[str] = None
|
||||||
|
date: Optional[str] = None
|
||||||
|
added: Optional[str] = None
|
||||||
|
attributes: dict[str, str] = field(default_factory=dict)
|
||||||
|
meta: Optional[dict] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Identity / shape
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def videos(self) -> list[MediaFile]:
|
||||||
|
"""Playable versions, in a stable order (labelled ones by label)."""
|
||||||
|
return sorted(
|
||||||
|
(m for m in self.media if m.role == ROLE_VIDEO),
|
||||||
|
key=lambda m: m.label.casefold())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_grouped(self) -> bool:
|
||||||
|
"""True when the movie needs its own folder (more than one version)."""
|
||||||
|
return len(self.videos) > 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stem(self) -> str:
|
||||||
|
"""Canonical ``Title (rok) [tmdbid-N]`` — the folder / filename base.
|
||||||
|
|
||||||
|
Falls back to the bare title when no four-digit year is known (a movie
|
||||||
|
can be imported without one).
|
||||||
|
"""
|
||||||
|
title = (self.title or "").replace("/", "-").replace("\\", "-").strip()
|
||||||
|
if not title:
|
||||||
|
title = self.key
|
||||||
|
if is_valid_year(self.year or ""):
|
||||||
|
return canonical_pool_stem(title, self.year, self.tmdb_id)
|
||||||
|
return title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary(self) -> Optional[MediaFile]:
|
||||||
|
"""The version that represents the movie (first by :attr:`videos`)."""
|
||||||
|
videos = self.videos
|
||||||
|
return videos[0] if videos else None
|
||||||
|
|
||||||
|
def desired_layout(self, pool_dir: Path) -> list[tuple[MediaFile, Path]]:
|
||||||
|
"""``(media, where it belongs)`` for every file, from the record alone.
|
||||||
|
|
||||||
|
The whole flat-vs-folder rule lives here: one video sits directly in
|
||||||
|
the library, two or more move into ``<Library>/<stem>/``. Callers move
|
||||||
|
whatever doesn't already match — which makes promotion, demotion and
|
||||||
|
renaming the same operation.
|
||||||
|
"""
|
||||||
|
base = Path(pool_dir) / self.library
|
||||||
|
grouped = self.is_grouped
|
||||||
|
folder = base / self.stem if grouped else base
|
||||||
|
return [
|
||||||
|
(m, folder / media_filename(
|
||||||
|
self.stem, m.label, Path(m.path).suffix, grouped=grouped))
|
||||||
|
for m in self.videos
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Index serialisation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def to_record(self, pool_dir: Path) -> dict:
|
||||||
|
data = {
|
||||||
|
"title": self.title,
|
||||||
|
"year": self.year,
|
||||||
|
"tmdb_id": self.tmdb_id,
|
||||||
|
"library": self.library,
|
||||||
|
"media": [m.to_record(pool_dir) for m in self.media],
|
||||||
|
"tags": [getattr(t, "full_path", t) for t in self.tags],
|
||||||
|
"auto_tags": sorted(self.auto_tag_paths),
|
||||||
|
"csfd_url": self.csfd_url,
|
||||||
|
"date": self.date,
|
||||||
|
"added": self.added,
|
||||||
|
"attributes": dict(self.attributes),
|
||||||
|
}
|
||||||
|
if self.meta is not None:
|
||||||
|
data["meta"] = dict(self.meta)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(
|
||||||
|
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
|
||||||
|
) -> "Movie":
|
||||||
|
movie = cls(
|
||||||
|
key=key,
|
||||||
|
title=str(data.get("title") or ""),
|
||||||
|
year=(str(data["year"]) if data.get("year") else None),
|
||||||
|
tmdb_id=(int(data["tmdb_id"]) if str(data.get("tmdb_id") or "").isdigit()
|
||||||
|
else None),
|
||||||
|
library=str(data.get("library") or ""),
|
||||||
|
media=[MediaFile.from_record(m, pool_dir)
|
||||||
|
for m in (data.get("media") or [])],
|
||||||
|
auto_tag_paths=set(data.get("auto_tags") or []),
|
||||||
|
csfd_url=data.get("csfd_url") or None,
|
||||||
|
date=data.get("date") or None,
|
||||||
|
added=data.get("added") or None,
|
||||||
|
attributes=dict(data.get("attributes") or {}),
|
||||||
|
meta=(dict(data["meta"]) if data.get("meta") else None),
|
||||||
|
)
|
||||||
|
if tagmanager is not None:
|
||||||
|
for full_path in data.get("tags") or []:
|
||||||
|
if "/" in str(full_path):
|
||||||
|
category, name = str(full_path).split("/", 1)
|
||||||
|
movie.tags.append(tagmanager.add_tag(category, name))
|
||||||
|
return movie
|
||||||
+46
-10
@@ -4,16 +4,32 @@ Every movie in the pool is named ``Title (YYYY) [tmdbid-N].ext`` — a title, a
|
|||||||
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||||
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
|
is exactly the form Jellyfin matches on. The ``[tmdbid-N]`` part is optional: a
|
||||||
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
|
plain ``Title (YYYY)`` is still valid (Jellyfin then matches by name). The year
|
||||||
lets two same-named films coexist and makes the filename self-describing. These
|
lets two same-named films coexist and makes the filename self-describing.
|
||||||
helpers build, validate and parse the stem (the filename without its extension).
|
|
||||||
|
A 3D movie additionally carries a ``.3D.<FORMAT>`` tag right before the
|
||||||
|
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
|
||||||
|
``Title (YYYY) [tmdbid-N].3D.HSBS.ext``. ``FORMAT`` is one of
|
||||||
|
:data:`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
|
||||||
|
writes it upper-case for a single canonical form.
|
||||||
|
|
||||||
|
These helpers build, validate and parse the stem (the filename without its
|
||||||
|
extension).
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
|
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
|
||||||
# title is non-greedy so a title that itself contains parentheses keeps them:
|
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
|
||||||
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
|
# half/full top-and-bottom, mvc = Multiview Video Coding.
|
||||||
|
THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
|
||||||
|
|
||||||
|
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and
|
||||||
|
# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that
|
||||||
|
# itself contains parentheses keeps them: "Kurz (special) (2019)" → title
|
||||||
|
# "Kurz (special)", year 2019.
|
||||||
_POOL_STEM_RE = re.compile(
|
_POOL_STEM_RE = re.compile(
|
||||||
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$")
|
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
|
||||||
|
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def is_valid_year(year: object) -> bool:
|
def is_valid_year(year: object) -> bool:
|
||||||
@@ -21,12 +37,16 @@ def is_valid_year(year: object) -> bool:
|
|||||||
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
|
||||||
|
|
||||||
|
|
||||||
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
|
def canonical_pool_stem(
|
||||||
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
|
title: str, year: object, tmdb_id: object = None, format_3d: object = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` /
|
||||||
|
``.3D.<FORMAT>`` if given).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: the title is empty, the year is not four digits, or a given
|
ValueError: the title is empty, the year is not four digits, a given
|
||||||
``tmdb_id`` is not a positive integer.
|
``tmdb_id`` is not a positive integer, or ``format_3d`` isn't one
|
||||||
|
of :data:`THREE_D_FORMATS`.
|
||||||
"""
|
"""
|
||||||
title = (title or "").strip()
|
title = (title or "").strip()
|
||||||
year_s = str(year).strip()
|
year_s = str(year).strip()
|
||||||
@@ -40,6 +60,13 @@ def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str
|
|||||||
if not id_s.isdigit():
|
if not id_s.isdigit():
|
||||||
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
|
||||||
stem += f" [tmdbid-{id_s}]"
|
stem += f" [tmdbid-{id_s}]"
|
||||||
|
if format_3d is not None and str(format_3d).strip() != "":
|
||||||
|
fmt = str(format_3d).strip().upper()
|
||||||
|
if fmt not in THREE_D_FORMATS:
|
||||||
|
raise ValueError(
|
||||||
|
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
|
||||||
|
f"ne „{format_3d}“.")
|
||||||
|
stem += f".3D.{fmt}"
|
||||||
return stem
|
return stem
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +90,15 @@ def parse_tmdb_id(stem: str) -> int | None:
|
|||||||
return int(match.group("id"))
|
return int(match.group("id"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_3d_format(stem: str) -> str | None:
|
||||||
|
"""Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or
|
||||||
|
``None`` if absent."""
|
||||||
|
match = _POOL_STEM_RE.match((stem or "").strip())
|
||||||
|
if not match or not match.group("format3d"):
|
||||||
|
return None
|
||||||
|
return match.group("format3d").upper()
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_pool_stem(stem: str) -> bool:
|
def is_canonical_pool_stem(stem: str) -> bool:
|
||||||
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
|
||||||
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
return _POOL_STEM_RE.match((stem or "").strip()) is not None
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class NfoData:
|
|||||||
countries: list[str] = field(default_factory=list)
|
countries: list[str] = field(default_factory=list)
|
||||||
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
|
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
|
||||||
tmdb_id: Optional[int] = None
|
tmdb_id: Optional[int] = None
|
||||||
|
csfd_url: Optional[str] = None # ČSFD film URL, if the NFO carries a csfd id
|
||||||
|
|
||||||
def field_values(self, name: str) -> list[str]:
|
def field_values(self, name: str) -> list[str]:
|
||||||
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
||||||
@@ -78,6 +79,29 @@ def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
|
|||||||
return int(raw.strip()) if raw and raw.strip().isdigit() else None
|
return int(raw.strip()) if raw and raw.strip().isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csfd_url(root: ET.Element) -> Optional[str]:
|
||||||
|
"""A ČSFD film URL from the NFO, if any.
|
||||||
|
|
||||||
|
Czech Kodi scrapers record ČSFD as ``<uniqueid type="csfd">`` (a bare id or a
|
||||||
|
full URL) or a legacy ``<csfdid>`` / ``<csfd>`` element. A bare id is expanded
|
||||||
|
to ``https://www.csfd.cz/film/<id>/``.
|
||||||
|
"""
|
||||||
|
candidates: list[str] = []
|
||||||
|
for uid in root.findall("uniqueid"):
|
||||||
|
if uid.get("type") == "csfd" and uid.text and uid.text.strip():
|
||||||
|
candidates.append(uid.text.strip())
|
||||||
|
for tag in ("csfdid", "csfd"):
|
||||||
|
raw = root.findtext(tag)
|
||||||
|
if raw and raw.strip():
|
||||||
|
candidates.append(raw.strip())
|
||||||
|
for value in candidates:
|
||||||
|
if value.isdigit():
|
||||||
|
return f"https://www.csfd.cz/film/{value}/"
|
||||||
|
if "csfd.cz" in value:
|
||||||
|
return value if value.startswith("http") else f"https://{value.lstrip('/')}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def read_nfo(video_path: Path) -> Optional[NfoData]:
|
def read_nfo(video_path: Path) -> Optional[NfoData]:
|
||||||
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
||||||
path = find_nfo(video_path)
|
path = find_nfo(video_path)
|
||||||
@@ -98,4 +122,5 @@ def read_nfo(video_path: Path) -> Optional[NfoData]:
|
|||||||
countries=_texts(root, "country"),
|
countries=_texts(root, "country"),
|
||||||
rating=_parse_rating(root),
|
rating=_parse_rating(root),
|
||||||
tmdb_id=_parse_tmdb_id(root),
|
tmdb_id=_parse_tmdb_id(root),
|
||||||
|
csfd_url=_parse_csfd_url(root),
|
||||||
)
|
)
|
||||||
|
|||||||
+130
-15
@@ -10,17 +10,26 @@ The index also carries **library-level settings** (``tag_schema``,
|
|||||||
particular pool travels with the pool. The global config keeps only app data
|
particular pool travels with the pool. The global config keeps only app data
|
||||||
(window state, MRU) and the pointers to where the pool/output live.
|
(window state, MRU) and the pointers to where the pool/output live.
|
||||||
|
|
||||||
On-disk shape::
|
On-disk shape (**v2**)::
|
||||||
|
|
||||||
{"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}}
|
{"version": 2,
|
||||||
|
"settings": {...},
|
||||||
|
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
|
||||||
|
|
||||||
Records are keyed by the file's path relative to the pool root (POSIX form),
|
Records are keyed by the movie's **identity** (``tmdb-<id>``, or a title/year
|
||||||
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
|
slug when there's no id) and own their files — see :mod:`src.core.movie`. The
|
||||||
|
original **v1** shape keyed a record by one file's pool-relative path, which
|
||||||
|
made metadata a passenger on that file; :meth:`PoolIndex.load` migrates such an
|
||||||
|
index in place (backing the old file up first). Migration only rewrites
|
||||||
|
metadata — no file on disk is moved, because a one-video movie is already in
|
||||||
|
its v2 position.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
INDEX_FILENAME = ".Curator.!index"
|
INDEX_FILENAME = ".Curator.!index"
|
||||||
|
INDEX_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
class PoolIndex:
|
class PoolIndex:
|
||||||
@@ -42,7 +51,11 @@ class PoolIndex:
|
|||||||
return path.as_posix()
|
return path.as_posix()
|
||||||
|
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
"""Load the index from disk (missing/corrupt index = empty)."""
|
"""Load the index from disk (missing/corrupt index = empty).
|
||||||
|
|
||||||
|
A v1 (path-keyed) index is migrated to v2 in place — the old file is
|
||||||
|
copied to ``.Curator.!index.v1.bak`` first.
|
||||||
|
"""
|
||||||
if not self.index_path.exists():
|
if not self.index_path.exists():
|
||||||
self.records = {}
|
self.records = {}
|
||||||
self.settings = {}
|
self.settings = {}
|
||||||
@@ -55,13 +68,82 @@ class PoolIndex:
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
self.records = {}
|
self.records = {}
|
||||||
self.settings = {}
|
self.settings = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
if int(data.get("version", 1)) < INDEX_VERSION:
|
||||||
|
self._migrate_v1_to_v2()
|
||||||
|
|
||||||
|
def _migrate_v1_to_v2(self) -> None:
|
||||||
|
"""Re-key path-keyed records into identity-keyed movie records.
|
||||||
|
|
||||||
|
Each v1 record described one file; several of them can describe one
|
||||||
|
movie (the interim multi-version layout put them in a shared folder),
|
||||||
|
so they are merged by identity. Metadata only — nothing is moved on
|
||||||
|
disk, since a v1 movie's files already sit where v2 wants them.
|
||||||
|
"""
|
||||||
|
from .movie import ROLE_VIDEO, identity_key
|
||||||
|
from .naming import is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id
|
||||||
|
|
||||||
|
migrated: dict[str, dict] = {}
|
||||||
|
for rel_path, record in self.records.items():
|
||||||
|
if not isinstance(record, dict) or "media" in record:
|
||||||
|
continue # already v2-shaped, leave it alone
|
||||||
|
path = Path(rel_path)
|
||||||
|
parts = path.parts
|
||||||
|
library = parts[0] if len(parts) > 1 else ""
|
||||||
|
# A file inside its own "Title (rok) [tmdbid]" folder is one
|
||||||
|
# version of that movie; its own stem carries the " - label".
|
||||||
|
parent = path.parent.name
|
||||||
|
grouped = len(parts) > 2 and is_canonical_pool_stem(parent)
|
||||||
|
identity_stem = parent if grouped else path.stem
|
||||||
|
|
||||||
|
parsed = parse_pool_stem(identity_stem)
|
||||||
|
title = record.get("title") or (parsed[0] if parsed else path.stem)
|
||||||
|
year = str(parsed[1]) if parsed else None
|
||||||
|
tmdb_id = parse_tmdb_id(identity_stem)
|
||||||
|
label = ""
|
||||||
|
if grouped and path.stem.startswith(f"{parent} - "):
|
||||||
|
label = path.stem[len(parent) + 3:]
|
||||||
|
elif grouped:
|
||||||
|
label = path.stem[len(parent):].lstrip(". -") or ""
|
||||||
|
|
||||||
|
key = identity_key(tmdb_id, title, year)
|
||||||
|
entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path}
|
||||||
|
if key in migrated:
|
||||||
|
migrated[key]["media"].append(entry)
|
||||||
|
continue
|
||||||
|
migrated[key] = {
|
||||||
|
"title": title,
|
||||||
|
"year": year,
|
||||||
|
"tmdb_id": tmdb_id,
|
||||||
|
"library": library,
|
||||||
|
"media": [entry],
|
||||||
|
"tags": record.get("tags", []),
|
||||||
|
"auto_tags": record.get("auto_tags", record.get("csfd_tags", [])),
|
||||||
|
"csfd_url": record.get("csfd_url") or record.get("csfd_link"),
|
||||||
|
"date": record.get("date"),
|
||||||
|
"added": record.get("added"),
|
||||||
|
"attributes": record.get("attributes", {}),
|
||||||
|
}
|
||||||
|
if record.get("meta"):
|
||||||
|
migrated[key]["meta"] = record["meta"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(self.index_path,
|
||||||
|
self.index_path.with_name(self.index_path.name + ".v1.bak"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.records = migrated
|
||||||
|
self.save()
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
"""Persist the index to disk."""
|
"""Persist the index to disk."""
|
||||||
self.pool_dir.mkdir(parents=True, exist_ok=True)
|
self.pool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
with open(self.index_path, "w", encoding="utf-8") as f:
|
with open(self.index_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(
|
json.dump(
|
||||||
{"settings": self.settings, "movies": self.records},
|
{"version": INDEX_VERSION,
|
||||||
|
"settings": self.settings,
|
||||||
|
"movies": self.records},
|
||||||
f, indent=2, ensure_ascii=False,
|
f, indent=2, ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,16 +156,49 @@ class PoolIndex:
|
|||||||
self.settings[key] = value
|
self.settings[key] = value
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def get(self, file_path: Path) -> dict | None:
|
# ------------------------------------------------------------------
|
||||||
"""Return the record for a file, or None if it is not indexed."""
|
# Movie records (v2 — keyed by identity, own their media files)
|
||||||
return self.records.get(self._key(file_path))
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def set(self, file_path: Path, data: dict) -> None:
|
def get_movie(self, key: str) -> dict | None:
|
||||||
"""Upsert a record and persist the index."""
|
"""Return the movie record under ``key``, or None."""
|
||||||
self.records[self._key(file_path)] = data
|
return self.records.get(key)
|
||||||
|
|
||||||
|
def set_movie(self, key: str, data: dict) -> None:
|
||||||
|
"""Upsert a movie record and persist the index."""
|
||||||
|
self.records[key] = data
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def delete(self, file_path: Path) -> None:
|
def delete_movie(self, key: str) -> None:
|
||||||
"""Remove a record (if present) and persist the index."""
|
"""Remove a movie record (if present) and persist the index."""
|
||||||
if self.records.pop(self._key(file_path), None) is not None:
|
if self.records.pop(key, None) is not None:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
def rekey_movie(self, old_key: str, new_key: str) -> None:
|
||||||
|
"""Move a record to a new identity key (e.g. its TMDb id was set/fixed)."""
|
||||||
|
if old_key == new_key or old_key not in self.records:
|
||||||
|
return
|
||||||
|
self.records[new_key] = self.records.pop(old_key)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def all_movies(self) -> dict[str, dict]:
|
||||||
|
"""Every movie record, keyed by identity."""
|
||||||
|
return dict(self.records)
|
||||||
|
|
||||||
|
def record_for(self, file_path: Path) -> dict | None:
|
||||||
|
"""The movie record that owns ``file_path``, or None if untracked."""
|
||||||
|
found = self.find_movie_by_media(file_path)
|
||||||
|
return found[1] if found is not None else None
|
||||||
|
|
||||||
|
def find_movie_by_media(self, file_path: Path) -> tuple[str, dict] | None:
|
||||||
|
"""``(key, record)`` of the movie owning ``file_path``, or None.
|
||||||
|
|
||||||
|
The reverse lookup a file needs now that records are keyed by identity
|
||||||
|
rather than by path.
|
||||||
|
"""
|
||||||
|
rel = self._key(file_path)
|
||||||
|
for key, record in self.records.items():
|
||||||
|
for media in record.get("media", []):
|
||||||
|
if media.get("file") == rel:
|
||||||
|
return key, record
|
||||||
|
return None
|
||||||
|
|||||||
+39
-18
@@ -71,31 +71,52 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]:
|
|||||||
return int(match.group(3)), int(match.group(4))
|
return int(match.group(3)), int(match.group(4))
|
||||||
|
|
||||||
|
|
||||||
|
def collect_episode_files(folder: Path) -> list[Path]:
|
||||||
|
"""Every video file under ``folder`` (recursively), sidecars excluded, sorted.
|
||||||
|
|
||||||
|
Lets the episode-import dialog take a whole ``Series/Season NN/`` tree (or any
|
||||||
|
nested layout) in one pick instead of file-by-file. Season/episode numbers
|
||||||
|
are still read per file from its name by :func:`parse_episode`.
|
||||||
|
"""
|
||||||
|
folder = Path(folder)
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
f for f in folder.rglob("*")
|
||||||
|
if f.is_file() and f.suffix.lower() not in _SIDECARS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def season_folder_name(season: int) -> str:
|
def season_folder_name(season: int) -> str:
|
||||||
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
|
||||||
return "Specials" if season == 0 else f"Season {season:02d}"
|
return "Specials" if season == 0 else f"Season {season:02d}"
|
||||||
|
|
||||||
|
|
||||||
def episode_body(stem: str) -> str:
|
def is_season_folder(name: str) -> bool:
|
||||||
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
|
"""True for a season folder name — ``Specials`` or ``Season <n>`` (any padding)."""
|
||||||
|
return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None
|
||||||
Keeps the original (release) filename so it survives into the canonical name,
|
|
||||||
while stripping an already-applied ``SxxEyy - `` prefix so repeated renames
|
|
||||||
don't double it. Returns ``""`` when the stem was only the code.
|
|
||||||
"""
|
|
||||||
s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token
|
|
||||||
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
|
|
||||||
return s.strip(" ._-")
|
|
||||||
|
|
||||||
|
|
||||||
def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str:
|
# Characters not allowed in a filename on the common filesystems.
|
||||||
"""Canonical episode filename ``SxxEyy - {original name}.ext``.
|
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
Keeps the original filename after the code; when the original was only the
|
|
||||||
code (nothing descriptive left) it collapses to ``SxxEyy.ext``.
|
def sanitize_title(title: str) -> str:
|
||||||
|
"""A series/episode title made safe to sit in a filename (spaces collapsed)."""
|
||||||
|
cleaned = _FS_UNSAFE.sub("", title or "")
|
||||||
|
return re.sub(r"\s+", " ", cleaned).strip(" .")
|
||||||
|
|
||||||
|
|
||||||
|
def episode_filename(season: int, episode: int, series_title: str, ext: str) -> str:
|
||||||
|
"""Canonical episode filename ``SxxEyy - {series title}.ext``.
|
||||||
|
|
||||||
|
The release string is deliberately **not** kept — identity is the ``SxxEyy``
|
||||||
|
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
|
||||||
|
the code + the series folder's ``[tmdbid-N]``). An empty title collapses to
|
||||||
|
``SxxEyy.ext``.
|
||||||
"""
|
"""
|
||||||
code = f"S{season:02d}E{episode:02d}"
|
code = f"S{season:02d}E{episode:02d}"
|
||||||
body = episode_body(orig_stem)
|
body = sanitize_title(series_title)
|
||||||
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
|
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
|
||||||
|
|
||||||
|
|
||||||
@@ -115,13 +136,13 @@ def series_folder_stem(title: str, year: object = None, tmdb_id: object = None)
|
|||||||
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_episode(path: Path) -> bool:
|
def is_canonical_episode(path: Path, series_title: str) -> bool:
|
||||||
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
|
"""True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
|
||||||
parsed = parse_episode(path.name)
|
parsed = parse_episode(path.name)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return False
|
return False
|
||||||
season, episode = parsed
|
season, episode = parsed
|
||||||
expected = episode_filename(season, episode, path.stem, path.suffix)
|
expected = episode_filename(season, episode, series_title, path.suffix)
|
||||||
if path.name != expected:
|
if path.name != expected:
|
||||||
return False
|
return False
|
||||||
return path.parent.name == season_folder_name(season)
|
return path.parent.name == season_folder_name(season)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""External subtitle sidecars — attach, discover, keep in sync on rename.
|
||||||
|
|
||||||
|
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
|
||||||
|
file natively as long as it shares the video's filename stem, with an optional
|
||||||
|
language code and flag suffix::
|
||||||
|
|
||||||
|
Title (rok) [tmdbid-N].mkv
|
||||||
|
Title (rok) [tmdbid-N].cs.srt # language
|
||||||
|
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
|
||||||
|
|
||||||
|
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
|
||||||
|
pool index. What Curator does own: copying a picked file into place with the
|
||||||
|
right name (:func:`attach_subtitle`), and moving a subtitle along whenever its
|
||||||
|
video gets renamed so it never silently detaches (:func:`rename_subtitles_with`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
|
||||||
|
_FLAGS = {"forced", "sdh", "default"}
|
||||||
|
_LANG_RE = re.compile(r"[a-z]{2,3}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SubtitleFile:
|
||||||
|
"""One subtitle sidecar found next to a video."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
language: Optional[str] = None # ISO 639-1/2 code, e.g. "cs"
|
||||||
|
flag: Optional[str] = None # "forced" | "sdh" | "default"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
"""Human-readable ``jazyk (flag)`` for display, e.g. ``cs (forced)``."""
|
||||||
|
text = self.language or "?"
|
||||||
|
return f"{text} ({self.flag})" if self.flag else text
|
||||||
|
|
||||||
|
|
||||||
|
def subtitle_filename(
|
||||||
|
video_stem: str, ext: str, language: Optional[str] = None,
|
||||||
|
flag: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Canonical subtitle filename: ``{video_stem}[.language][.flag]{ext}``."""
|
||||||
|
parts = [video_stem]
|
||||||
|
if language:
|
||||||
|
parts.append(language.strip().lower())
|
||||||
|
if flag:
|
||||||
|
parts.append(flag.strip().lower())
|
||||||
|
return ".".join(parts) + ext.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_subtitle_name(
|
||||||
|
video_stem: str, filename: str
|
||||||
|
) -> Optional[tuple[Optional[str], Optional[str]]]:
|
||||||
|
"""``(language, flag)`` if ``filename`` is a subtitle sidecar of ``video_stem``."""
|
||||||
|
candidate = Path(filename)
|
||||||
|
if candidate.suffix.lower() not in SUBTITLE_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
stem = candidate.stem
|
||||||
|
if stem == video_stem:
|
||||||
|
return None, None
|
||||||
|
if not stem.startswith(f"{video_stem}."):
|
||||||
|
return None
|
||||||
|
tokens = [t for t in stem[len(video_stem) + 1:].split(".") if t]
|
||||||
|
if not tokens:
|
||||||
|
return None, None
|
||||||
|
language: Optional[str] = None
|
||||||
|
flag: Optional[str] = None
|
||||||
|
for token in tokens:
|
||||||
|
low = token.lower()
|
||||||
|
if low in _FLAGS:
|
||||||
|
flag = low
|
||||||
|
elif language is None and _LANG_RE.fullmatch(low):
|
||||||
|
language = low
|
||||||
|
return language, flag
|
||||||
|
|
||||||
|
|
||||||
|
def find_subtitles(video_path: Path) -> list[SubtitleFile]:
|
||||||
|
"""Every external subtitle sidecar sitting next to ``video_path``, sorted."""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
folder = video_path.parent
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
found: list[SubtitleFile] = []
|
||||||
|
for f in sorted(folder.iterdir()):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
parsed = _parse_subtitle_name(video_path.stem, f.name)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
language, flag = parsed
|
||||||
|
found.append(SubtitleFile(f, language, flag))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def attach_subtitle(
|
||||||
|
video_path: Path, source: Path, *, language: Optional[str] = None,
|
||||||
|
flag: Optional[str] = None, move: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Copy (or move) ``source`` next to ``video_path``, named to match it.
|
||||||
|
|
||||||
|
An existing subtitle already at that exact name (e.g. replacing the Czech
|
||||||
|
track) is overwritten; nothing else next to the video is touched.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: ``source``'s extension isn't a recognised subtitle format.
|
||||||
|
"""
|
||||||
|
video_path = Path(video_path)
|
||||||
|
source = Path(source)
|
||||||
|
ext = source.suffix.lower()
|
||||||
|
if ext not in SUBTITLE_EXTENSIONS:
|
||||||
|
raise ValueError(f"Nepodporovaný formát titulků: {source.suffix}")
|
||||||
|
target = video_path.parent / subtitle_filename(video_path.stem, ext, language, flag)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
if move:
|
||||||
|
shutil.move(str(source), str(target))
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]:
|
||||||
|
"""Move every subtitle sidecar of ``old_video_path`` to match ``new_video_path``.
|
||||||
|
|
||||||
|
Call this wherever a video gets renamed/moved so an attached subtitle never
|
||||||
|
silently detaches (its stem would stop matching and Jellyfin would drop it).
|
||||||
|
No-op when the video's path didn't actually change. Returns the new paths.
|
||||||
|
"""
|
||||||
|
old_video_path = Path(old_video_path)
|
||||||
|
new_video_path = Path(new_video_path)
|
||||||
|
if old_video_path == new_video_path:
|
||||||
|
return []
|
||||||
|
moved: list[Path] = []
|
||||||
|
for sub in find_subtitles(old_video_path):
|
||||||
|
target = new_video_path.parent / subtitle_filename(
|
||||||
|
new_video_path.stem, sub.path.suffix, sub.language, sub.flag)
|
||||||
|
if sub.path == target:
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
target.unlink()
|
||||||
|
sub.path.rename(target)
|
||||||
|
moved.append(target)
|
||||||
|
return moved
|
||||||
+784
-39
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
|
||||||
|
import hashlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core import csfd
|
||||||
|
from src.core.csfd import (
|
||||||
|
CsfdError,
|
||||||
|
CsfdFilm,
|
||||||
|
_extract_csfd_id,
|
||||||
|
_is_film_href,
|
||||||
|
_parse_origin,
|
||||||
|
_parse_original_title,
|
||||||
|
_solve_anubis_pow,
|
||||||
|
clean_filename_to_query,
|
||||||
|
fetch_film,
|
||||||
|
find_csfd_url,
|
||||||
|
search_films,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A trimmed ČSFD film page: Czech title in JSON-LD, USA origin, and a
|
||||||
|
# ``.film-names`` list whose USA-flagged entry is the original title.
|
||||||
|
FILM_HTML = """
|
||||||
|
<html><head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
|
||||||
|
"dateCreated":"1994","duration":"PT142M"}
|
||||||
|
</script>
|
||||||
|
</head><body>
|
||||||
|
<div class="film-header-name">
|
||||||
|
<h1>Vykoupení z věznice Shawshank</h1>
|
||||||
|
<ul class="film-names">
|
||||||
|
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
|
||||||
|
<span class="normal more-name-link"><a class="more">více</a></span></li>
|
||||||
|
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
|
||||||
|
Vykúpenie z väznice Shawshank</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="origin">USA <span class="bullet"></span>
|
||||||
|
<span>1994 <span class="bullet"></span> </span>142 min</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A Czech film: ČSFD omits the Czech title from ``.film-names`` (it equals the
|
||||||
|
# <h1>) and lists only a foreign festival title — the original title must fall
|
||||||
|
# back to the <h1>, not "Cosy Dens".
|
||||||
|
CZECH_FILM_HTML = """
|
||||||
|
<html><head>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
|
||||||
|
</script>
|
||||||
|
</head><body>
|
||||||
|
<div class="film-header-name"><h1>Pelíšky</h1>
|
||||||
|
<ul class="film-names">
|
||||||
|
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
|
||||||
|
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
|
||||||
|
</ul></div>
|
||||||
|
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The real search page groups hits into <section> blocks; a.film-title-name also
|
||||||
|
# matches series episodes, so search_films must scope to "Filmy" and drop the
|
||||||
|
# two-segment episode hrefs.
|
||||||
|
SEARCH_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<section><div class="box-header"><h2>Filmy</h2></div>
|
||||||
|
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
|
||||||
|
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
|
||||||
|
</section>
|
||||||
|
<section><div class="box-header"><h2>Seriály</h2></div>
|
||||||
|
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
|
||||||
|
</section>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanFilenameToQuery:
|
||||||
|
def test_strips_release_tags_and_keeps_year(self):
|
||||||
|
assert clean_filename_to_query(
|
||||||
|
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv") == "Matrix 1999"
|
||||||
|
|
||||||
|
def test_handles_spaces_and_parens_year(self):
|
||||||
|
assert clean_filename_to_query(
|
||||||
|
"Forrest Gump (1994) 2160p HDR.mkv") == "Forrest Gump 1994"
|
||||||
|
|
||||||
|
def test_no_year_no_markers(self):
|
||||||
|
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
|
||||||
|
|
||||||
|
def test_underscores_and_resolution(self):
|
||||||
|
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
|
||||||
|
|
||||||
|
def test_falls_back_to_stem_when_starting_with_marker(self):
|
||||||
|
assert clean_filename_to_query("1080p.mkv") == "1080p"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractCsfdId:
|
||||||
|
def test_extract_from_various_urls(self):
|
||||||
|
assert _extract_csfd_id(
|
||||||
|
"https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
|
||||||
|
assert _extract_csfd_id("/film/999/") == 999
|
||||||
|
|
||||||
|
def test_none_when_absent(self):
|
||||||
|
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnubisPoW:
|
||||||
|
def test_solve_difficulty_one_gives_one_leading_zero_nibble(self):
|
||||||
|
hash_hex, nonce, _ = _solve_anubis_pow("abc123", difficulty=1)
|
||||||
|
assert hash_hex[0] == "0"
|
||||||
|
assert hashlib.sha256(f"abc123{nonce}".encode()).hexdigest() == hash_hex
|
||||||
|
|
||||||
|
def test_solve_difficulty_two_gives_leading_zero_byte(self):
|
||||||
|
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
|
||||||
|
assert hash_hex[:2] == "00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOrigin:
|
||||||
|
def test_country_and_year_from_bullet_format(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||||
|
assert _parse_origin(soup) == ("USA", "1994")
|
||||||
|
|
||||||
|
def test_first_country_of_coproduction(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
html = ('<div class="origin">Francie / Německo <span class="bullet"></span>'
|
||||||
|
'<span>2001</span> 120 min</div>')
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
assert _parse_origin(soup) == ("Francie", "2001")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOriginalTitle:
|
||||||
|
def test_picks_name_matching_origin_country(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||||
|
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
|
||||||
|
|
||||||
|
def test_none_without_country_match_so_caller_uses_czech_title(self):
|
||||||
|
# A Czech film lists only foreign/festival alt-titles here — none match
|
||||||
|
# the origin country, so we must NOT return one of them.
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
html = ('<ul class="film-names">'
|
||||||
|
'<li><img class="flag" title="angličtina"/> Loners</li>'
|
||||||
|
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>')
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
assert _parse_original_title(soup, "Česko") is None
|
||||||
|
|
||||||
|
def test_cesko_matches_ceskoslovensko_flag(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
html = ('<ul class="film-names">'
|
||||||
|
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
|
||||||
|
'</ul>')
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
|
||||||
|
|
||||||
|
def test_none_when_no_names(self):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
soup = BeautifulSoup("<div>x</div>", "html.parser")
|
||||||
|
assert _parse_original_title(soup, "USA") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchFilm:
|
||||||
|
def test_parses_czech_and_original_title_and_year(self):
|
||||||
|
class _Resp:
|
||||||
|
text = FILM_HTML
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||||
|
film = fetch_film("https://www.csfd.cz/film/2294-x/")
|
||||||
|
|
||||||
|
assert film.czech_title == "Vykoupení z věznice Shawshank"
|
||||||
|
assert film.original_title == "The Shawshank Redemption"
|
||||||
|
assert film.year == "1994"
|
||||||
|
assert film.csfd_id == 2294
|
||||||
|
assert film.tmdb_query == ("The Shawshank Redemption", "1994")
|
||||||
|
|
||||||
|
def test_czech_film_original_title_falls_back_to_czech(self):
|
||||||
|
class _Resp:
|
||||||
|
text = CZECH_FILM_HTML
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||||
|
film = fetch_film("https://www.csfd.cz/film/15387-pelisky/")
|
||||||
|
|
||||||
|
assert film.czech_title == "Pelíšky"
|
||||||
|
assert film.original_title == "Pelíšky"
|
||||||
|
assert film.year == "1999"
|
||||||
|
assert film.tmdb_query == ("Pelíšky", "1999")
|
||||||
|
|
||||||
|
def test_wraps_network_error_in_csfd_error(self):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page",
|
||||||
|
side_effect=requests.RequestException("boom")):
|
||||||
|
with pytest.raises(CsfdError):
|
||||||
|
fetch_film("https://www.csfd.cz/film/1/")
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsFilmHref:
|
||||||
|
def test_accepts_single_segment_film(self):
|
||||||
|
assert _is_film_href("/film/2294-vykoupeni/prehled/") is True
|
||||||
|
assert _is_film_href("/film/2294-vykoupeni/") is True
|
||||||
|
|
||||||
|
def test_rejects_series_episode(self):
|
||||||
|
assert _is_film_href("/film/318290-arrow/620181-vykoupeni/prehled/") is False
|
||||||
|
|
||||||
|
def test_rejects_non_film(self):
|
||||||
|
assert _is_film_href("/tvurce/123-nekdo/") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchFilms:
|
||||||
|
def test_scopes_to_films_section_and_drops_episodes(self):
|
||||||
|
class _Resp:
|
||||||
|
text = SEARCH_HTML
|
||||||
|
|
||||||
|
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||||
|
films = search_films("shawshank", limit=5)
|
||||||
|
|
||||||
|
# the Seriály-section episode link is excluded
|
||||||
|
assert [f.csfd_id for f in films] == [2294, 248803]
|
||||||
|
assert films[0].url == (
|
||||||
|
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/")
|
||||||
|
|
||||||
|
def test_empty_query_returns_empty(self):
|
||||||
|
assert search_films(" ") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCsfdUrl:
|
||||||
|
def test_returns_first_hit_url(self):
|
||||||
|
hit = CsfdFilm(url="https://www.csfd.cz/film/2294-x/", csfd_id=2294)
|
||||||
|
with patch("src.core.csfd.search_films", return_value=[hit]):
|
||||||
|
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
|
||||||
|
|
||||||
|
def test_retries_without_trailing_year_when_first_query_misses(self):
|
||||||
|
hit = CsfdFilm(url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006)
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_search(query, limit=1, session=None):
|
||||||
|
calls.append(query)
|
||||||
|
return [] if query.endswith("2006") else [hit]
|
||||||
|
|
||||||
|
with patch("src.core.csfd.search_films", side_effect=fake_search):
|
||||||
|
assert find_csfd_url("Vratne lahve 2006") == hit.url
|
||||||
|
assert calls == ["Vratne lahve 2006", "Vratne lahve"]
|
||||||
|
|
||||||
|
def test_none_for_blank_query(self):
|
||||||
|
assert find_csfd_url(" ") is None
|
||||||
|
|
||||||
|
def test_none_when_no_results(self):
|
||||||
|
with patch("src.core.csfd.search_films", return_value=[]):
|
||||||
|
assert find_csfd_url("nic") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDependencies:
|
||||||
|
def test_dependencies_available_in_test_env(self):
|
||||||
|
assert csfd.dependencies_available() is True
|
||||||
+4
-4
@@ -37,7 +37,7 @@ class TestFile:
|
|||||||
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
|
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
|
||||||
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
||||||
file_obj = File(test_file, tag_manager, index=index)
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
assert index.get(file_obj.file_path) is not None
|
assert index.record_for(file_obj.file_path) is not None
|
||||||
|
|
||||||
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
|
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
|
||||||
"""Žádný .!tag sidecar už nevzniká."""
|
"""Žádný .!tag sidecar už nevzniká."""
|
||||||
@@ -57,7 +57,7 @@ class TestFile:
|
|||||||
file_obj.ignored = True
|
file_obj.ignored = True
|
||||||
file_obj.save_metadata()
|
file_obj.save_metadata()
|
||||||
|
|
||||||
data = index.get(file_obj.file_path)
|
data = index.record_for(file_obj.file_path)
|
||||||
assert data["new"] is False
|
assert data["new"] is False
|
||||||
assert data["ignored"] is True
|
assert data["ignored"] is True
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class TestFile:
|
|||||||
file_obj = File(test_file, tag_manager, index=index)
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
file_obj.set_date("2025-12-25")
|
file_obj.set_date("2025-12-25")
|
||||||
assert file_obj.date == "2025-12-25"
|
assert file_obj.date == "2025-12-25"
|
||||||
assert index.get(file_obj.file_path)["date"] == "2025-12-25"
|
assert index.record_for(file_obj.file_path)["date"] == "2025-12-25"
|
||||||
|
|
||||||
def test_file_set_date_to_none(self, test_file, tag_manager, index):
|
def test_file_set_date_to_none(self, test_file, tag_manager, index):
|
||||||
file_obj = File(test_file, tag_manager, index=index)
|
file_obj = File(test_file, tag_manager, index=index)
|
||||||
@@ -169,7 +169,7 @@ class TestFile:
|
|||||||
file_obj.add_tag("Test/Tag")
|
file_obj.add_tag("Test/Tag")
|
||||||
file_obj.set_date("2025-06-15")
|
file_obj.set_date("2025-06-15")
|
||||||
|
|
||||||
data = index.get(file_obj.file_path)
|
data = index.record_for(file_obj.file_path)
|
||||||
assert "new" in data
|
assert "new" in data
|
||||||
assert "ignored" in data
|
assert "ignored" in data
|
||||||
assert "tags" in data
|
assert "tags" in data
|
||||||
|
|||||||
+645
-9
@@ -208,7 +208,7 @@ class TestPoolManagement:
|
|||||||
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Matrix.mkv"
|
||||||
assert source.exists() # non-destructive copy
|
assert source.exists() # non-destructive copy
|
||||||
assert movie.title == "Matrix"
|
assert movie.title == "Matrix"
|
||||||
assert file_manager.index.get(movie.file_path) is not None
|
assert file_manager.index.record_for(movie.file_path) is not None
|
||||||
|
|
||||||
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
|
def test_statistics_aggregates_pool(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
@@ -274,7 +274,7 @@ class TestPoolManagement:
|
|||||||
# parseable ISO timestamp, persisted in the index
|
# parseable ISO timestamp, persisted in the index
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
datetime.fromisoformat(movie.added)
|
datetime.fromisoformat(movie.added)
|
||||||
assert file_manager.index.get(movie.file_path)["added"] == movie.added
|
assert file_manager.index.record_for(movie.file_path)["added"] == movie.added
|
||||||
|
|
||||||
def test_import_movie_move_removes_source(self, file_manager, tmp_path):
|
def test_import_movie_move_removes_source(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
@@ -325,7 +325,7 @@ class TestPoolManagement:
|
|||||||
assert second.file_path.read_bytes() == b"bb" # new content in place
|
assert second.file_path.read_bytes() == b"bb" # new content in place
|
||||||
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
|
assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"]
|
||||||
# index record reflects the new import (fresh metadata, old tags dropped)
|
# index record reflects the new import (fresh metadata, old tags dropped)
|
||||||
record = file_manager.index.get(second.file_path)
|
record = file_manager.index.record_for(second.file_path)
|
||||||
assert record is not None
|
assert record is not None
|
||||||
assert record["tags"] == []
|
assert record["tags"] == []
|
||||||
assert second.tags == []
|
assert second.tags == []
|
||||||
@@ -367,8 +367,8 @@ class TestPoolManagement:
|
|||||||
assert not old_path.exists()
|
assert not old_path.exists()
|
||||||
assert movie.title == "Matrix Reloaded"
|
assert movie.title == "Matrix Reloaded"
|
||||||
# metadata moved to the new key, old key gone, tags preserved
|
# metadata moved to the new key, old key gone, tags preserved
|
||||||
assert file_manager.index.get(new_path) is not None
|
assert file_manager.index.record_for(new_path) is not None
|
||||||
assert file_manager.index.get(old_path) is None
|
assert file_manager.index.record_for(old_path) is None
|
||||||
# a fresh manager reading the index sees the renamed file with its tags
|
# a fresh manager reading the index sees the renamed file with its tags
|
||||||
reloaded = FileManager(TagManager())
|
reloaded = FileManager(TagManager())
|
||||||
reloaded.set_pool_dir(tmp_path / "pool")
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
@@ -427,6 +427,15 @@ class TestPoolManagement:
|
|||||||
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||||
assert movie.title == "Matrix" # clean title, no year/id
|
assert movie.title == "Matrix" # clean title, no year/id
|
||||||
|
|
||||||
|
def test_import_movie_with_3d_format(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "raw.mkv", "Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||||
|
|
||||||
def test_libraries_default_and_derivation(self, file_manager, tmp_path):
|
def test_libraries_default_and_derivation(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
assert file_manager.libraries == [
|
assert file_manager.libraries == [
|
||||||
@@ -529,15 +538,18 @@ class TestPoolManagement:
|
|||||||
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Auta", year=2006)
|
||||||
old_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
|
old_path = movie.file_path
|
||||||
|
|
||||||
file_manager.move_to_library(movie, "Kids")
|
file_manager.move_to_library(movie, "Kids")
|
||||||
|
|
||||||
assert file_manager.library_of(movie) == "Kids"
|
assert file_manager.library_of(movie) == "Kids"
|
||||||
assert movie.file_path.exists()
|
assert movie.file_path.exists()
|
||||||
assert file_manager.index.records.get(old_key) is None # old key dropped
|
# The movie keeps its identity record; only its media path + library move.
|
||||||
new_key = movie.file_path.relative_to(tmp_path / "pool").as_posix()
|
assert file_manager.index.record_for(old_path) is None
|
||||||
assert file_manager.index.records.get(new_key) is not None
|
record = file_manager.index.record_for(movie.file_path)
|
||||||
|
assert record is not None
|
||||||
|
assert record["library"] == "Kids"
|
||||||
|
assert [m["file"] for m in record["media"]] == ["Kids/Auta (2006).mkv"]
|
||||||
|
|
||||||
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
|
def test_move_to_unknown_library_rejected(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
@@ -613,6 +625,42 @@ class TestPoolManagement:
|
|||||||
csfd_url="https://www.csfd.cz/film/9499-matrix/")
|
csfd_url="https://www.csfd.cz/film/9499-matrix/")
|
||||||
assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
assert movie.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||||
|
|
||||||
|
def test_apply_movie_edits_sets_3d_format(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||||
|
|
||||||
|
def test_apply_movie_edits_omitted_3d_format_is_preserved(self, file_manager, tmp_path):
|
||||||
|
"""Editing an unrelated field (e.g. date) must not silently drop the tag."""
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||||
|
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, date="2026-01-01")
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||||
|
assert movie.date == "2026-01-01"
|
||||||
|
|
||||||
|
def test_apply_movie_edits_empty_string_clears_3d_format(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="hsbs")
|
||||||
|
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="")
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Avatar (2009) [tmdbid-19995].mkv"
|
||||||
|
|
||||||
def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path):
|
def test_apply_movie_edits_rejects_name_clash(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
@@ -634,6 +682,19 @@ class TestPoolManagement:
|
|||||||
assert renamed is not None
|
assert renamed is not None
|
||||||
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
|
assert movie.file_path.name == "Matrix Reloaded (1999) [tmdbid-603].mkv"
|
||||||
|
|
||||||
|
def test_rename_to_canonical_preserves_3d_format(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "raw.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, format_3d="fsbs")
|
||||||
|
|
||||||
|
movie.title = "Avatar Retitled"
|
||||||
|
file_manager.rename_to_canonical(movie)
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Avatar Retitled (2009) [tmdbid-19995].3D.FSBS.mkv"
|
||||||
|
|
||||||
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
|
def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
(tmp_path / "a.mkv").write_bytes(b"a")
|
(tmp_path / "a.mkv").write_bytes(b"a")
|
||||||
@@ -734,3 +795,578 @@ class TestPoolManagement:
|
|||||||
assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"]
|
assert file_manager.copyasis_folders == ["Seriály", "Filmy jinak"]
|
||||||
assert "copyasis_folders" not in file_manager.global_config
|
assert "copyasis_folders" not in file_manager.global_config
|
||||||
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"]
|
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Filmy jinak"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCsfdBridge:
|
||||||
|
"""ČSFD → TMDb title bridge (resolve_via_csfd, migration wiring)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "test_config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_manager(self, temp_global_config):
|
||||||
|
return FileManager(TagManager())
|
||||||
|
|
||||||
|
def test_resolve_via_csfd_bridges_original_title_to_tmdb(
|
||||||
|
self, file_manager, monkeypatch
|
||||||
|
):
|
||||||
|
from src.core import csfd, tmdb
|
||||||
|
film = csfd.CsfdFilm(
|
||||||
|
url="https://www.csfd.cz/film/2294-x/", csfd_id=2294,
|
||||||
|
czech_title="Vykoupení z věznice Shawshank",
|
||||||
|
original_title="The Shawshank Redemption", year="1994")
|
||||||
|
monkeypatch.setattr(csfd, "fetch_film", lambda url, session=None: film)
|
||||||
|
monkeypatch.setattr(tmdb, "api_available", lambda: True)
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def fake_search(query, year=None, limit=5):
|
||||||
|
captured["query"], captured["year"] = query, year
|
||||||
|
return [tmdb.TmdbResult(278, "Vykoupení z věznice Shawshank", "1994", "movie")]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tmdb, "search_movie", fake_search)
|
||||||
|
|
||||||
|
result = file_manager.resolve_via_csfd(
|
||||||
|
csfd_url="https://www.csfd.cz/film/2294-x/")
|
||||||
|
|
||||||
|
assert captured == {"query": "The Shawshank Redemption", "year": "1994"}
|
||||||
|
assert result["tmdb"].tmdb_id == 278
|
||||||
|
assert result["film"] is film
|
||||||
|
|
||||||
|
def test_resolve_via_csfd_swallows_scrape_failure(self, file_manager, monkeypatch):
|
||||||
|
from src.core import csfd
|
||||||
|
|
||||||
|
def boom(url, session=None):
|
||||||
|
raise csfd.CsfdError("anti-bot")
|
||||||
|
|
||||||
|
monkeypatch.setattr(csfd, "fetch_film", boom)
|
||||||
|
result = file_manager.resolve_via_csfd(csfd_url="https://www.csfd.cz/film/1/")
|
||||||
|
assert result == {"csfd_url": "https://www.csfd.cz/film/1/",
|
||||||
|
"film": None, "tmdb": None}
|
||||||
|
|
||||||
|
def test_match_pool_uses_csfd_url_for_the_search(
|
||||||
|
self, file_manager, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
from src.core import csfd, tmdb
|
||||||
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "a.mkv", "Vykoupení z věznice Shawshank", year=1994,
|
||||||
|
csfd_url="https://www.csfd.cz/film/2294-x/")
|
||||||
|
|
||||||
|
film = csfd.CsfdFilm(
|
||||||
|
url=movie.csfd_url, czech_title="Vykoupení z věznice Shawshank",
|
||||||
|
original_title="The Shawshank Redemption", year="1994")
|
||||||
|
monkeypatch.setattr(csfd, "fetch_film", lambda url, session=None: film)
|
||||||
|
monkeypatch.setattr(tmdb, "api_available", lambda: True)
|
||||||
|
|
||||||
|
seen: list = []
|
||||||
|
|
||||||
|
def fake_search(query, year=None, limit=5):
|
||||||
|
seen.append((query, year))
|
||||||
|
return [tmdb.TmdbResult(278, "The Shawshank Redemption", "1994", "movie")]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tmdb, "search_movie", fake_search)
|
||||||
|
|
||||||
|
proposals = file_manager.match_pool_to_tmdb([movie])
|
||||||
|
|
||||||
|
assert seen == [("The Shawshank Redemption", "1994")]
|
||||||
|
assert len(proposals) == 1
|
||||||
|
assert proposals[0].status == "auto"
|
||||||
|
assert proposals[0].chosen.tmdb_id == 278
|
||||||
|
assert "ČSFD" in proposals[0].note
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubtitleAttachment:
|
||||||
|
"""External subtitle sidecars for movies (attach / list / remove / rename-along)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "test_config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_manager(self, temp_global_config, tmp_path):
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_attach_subtitle_copies_next_to_movie(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||||
|
srt = tmp_path / "downloaded.srt"
|
||||||
|
srt.write_bytes(b"1\n00:00:01,000 --> 00:00:02,000\nAhoj\n")
|
||||||
|
|
||||||
|
target = file_manager.attach_subtitle(movie, srt, language="cs")
|
||||||
|
|
||||||
|
assert target == movie.file_path.with_name("Matrix (1999).cs.srt")
|
||||||
|
assert target.exists()
|
||||||
|
assert srt.exists() # copied, not moved
|
||||||
|
assert [s.path for s in file_manager.list_subtitles(movie)] == [target]
|
||||||
|
|
||||||
|
def test_remove_subtitle_deletes_file(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||||
|
srt = tmp_path / "s.srt"
|
||||||
|
srt.write_bytes(b"x")
|
||||||
|
target = file_manager.attach_subtitle(movie, srt, language="cs")
|
||||||
|
|
||||||
|
file_manager.remove_subtitle(target)
|
||||||
|
|
||||||
|
assert not target.exists()
|
||||||
|
assert file_manager.list_subtitles(movie) == []
|
||||||
|
|
||||||
|
def test_rename_movie_carries_subtitle_along(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||||
|
srt = tmp_path / "s.srt"
|
||||||
|
srt.write_bytes(b"x")
|
||||||
|
old_sub = file_manager.attach_subtitle(movie, srt, language="cs")
|
||||||
|
|
||||||
|
file_manager.rename_movie(movie, "Matrix Reloaded")
|
||||||
|
|
||||||
|
assert not old_sub.exists()
|
||||||
|
new_sub = movie.file_path.with_name("Matrix Reloaded.cs.srt")
|
||||||
|
assert new_sub.exists()
|
||||||
|
|
||||||
|
def test_apply_movie_edits_carries_subtitle_along(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||||
|
srt = tmp_path / "s.srt"
|
||||||
|
srt.write_bytes(b"x")
|
||||||
|
file_manager.attach_subtitle(movie, srt, language="cs", flag="forced")
|
||||||
|
|
||||||
|
file_manager.apply_movie_edits(movie, title="Matrix", year=1999, tmdb_id=603)
|
||||||
|
|
||||||
|
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||||
|
assert movie.file_path.with_name(
|
||||||
|
"Matrix (1999) [tmdbid-603].cs.forced.srt").exists()
|
||||||
|
|
||||||
|
def test_move_to_library_carries_subtitle_along(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999)
|
||||||
|
srt = tmp_path / "s.srt"
|
||||||
|
srt.write_bytes(b"x")
|
||||||
|
file_manager.attach_subtitle(movie, srt, language="cs")
|
||||||
|
|
||||||
|
file_manager.move_to_library(movie, "Kids")
|
||||||
|
|
||||||
|
assert movie.file_path == tmp_path / "pool" / "Kids" / "Matrix (1999).mkv"
|
||||||
|
assert (tmp_path / "pool" / "Kids" / "Matrix (1999).cs.srt").exists()
|
||||||
|
assert not (tmp_path / "pool" / "Filmy" / "Matrix (1999).cs.srt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinidlnaScope:
|
||||||
|
"""Which libraries feed miniDLNA generation (minidlna_libraries / scope)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "test_config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_manager(self, temp_global_config, tmp_path):
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_default_is_none_meaning_all(self, file_manager):
|
||||||
|
assert file_manager.minidlna_libraries is None
|
||||||
|
|
||||||
|
def test_default_scope_is_the_whole_pool(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Filmy", "kind": "movie"}, {"name": "Seriály", "kind": "series"}])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||||
|
|
||||||
|
movies, mirrors, mixed_series = file_manager.minidlna_scope()
|
||||||
|
|
||||||
|
assert movies == [movie]
|
||||||
|
assert mirrors == ["Seriály"]
|
||||||
|
assert mixed_series == []
|
||||||
|
|
||||||
|
def test_scope_filters_to_selected_libraries(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Filmy", "kind": "movie"}, {"name": "Kids", "kind": "movie"},
|
||||||
|
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "series"}])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"x")
|
||||||
|
in_scope = file_manager.import_movie(
|
||||||
|
tmp_path / "a.mkv", "Matrix", year=1999, library="Filmy")
|
||||||
|
file_manager.import_movie(tmp_path / "b.mkv", "Kids Movie", year=2005, library="Kids")
|
||||||
|
|
||||||
|
file_manager.set_minidlna_libraries(["Filmy", "Seriály"])
|
||||||
|
movies, mirrors, mixed_series = file_manager.minidlna_scope()
|
||||||
|
|
||||||
|
assert movies == [in_scope]
|
||||||
|
assert mirrors == ["Seriály"]
|
||||||
|
assert mixed_series == []
|
||||||
|
|
||||||
|
def test_homevideo_libraries_are_mirrored_like_series(self, file_manager):
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||||
|
|
||||||
|
assert file_manager.mirror_as_is_libraries == ["Seriály", "Dokumenty"]
|
||||||
|
_, mirrors, _ = file_manager.minidlna_scope()
|
||||||
|
assert mirrors == ["Seriály", "Dokumenty"]
|
||||||
|
|
||||||
|
def test_homevideo_library_excluded_when_not_selected(self, file_manager):
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||||
|
file_manager.set_minidlna_libraries(["Seriály"])
|
||||||
|
|
||||||
|
_, mirrors, _ = file_manager.minidlna_scope()
|
||||||
|
assert mirrors == ["Seriály"]
|
||||||
|
|
||||||
|
def test_copyasis_folders_stays_series_only(self, file_manager):
|
||||||
|
"""The library-organizer "copy-as-is" concept doesn't widen to homevideo."""
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Seriály", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||||
|
assert file_manager.copyasis_folders == ["Seriály"]
|
||||||
|
|
||||||
|
def test_set_none_resets_to_all(self, file_manager):
|
||||||
|
file_manager.set_minidlna_libraries(["Filmy"])
|
||||||
|
file_manager.set_minidlna_libraries(None)
|
||||||
|
assert file_manager.minidlna_libraries is None
|
||||||
|
|
||||||
|
def test_drops_unknown_library_names_on_read(self, file_manager):
|
||||||
|
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
|
||||||
|
file_manager.set_minidlna_libraries(["Filmy", "Zaniklá knihovna"])
|
||||||
|
assert file_manager.minidlna_libraries == ["Filmy"]
|
||||||
|
|
||||||
|
def test_persists_across_reload(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}])
|
||||||
|
file_manager.set_minidlna_libraries(["Filmy"])
|
||||||
|
|
||||||
|
reloaded = FileManager(TagManager())
|
||||||
|
reloaded.set_pool_dir(tmp_path / "pool")
|
||||||
|
assert reloaded.minidlna_libraries == ["Filmy"]
|
||||||
|
|
||||||
|
def test_mixed_library_series_returned_separately_from_movies(
|
||||||
|
self, file_manager, tmp_path
|
||||||
|
):
|
||||||
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
|
(tmp_path / "movie.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(
|
||||||
|
tmp_path / "movie.mkv", "Matrix", year=1999, library="Hlavní")
|
||||||
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
|
||||||
|
library="Hlavní")
|
||||||
|
|
||||||
|
movies, mirrors, mixed_series = file_manager.minidlna_scope()
|
||||||
|
|
||||||
|
assert movies == [movie]
|
||||||
|
assert mirrors == []
|
||||||
|
assert len(mixed_series) == 1
|
||||||
|
assert mixed_series[0].title == "Breaking Bad"
|
||||||
|
assert mixed_series[0].library == "Hlavní"
|
||||||
|
|
||||||
|
def test_mixed_library_series_excluded_when_library_not_selected(
|
||||||
|
self, file_manager, tmp_path
|
||||||
|
):
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Hlavní", "kind": "mixed"}, {"name": "Filmy", "kind": "movie"}])
|
||||||
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, library="Hlavní")
|
||||||
|
|
||||||
|
file_manager.set_minidlna_libraries(["Filmy"])
|
||||||
|
_, _, mixed_series = file_manager.minidlna_scope()
|
||||||
|
|
||||||
|
assert mixed_series == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinidlnaPruneTargets:
|
||||||
|
"""What to remove from a previous generation's output once deselected."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "test_config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_manager(self, temp_global_config, tmp_path):
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_deselected_series_library_is_stale(self, file_manager):
|
||||||
|
file_manager.set_libraries([
|
||||||
|
{"name": "Anime", "kind": "series"}, {"name": "Dokumenty", "kind": "homevideo"}])
|
||||||
|
# both deselected — neither is "Seriály" so both go whole-folder
|
||||||
|
stale, valid = file_manager.minidlna_prune_targets(
|
||||||
|
mirror_libs=[], mixed_series=[], mixed_series_bucket="Seriály")
|
||||||
|
assert set(stale) == {"Anime", "Dokumenty"}
|
||||||
|
assert valid == set()
|
||||||
|
|
||||||
|
def test_selected_library_is_not_stale(self, file_manager):
|
||||||
|
file_manager.set_libraries([{"name": "Anime", "kind": "series"}])
|
||||||
|
stale, _ = file_manager.minidlna_prune_targets(
|
||||||
|
mirror_libs=["Anime"], mixed_series=[], mixed_series_bucket="Seriály")
|
||||||
|
assert stale == []
|
||||||
|
|
||||||
|
def test_bucket_named_library_never_whole_folder_removed(self, file_manager):
|
||||||
|
"""A real 'Seriály' library, even deselected, is reconciled per-child —
|
||||||
|
never nuked via the whole-folder path (that would also erase valid
|
||||||
|
mixed-library series sharing the same bucket)."""
|
||||||
|
file_manager.set_libraries([{"name": "Seriály", "kind": "series"}])
|
||||||
|
stale, valid = file_manager.minidlna_prune_targets(
|
||||||
|
mirror_libs=[], mixed_series=[], mixed_series_bucket="Seriály")
|
||||||
|
assert "Seriály" not in stale
|
||||||
|
assert valid == set()
|
||||||
|
|
||||||
|
def test_bucket_named_library_selected_contributes_its_children(
|
||||||
|
self, file_manager, tmp_path
|
||||||
|
):
|
||||||
|
file_manager.set_libraries([{"name": "Seriály", "kind": "series"}])
|
||||||
|
(tmp_path / "pool" / "Seriály" / "Show A").mkdir(parents=True)
|
||||||
|
(tmp_path / "pool" / "Seriály" / "Show B").mkdir(parents=True)
|
||||||
|
|
||||||
|
_, valid = file_manager.minidlna_prune_targets(
|
||||||
|
mirror_libs=["Seriály"], mixed_series=[], mixed_series_bucket="Seriály")
|
||||||
|
|
||||||
|
assert valid == {"Show A", "Show B"}
|
||||||
|
|
||||||
|
def test_mixed_series_folder_names_are_valid(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008,
|
||||||
|
tmdb_id=1396, library="Hlavní")
|
||||||
|
_, _, mixed_series = file_manager.minidlna_scope()
|
||||||
|
|
||||||
|
_, valid = file_manager.minidlna_prune_targets(
|
||||||
|
mirror_libs=[], mixed_series=mixed_series, mixed_series_bucket="Seriály")
|
||||||
|
|
||||||
|
assert valid == {"Breaking Bad (2008) [tmdbid-1396]"}
|
||||||
|
|
||||||
|
def test_deselected_mixed_series_drops_out_of_valid(self, file_manager, tmp_path):
|
||||||
|
"""Simulates re-running after the mixed library is unchecked: the
|
||||||
|
previously-mirrored series' folder name is no longer valid."""
|
||||||
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008,
|
||||||
|
tmdb_id=1396, library="Hlavní")
|
||||||
|
|
||||||
|
file_manager.set_minidlna_libraries([]) # deselect everything
|
||||||
|
_, _, mixed_series = file_manager.minidlna_scope()
|
||||||
|
_, valid = file_manager.minidlna_prune_targets(
|
||||||
|
mirror_libs=[], mixed_series=mixed_series, mixed_series_bucket="Seriály")
|
||||||
|
|
||||||
|
assert mixed_series == []
|
||||||
|
assert valid == set()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMovieVersions:
|
||||||
|
"""Multiple versions of one movie (2D+3D, 4K+1080p, …) — flat until a
|
||||||
|
second file shows up, then grouped into a per-movie folder."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_global_config(self, tmp_path, monkeypatch):
|
||||||
|
config_path = tmp_path / "test_config.json"
|
||||||
|
import src.core.config as config_module
|
||||||
|
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_manager(self, temp_global_config, tmp_path):
|
||||||
|
fm = FileManager(TagManager())
|
||||||
|
fm.set_pool_dir(tmp_path / "pool")
|
||||||
|
return fm
|
||||||
|
|
||||||
|
def test_flat_movie_is_not_a_group_member(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
assert file_manager.is_movie_group_member(movie) is False
|
||||||
|
assert file_manager.list_movie_versions(movie) == [movie]
|
||||||
|
assert file_manager.visible_movies() == [movie]
|
||||||
|
|
||||||
|
def test_add_version_promotes_flat_movie_to_a_folder(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
|
||||||
|
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||||
|
|
||||||
|
base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||||
|
assert movie.file_path == base / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
|
||||||
|
assert v2.file_path == base / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
|
||||||
|
assert file_manager.is_movie_group_member(movie)
|
||||||
|
assert file_manager.is_movie_group_member(v2)
|
||||||
|
assert (tmp_path / "b.mkv").exists() # copied, not moved (move=False default)
|
||||||
|
|
||||||
|
def test_add_version_carries_subtitle_along_on_promotion(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
srt = tmp_path / "s.srt"
|
||||||
|
srt.write_bytes(b"x")
|
||||||
|
file_manager.attach_subtitle(movie, srt, language="cs")
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||||
|
|
||||||
|
base = tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||||
|
assert (base / "Avatar (2009) [tmdbid-19995] - Výchozí.cs.srt").exists()
|
||||||
|
|
||||||
|
def test_second_version_added_to_existing_group(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||||
|
(tmp_path / "c.mkv").write_bytes(b"4k")
|
||||||
|
|
||||||
|
v3 = file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K")
|
||||||
|
|
||||||
|
assert len(file_manager.list_movie_versions(movie)) == 3
|
||||||
|
assert v3.file_path.name == "Avatar (2009) [tmdbid-19995] - 4K.mkv"
|
||||||
|
|
||||||
|
def test_add_version_rejects_empty_label(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "a.mkv", " ")
|
||||||
|
|
||||||
|
def test_add_version_rejects_duplicate_label(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
(tmp_path / "c.mkv").write_bytes(b"3d-2")
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "c.mkv", "3D")
|
||||||
|
|
||||||
|
def test_visible_movies_shows_only_the_primary_version(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
movie.add_tag("Žánr/Sci-Fi")
|
||||||
|
movie.csfd_url = "https://www.csfd.cz/film/avatar/"
|
||||||
|
movie.save_metadata()
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D HSBS")
|
||||||
|
|
||||||
|
visible = file_manager.visible_movies()
|
||||||
|
|
||||||
|
assert len(visible) == 1
|
||||||
|
assert visible[0].file_path == movie.file_path
|
||||||
|
assert visible[0].title == "Avatar"
|
||||||
|
assert visible[0].csfd_url == "https://www.csfd.cz/film/avatar/"
|
||||||
|
assert {t.full_path for t in visible[0].tags} == {"Žánr/Sci-Fi"}
|
||||||
|
|
||||||
|
def test_remove_version_keeps_group_when_more_than_one_remains(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
(tmp_path / "c.mkv").write_bytes(b"4k")
|
||||||
|
v3 = file_manager.add_movie_version(movie, tmp_path / "c.mkv", "4K")
|
||||||
|
|
||||||
|
file_manager.remove_movie_version(v3)
|
||||||
|
|
||||||
|
assert not v3.file_path.exists()
|
||||||
|
assert file_manager.is_movie_group_member(movie)
|
||||||
|
assert file_manager.is_movie_group_member(v2)
|
||||||
|
assert len(file_manager.list_movie_versions(movie)) == 2
|
||||||
|
|
||||||
|
def test_remove_last_extra_version_demotes_to_flat(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
file_manager.remove_movie_version(v2)
|
||||||
|
|
||||||
|
assert not file_manager.is_movie_group_member(movie)
|
||||||
|
assert movie.file_path == tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
|
||||||
|
assert movie.file_path.exists()
|
||||||
|
assert not (tmp_path / "pool" / "Filmy" / "Avatar (2009) [tmdbid-19995]").exists()
|
||||||
|
|
||||||
|
def test_removing_primary_migrates_metadata_to_survivor(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
movie.add_tag("Žánr/Sci-Fi")
|
||||||
|
movie.csfd_url = "https://www.csfd.cz/film/avatar/"
|
||||||
|
movie.attributes = {"collection_sort": "01"}
|
||||||
|
movie.save_metadata()
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
v2 = file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
file_manager.remove_movie_version(movie) # remove the primary
|
||||||
|
|
||||||
|
assert v2.title == "Avatar"
|
||||||
|
assert v2.csfd_url == "https://www.csfd.cz/film/avatar/"
|
||||||
|
assert v2.attributes == {"collection_sort": "01"}
|
||||||
|
assert {t.full_path for t in v2.tags} == {"Žánr/Sci-Fi"}
|
||||||
|
assert not file_manager.is_movie_group_member(v2) # demoted, only one left
|
||||||
|
|
||||||
|
def test_remove_version_rejects_ungrouped_movie(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.remove_movie_version(movie)
|
||||||
|
|
||||||
|
def test_rename_movie_refuses_grouped_movie(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.rename_movie(movie, "Avatar Reloaded")
|
||||||
|
|
||||||
|
def test_move_to_library_refuses_grouped_movie(self, file_manager, tmp_path):
|
||||||
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.move_to_library(movie, "Kids")
|
||||||
|
|
||||||
|
def test_apply_movie_edits_refuses_rename_of_grouped_movie(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.apply_movie_edits(movie, title="Avatar Reloaded", year=2009, tmdb_id=19995)
|
||||||
|
|
||||||
|
def test_apply_movie_edits_allows_metadata_only_edit_of_grouped_movie(
|
||||||
|
self, file_manager, tmp_path
|
||||||
|
):
|
||||||
|
"""Same title/year/tmdb_id → no path change → date/tags/etc still work."""
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
file_manager.apply_movie_edits(
|
||||||
|
movie, title="Avatar", year=2009, tmdb_id=19995, date="2026-01-01")
|
||||||
|
|
||||||
|
assert movie.date == "2026-01-01"
|
||||||
|
assert file_manager.is_movie_group_member(movie)
|
||||||
|
|
||||||
|
def test_rename_to_canonical_skips_grouped_movie(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "a.mkv").write_bytes(b"2d")
|
||||||
|
movie = file_manager.import_movie(tmp_path / "a.mkv", "Avatar", year=2009, tmdb_id=19995)
|
||||||
|
(tmp_path / "b.mkv").write_bytes(b"3d")
|
||||||
|
file_manager.add_movie_version(movie, tmp_path / "b.mkv", "3D")
|
||||||
|
|
||||||
|
movie.title = "Avatar Retitled"
|
||||||
|
assert file_manager.rename_to_canonical(movie) is None
|
||||||
|
assert movie.title == "Avatar Retitled" # metadata field unaffected
|
||||||
|
assert file_manager.is_movie_group_member(movie) # file untouched
|
||||||
|
|||||||
@@ -942,3 +942,30 @@ class TestMirrorAsIs:
|
|||||||
"""Neexistující zdroj nic neudělá"""
|
"""Neexistující zdroj nic neudělá"""
|
||||||
manager = HardlinkManager(tmp_path / "out")
|
manager = HardlinkManager(tmp_path / "out")
|
||||||
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
|
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveMirror:
|
||||||
|
"""Removing a whole mirrored library (or one bucket entry) from the output."""
|
||||||
|
|
||||||
|
def test_removes_a_whole_mirrored_folder(self, tmp_path):
|
||||||
|
output = tmp_path / "out"
|
||||||
|
(output / "Dokumenty" / "sub").mkdir(parents=True)
|
||||||
|
(output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x")
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
|
||||||
|
assert manager.remove_mirror("Dokumenty") is True
|
||||||
|
assert not (output / "Dokumenty").exists()
|
||||||
|
|
||||||
|
def test_removes_one_bucket_entry_by_compound_name(self, tmp_path):
|
||||||
|
output = tmp_path / "out"
|
||||||
|
(output / "Seriály" / "Show A").mkdir(parents=True)
|
||||||
|
(output / "Seriály" / "Show B").mkdir(parents=True)
|
||||||
|
manager = HardlinkManager(output)
|
||||||
|
|
||||||
|
assert manager.remove_mirror("Seriály/Show A") is True
|
||||||
|
assert not (output / "Seriály" / "Show A").exists()
|
||||||
|
assert (output / "Seriály" / "Show B").exists() # sibling untouched
|
||||||
|
|
||||||
|
def test_missing_target_returns_false(self, tmp_path):
|
||||||
|
manager = HardlinkManager(tmp_path / "out")
|
||||||
|
assert manager.remove_mirror("Nope") is False
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.naming import (
|
from src.core.naming import (
|
||||||
|
THREE_D_FORMATS,
|
||||||
canonical_pool_stem,
|
canonical_pool_stem,
|
||||||
is_canonical_pool_stem,
|
is_canonical_pool_stem,
|
||||||
is_valid_year,
|
is_valid_year,
|
||||||
|
parse_3d_format,
|
||||||
parse_pool_stem,
|
parse_pool_stem,
|
||||||
parse_tmdb_id,
|
parse_tmdb_id,
|
||||||
year_from_filename,
|
year_from_filename,
|
||||||
@@ -79,6 +81,43 @@ def test_is_canonical_pool_stem():
|
|||||||
assert not is_canonical_pool_stem("Matrix")
|
assert not is_canonical_pool_stem("Matrix")
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_with_3d_format():
|
||||||
|
assert canonical_pool_stem("Avatar", 2009, 19995, "hsbs") == \
|
||||||
|
"Avatar (2009) [tmdbid-19995].3D.HSBS"
|
||||||
|
# upper-cased regardless of input case
|
||||||
|
assert canonical_pool_stem("Avatar", 2009, None, "fsbs") == "Avatar (2009).3D.FSBS"
|
||||||
|
# empty/None → no tag at all
|
||||||
|
assert canonical_pool_stem("Avatar", 2009, None, None) == "Avatar (2009)"
|
||||||
|
assert canonical_pool_stem("Avatar", 2009, None, "") == "Avatar (2009)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_rejects_bad_3d_format():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
canonical_pool_stem("Avatar", 2009, None, "bogus")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fmt", THREE_D_FORMATS)
|
||||||
|
def test_parse_3d_format_roundtrip(fmt):
|
||||||
|
stem = canonical_pool_stem("Avatar", 2009, 19995, fmt)
|
||||||
|
assert parse_3d_format(stem) == fmt
|
||||||
|
assert parse_pool_stem(stem) == ("Avatar", 2009)
|
||||||
|
assert parse_tmdb_id(stem) == 19995
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_3d_format_case_insensitive_on_read():
|
||||||
|
assert parse_3d_format("Avatar (2009).3D.hsbs") == "HSBS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_3d_format_absent_is_none():
|
||||||
|
assert parse_3d_format("Avatar (2009) [tmdbid-19995]") is None
|
||||||
|
assert parse_3d_format("Avatar (2009)") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_pool_stem_3d_without_tmdb_id():
|
||||||
|
assert canonical_pool_stem("Avatar", 2009, None, "MVC") == "Avatar (2009).3D.MVC"
|
||||||
|
assert parse_3d_format("Avatar (2009).3D.MVC") == "MVC"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("name,year", [
|
@pytest.mark.parametrize("name,year", [
|
||||||
("Matrix.1999.1080p.BluRay.mkv", "1999"),
|
("Matrix.1999.1080p.BluRay.mkv", "1999"),
|
||||||
("Šíleně smutná princezna (1968).mkv", "1968"),
|
("Šíleně smutná princezna (1968).mkv", "1968"),
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ def test_tmdbid_from_tmdbid_tag(tmp_path):
|
|||||||
assert read_nfo(video).tmdb_id == 603
|
assert read_nfo(video).tmdb_id == 603
|
||||||
|
|
||||||
|
|
||||||
|
def test_csfd_url_from_uniqueid_bare_id(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8")
|
||||||
|
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csfd_url_from_uniqueid_full_url(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
'<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/'
|
||||||
|
'</uniqueid></movie>', encoding="utf-8")
|
||||||
|
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csfd_url_from_legacy_csfdid_tag(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8")
|
||||||
|
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/12345/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csfd_url_absent_is_none(tmp_path):
|
||||||
|
video = _movie(tmp_path)
|
||||||
|
video.with_suffix(".nfo").write_text(
|
||||||
|
"<movie><title>Matrix</title></movie>", encoding="utf-8")
|
||||||
|
assert read_nfo(video).csfd_url is None
|
||||||
|
|
||||||
|
|
||||||
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
|
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
|
||||||
folder = tmp_path / "Matrix (1999)"
|
folder = tmp_path / "Matrix (1999)"
|
||||||
folder.mkdir()
|
folder.mkdir()
|
||||||
|
|||||||
+144
-23
@@ -1,48 +1,68 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from src.core.pool_index import PoolIndex, INDEX_FILENAME
|
from src.core.pool_index import PoolIndex, INDEX_FILENAME, INDEX_VERSION
|
||||||
from src.core.file import File
|
from src.core.file import File
|
||||||
from src.core.tag_manager import TagManager
|
from src.core.tag_manager import TagManager
|
||||||
|
|
||||||
|
|
||||||
class TestPoolIndex:
|
class TestPoolIndex:
|
||||||
"""Testy pro unifikovaný metadata index poolu"""
|
"""Testy pro unifikovaný metadata index poolu (v2 — klíč = identita filmu)"""
|
||||||
|
|
||||||
def test_empty_index_when_no_file(self, tmp_path):
|
def test_empty_index_when_no_file(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
assert index.records == {}
|
assert index.records == {}
|
||||||
assert index.get(tmp_path / "Filmy" / "x.mkv") is None
|
assert index.get_movie("tmdb-603") is None
|
||||||
|
|
||||||
def test_set_and_get_by_relative_key(self, tmp_path):
|
def test_set_and_get_movie_by_identity(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
path = tmp_path / "Filmy" / "Matrix.mkv"
|
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||||
index.set(path, {"title": "Matrix"})
|
|
||||||
|
|
||||||
assert index.get(path) == {"title": "Matrix"}
|
assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []}
|
||||||
# key is the pool-relative POSIX path
|
assert "tmdb-603" in index.records
|
||||||
assert "Filmy/Matrix.mkv" in index.records
|
|
||||||
|
|
||||||
def test_set_persists_to_disk(self, tmp_path):
|
def test_set_persists_to_disk_with_version(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
index.set(tmp_path / "Filmy" / "A.mkv", {"title": "A"})
|
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||||
|
|
||||||
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
||||||
assert on_disk["movies"]["Filmy/A.mkv"]["title"] == "A"
|
assert on_disk["version"] == INDEX_VERSION
|
||||||
|
assert on_disk["movies"]["tmdb-603"]["title"] == "Matrix"
|
||||||
|
|
||||||
def test_reload_reads_existing_index(self, tmp_path):
|
def test_reload_reads_existing_index(self, tmp_path):
|
||||||
PoolIndex(tmp_path).set(tmp_path / "Filmy" / "A.mkv", {"title": "A"})
|
PoolIndex(tmp_path).set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||||
|
|
||||||
reloaded = PoolIndex(tmp_path)
|
reloaded = PoolIndex(tmp_path)
|
||||||
assert reloaded.get(tmp_path / "Filmy" / "A.mkv") == {"title": "A"}
|
assert reloaded.get_movie("tmdb-603")["title"] == "Matrix"
|
||||||
|
|
||||||
def test_delete_removes_record(self, tmp_path):
|
def test_delete_movie_removes_record(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
path = tmp_path / "Filmy" / "A.mkv"
|
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||||
index.set(path, {"title": "A"})
|
index.delete_movie("tmdb-603")
|
||||||
index.delete(path)
|
|
||||||
|
|
||||||
assert index.get(path) is None
|
assert index.get_movie("tmdb-603") is None
|
||||||
assert PoolIndex(tmp_path).get(path) is None
|
assert PoolIndex(tmp_path).get_movie("tmdb-603") is None
|
||||||
|
|
||||||
|
def test_rekey_movie(self, tmp_path):
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
index.set_movie("name-matrix-1999", {"title": "Matrix", "media": []})
|
||||||
|
|
||||||
|
index.rekey_movie("name-matrix-1999", "tmdb-603")
|
||||||
|
|
||||||
|
assert index.get_movie("name-matrix-1999") is None
|
||||||
|
assert index.get_movie("tmdb-603")["title"] == "Matrix"
|
||||||
|
|
||||||
|
def test_find_movie_by_media(self, tmp_path):
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
index.set_movie("tmdb-603", {
|
||||||
|
"title": "Matrix",
|
||||||
|
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}]})
|
||||||
|
|
||||||
|
found = index.find_movie_by_media(tmp_path / "Filmy" / "Matrix.mkv")
|
||||||
|
assert found is not None
|
||||||
|
key, record = found
|
||||||
|
assert key == "tmdb-603"
|
||||||
|
assert record["title"] == "Matrix"
|
||||||
|
assert index.find_movie_by_media(tmp_path / "Filmy" / "Jiny.mkv") is None
|
||||||
|
|
||||||
def test_corrupt_index_loads_empty(self, tmp_path):
|
def test_corrupt_index_loads_empty(self, tmp_path):
|
||||||
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
|
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
|
||||||
@@ -50,8 +70,89 @@ class TestPoolIndex:
|
|||||||
assert index.records == {}
|
assert index.records == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1Migration:
|
||||||
|
"""Starý path-klíčovaný index se převede na identity-klíčovaný (bez hýbání soubory)"""
|
||||||
|
|
||||||
|
def _write_v1(self, tmp_path, movies: dict) -> None:
|
||||||
|
(tmp_path / INDEX_FILENAME).write_text(
|
||||||
|
json.dumps({"settings": {"tag_schema": []}, "movies": movies}),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
def test_flat_movie_becomes_identity_keyed_record(self, tmp_path):
|
||||||
|
self._write_v1(tmp_path, {
|
||||||
|
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
|
||||||
|
"title": "Matrix", "tags": ["Žánr/Akční"],
|
||||||
|
"csfd_url": "https://www.csfd.cz/film/9499/",
|
||||||
|
"date": "1999-03-31", "added": "2026-01-01T00:00:00",
|
||||||
|
"attributes": {"collection_sort": "01"},
|
||||||
|
}})
|
||||||
|
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
record = index.get_movie("tmdb-603")
|
||||||
|
assert record is not None
|
||||||
|
assert record["title"] == "Matrix"
|
||||||
|
assert record["year"] == "1999"
|
||||||
|
assert record["tmdb_id"] == 603
|
||||||
|
assert record["library"] == "Filmy"
|
||||||
|
assert record["media"] == [
|
||||||
|
{"role": "video", "label": "",
|
||||||
|
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv"}]
|
||||||
|
assert record["tags"] == ["Žánr/Akční"]
|
||||||
|
assert record["csfd_url"] == "https://www.csfd.cz/film/9499/"
|
||||||
|
assert record["attributes"] == {"collection_sort": "01"}
|
||||||
|
|
||||||
|
def test_grouped_versions_merge_into_one_record(self, tmp_path):
|
||||||
|
base = "Filmy/Avatar (2009) [tmdbid-19995]"
|
||||||
|
self._write_v1(tmp_path, {
|
||||||
|
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {"title": "Avatar"},
|
||||||
|
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {"title": "Avatar"},
|
||||||
|
})
|
||||||
|
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
assert len(index.all_movies()) == 1
|
||||||
|
record = index.get_movie("tmdb-19995")
|
||||||
|
assert {m["label"] for m in record["media"]} == {"Výchozí", "3D HSBS"}
|
||||||
|
assert record["library"] == "Filmy"
|
||||||
|
|
||||||
|
def test_movie_without_tmdbid_gets_slug_key(self, tmp_path):
|
||||||
|
self._write_v1(tmp_path, {
|
||||||
|
"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
|
||||||
|
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
assert index.get_movie("name-pelisky-1999") is not None
|
||||||
|
|
||||||
|
def test_migration_backs_up_and_bumps_version(self, tmp_path):
|
||||||
|
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
|
||||||
|
|
||||||
|
PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
backup = tmp_path / (INDEX_FILENAME + ".v1.bak")
|
||||||
|
assert backup.exists()
|
||||||
|
assert "Filmy/A (2000).mkv" in json.loads(backup.read_text(encoding="utf-8"))["movies"]
|
||||||
|
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
||||||
|
assert on_disk["version"] == INDEX_VERSION
|
||||||
|
|
||||||
|
def test_migration_keeps_settings(self, tmp_path):
|
||||||
|
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
assert index.get_setting("tag_schema") == []
|
||||||
|
|
||||||
|
def test_v2_index_is_not_migrated_again(self, tmp_path):
|
||||||
|
(tmp_path / INDEX_FILENAME).write_text(json.dumps({
|
||||||
|
"version": INDEX_VERSION, "settings": {},
|
||||||
|
"movies": {"tmdb-1": {"title": "X", "media": []}}}), encoding="utf-8")
|
||||||
|
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
|
||||||
|
assert index.get_movie("tmdb-1")["title"] == "X"
|
||||||
|
assert not (tmp_path / (INDEX_FILENAME + ".v1.bak")).exists()
|
||||||
|
|
||||||
|
|
||||||
class TestFileWithIndex:
|
class TestFileWithIndex:
|
||||||
"""File používá index místo sidecar souboru, je-li injektován"""
|
"""File je pohled na jeden media záznam filmu v indexu"""
|
||||||
|
|
||||||
def test_index_backed_file_writes_no_sidecar(self, tmp_path):
|
def test_index_backed_file_writes_no_sidecar(self, tmp_path):
|
||||||
index = PoolIndex(tmp_path)
|
index = PoolIndex(tmp_path)
|
||||||
@@ -62,7 +163,7 @@ class TestFileWithIndex:
|
|||||||
f = File(movie, TagManager(), index=index)
|
f = File(movie, TagManager(), index=index)
|
||||||
|
|
||||||
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
|
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
|
||||||
assert index.get(movie) is not None # record created in index
|
assert index.find_movie_by_media(movie) is not None # record created
|
||||||
assert f.tags == [] # no automatic tags
|
assert f.tags == [] # no automatic tags
|
||||||
|
|
||||||
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
|
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
|
||||||
@@ -94,4 +195,24 @@ class TestFileWithIndex:
|
|||||||
|
|
||||||
f = File(movie, TagManager(), index=index)
|
f = File(movie, TagManager(), index=index)
|
||||||
f.delete_metadata()
|
f.delete_metadata()
|
||||||
assert index.get(movie) is None
|
assert index.find_movie_by_media(movie) is None
|
||||||
|
|
||||||
|
def test_two_versions_share_one_movie_record(self, tmp_path):
|
||||||
|
"""Obě verze čtou stejná metadata — to je smysl identity klíče."""
|
||||||
|
index = PoolIndex(tmp_path)
|
||||||
|
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
a = folder / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
|
||||||
|
b = folder / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
|
||||||
|
a.write_bytes(b"x")
|
||||||
|
b.write_bytes(b"y")
|
||||||
|
|
||||||
|
tm = TagManager()
|
||||||
|
fa = File(a, tm, index=index)
|
||||||
|
fa.title = "Avatar"
|
||||||
|
fa.add_tag("Žánr/Sci-Fi")
|
||||||
|
fb = File(b, tm, index=index)
|
||||||
|
|
||||||
|
assert len(index.all_movies()) == 1
|
||||||
|
assert fb.title == "Avatar"
|
||||||
|
assert "Žánr/Sci-Fi" in {t.full_path for t in fb.tags}
|
||||||
|
|||||||
+138
-16
@@ -2,9 +2,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.core.series import (
|
from src.core.series import (
|
||||||
|
collect_episode_files,
|
||||||
episode_filename,
|
episode_filename,
|
||||||
is_canonical_episode,
|
is_canonical_episode,
|
||||||
|
is_season_folder,
|
||||||
parse_episode,
|
parse_episode,
|
||||||
|
sanitize_title,
|
||||||
scan_series,
|
scan_series,
|
||||||
season_folder_name,
|
season_folder_name,
|
||||||
series_folder_stem,
|
series_folder_stem,
|
||||||
@@ -29,13 +32,29 @@ def test_season_folder_name():
|
|||||||
assert season_folder_name(12) == "Season 12"
|
assert season_folder_name(12) == "Season 12"
|
||||||
|
|
||||||
|
|
||||||
def test_episode_filename_keeps_original_name():
|
def test_episode_filename_is_code_plus_series_title():
|
||||||
assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv"
|
assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
|
||||||
# already-canonical stem → no doubled prefix
|
assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
|
||||||
assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \
|
# release string is NOT kept — only the code + series title
|
||||||
"S01E02 - bb.pilot.hdtv.mkv"
|
assert episode_filename(12, 145, "Breaking Bad", ".mkv") == \
|
||||||
# stem that was only the code → collapses to just the code
|
"S12E145 - Breaking Bad.mkv"
|
||||||
assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv"
|
# empty / unusable title → just the code
|
||||||
|
assert episode_filename(3, 4, "", ".mkv") == "S03E04.mkv"
|
||||||
|
assert episode_filename(3, 4, "/", ".mkv") == "S03E04.mkv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_title():
|
||||||
|
assert sanitize_title("Most!") == "Most!"
|
||||||
|
assert sanitize_title('A: B / C D') == "A B C D"
|
||||||
|
assert sanitize_title(" trailing. ") == "trailing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_season_folder():
|
||||||
|
assert is_season_folder("Season 01")
|
||||||
|
assert is_season_folder("Season 1")
|
||||||
|
assert is_season_folder("Specials")
|
||||||
|
assert not is_season_folder("Extras")
|
||||||
|
assert not is_season_folder("Season")
|
||||||
|
|
||||||
|
|
||||||
def test_series_folder_stem():
|
def test_series_folder_stem():
|
||||||
@@ -47,12 +66,15 @@ def test_series_folder_stem():
|
|||||||
def test_is_canonical_episode(tmp_path):
|
def test_is_canonical_episode(tmp_path):
|
||||||
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||||
folder.mkdir(parents=True)
|
folder.mkdir(parents=True)
|
||||||
good = folder / "S01E02 - bb.pilot.mkv"
|
good = folder / "S01E02 - Breaking Bad.mkv"
|
||||||
good.write_bytes(b"x")
|
good.write_bytes(b"x")
|
||||||
bad = folder / "bb.s01e02.mkv"
|
bad = folder / "bb.s01e02.mkv"
|
||||||
bad.write_bytes(b"x")
|
bad.write_bytes(b"x")
|
||||||
assert is_canonical_episode(good)
|
wrong_body = folder / "S01E02 - old release.mkv"
|
||||||
assert not is_canonical_episode(bad)
|
wrong_body.write_bytes(b"x")
|
||||||
|
assert is_canonical_episode(good, "Breaking Bad")
|
||||||
|
assert not is_canonical_episode(bad, "Breaking Bad")
|
||||||
|
assert not is_canonical_episode(wrong_body, "Breaking Bad")
|
||||||
|
|
||||||
|
|
||||||
def test_scan_series(tmp_path):
|
def test_scan_series(tmp_path):
|
||||||
@@ -73,6 +95,26 @@ def test_scan_series(tmp_path):
|
|||||||
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
|
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_episode_files_walks_subfolders(tmp_path):
|
||||||
|
root = tmp_path / "Breaking.Bad.COMPLETE"
|
||||||
|
(root / "Season 01").mkdir(parents=True)
|
||||||
|
(root / "Season 02").mkdir(parents=True)
|
||||||
|
(root / "Season 01" / "BB S01E01.mkv").write_bytes(b"x")
|
||||||
|
(root / "Season 01" / "BB S01E02.mkv").write_bytes(b"x")
|
||||||
|
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
|
||||||
|
(root / "Season 02" / "BB S02E01.avi").write_bytes(b"x")
|
||||||
|
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
|
||||||
|
|
||||||
|
files = collect_episode_files(root)
|
||||||
|
|
||||||
|
assert [f.name for f in files] == [
|
||||||
|
"BB S01E01.mkv", "BB S01E02.mkv", "BB S02E01.avi"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_episode_files_missing_folder_is_empty(tmp_path):
|
||||||
|
assert collect_episode_files(tmp_path / "nope") == []
|
||||||
|
|
||||||
|
|
||||||
class TestFileManagerSeries:
|
class TestFileManagerSeries:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def file_manager(self, tmp_path, monkeypatch):
|
def file_manager(self, tmp_path, monkeypatch):
|
||||||
@@ -94,8 +136,8 @@ class TestFileManagerSeries:
|
|||||||
assert result["imported"] == 2
|
assert result["imported"] == 2
|
||||||
base = (tmp_path / "pool" / "Seriály"
|
base = (tmp_path / "pool" / "Seriály"
|
||||||
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
||||||
assert (base / "S01E01 - bb.mkv").exists()
|
assert (base / "S01E01 - Breaking Bad.mkv").exists()
|
||||||
assert (base / "S01E02 - bb.mkv").exists()
|
assert (base / "S01E02 - Breaking Bad.mkv").exists()
|
||||||
|
|
||||||
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
|
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
|
||||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||||
@@ -106,7 +148,7 @@ class TestFileManagerSeries:
|
|||||||
|
|
||||||
assert result["imported"] == 1
|
assert result["imported"] == 1
|
||||||
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
|
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||||
/ "Season 01" / "S01E01 - bb.mkv").exists()
|
/ "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
|
||||||
# a mixed library is scanned for series too
|
# a mixed library is scanned for series too
|
||||||
found = file_manager.load_series()
|
found = file_manager.load_series()
|
||||||
assert [s.title for s in found] == ["Breaking Bad"]
|
assert [s.title for s in found] == ["Breaking Bad"]
|
||||||
@@ -118,6 +160,45 @@ class TestFileManagerSeries:
|
|||||||
assert result["imported"] == 0
|
assert result["imported"] == 0
|
||||||
assert result["skipped_no_code"] == ["random.mkv"]
|
assert result["skipped_no_code"] == ["random.mkv"]
|
||||||
|
|
||||||
|
def test_remove_series_deletes_the_folder(self, file_manager, tmp_path):
|
||||||
|
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||||
|
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
|
||||||
|
file_manager.import_episodes(
|
||||||
|
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S02E01.mkv"],
|
||||||
|
"Breaking Bad", year=2008, tmdb_id=1396)
|
||||||
|
(ser,) = file_manager.load_series()
|
||||||
|
assert ser.folder.is_dir()
|
||||||
|
|
||||||
|
file_manager.remove_series(ser)
|
||||||
|
|
||||||
|
assert not ser.folder.exists()
|
||||||
|
assert file_manager.load_series() == []
|
||||||
|
|
||||||
|
def test_remove_series_refuses_folder_outside_pool(self, file_manager, tmp_path):
|
||||||
|
from src.core.series import Series
|
||||||
|
outside = tmp_path / "not-the-pool" / "Show (2020)"
|
||||||
|
outside.mkdir(parents=True)
|
||||||
|
bogus = Series(outside, "Show", "2020", None, "Seriály")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
file_manager.remove_series(bogus)
|
||||||
|
assert outside.is_dir() # untouched
|
||||||
|
|
||||||
|
def test_import_episodes_uses_explicit_codes_override(self, file_manager, tmp_path):
|
||||||
|
uncoded = tmp_path / "pilot.mkv"
|
||||||
|
uncoded.write_bytes(b"x")
|
||||||
|
misparsed = tmp_path / "bb 2008 e07.mkv" # "2008" would parse as 20x08
|
||||||
|
misparsed.write_bytes(b"x")
|
||||||
|
result = file_manager.import_episodes(
|
||||||
|
[uncoded, misparsed], "Breaking Bad", year=2008, tmdb_id=1396,
|
||||||
|
codes={uncoded: (1, 1), misparsed: (1, 7)})
|
||||||
|
|
||||||
|
assert result["imported"] == 2
|
||||||
|
base = (tmp_path / "pool" / "Seriály"
|
||||||
|
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
||||||
|
assert (base / "S01E01 - Breaking Bad.mkv").exists()
|
||||||
|
assert (base / "S01E07 - Breaking Bad.mkv").exists()
|
||||||
|
assert result["skipped_no_code"] == []
|
||||||
|
|
||||||
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
|
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
|
||||||
# a mis-named episode sitting in the wrong place
|
# a mis-named episode sitting in the wrong place
|
||||||
folder = (tmp_path / "pool" / "Seriály"
|
folder = (tmp_path / "pool" / "Seriály"
|
||||||
@@ -129,8 +210,49 @@ class TestFileManagerSeries:
|
|||||||
assert len(series_list) == 1
|
assert len(series_list) == 1
|
||||||
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
||||||
|
|
||||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")]
|
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||||
assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists()
|
assert (folder / "Season 01" / "S01E03 - Breaking Bad.mkv").exists()
|
||||||
|
|
||||||
|
def test_rename_episodes_to_canonical_carries_subtitle_along(
|
||||||
|
self, file_manager, tmp_path
|
||||||
|
):
|
||||||
|
folder = (tmp_path / "pool" / "Seriály"
|
||||||
|
/ "Breaking Bad (2008) [tmdbid-1396]")
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
||||||
|
(folder / "bb.1x03.HDTV.cs.srt").write_bytes(b"x")
|
||||||
|
|
||||||
|
result = file_manager.rename_episodes_to_canonical(file_manager.load_series()[0])
|
||||||
|
|
||||||
|
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||||
|
assert (folder / "Season 01" / "S01E03 - Breaking Bad.cs.srt").exists()
|
||||||
|
assert not (folder / "bb.1x03.HDTV.cs.srt").exists()
|
||||||
|
|
||||||
|
def test_rename_all_series_to_canonical_bulk(self, file_manager, tmp_path):
|
||||||
|
seri = tmp_path / "pool" / "Seriály"
|
||||||
|
# series A: mis-named episodes in a non-padded season folder
|
||||||
|
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").mkdir(parents=True)
|
||||||
|
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1"
|
||||||
|
/ "fargo.1x01.1080p.WEB.mkv").write_bytes(b"x")
|
||||||
|
# series B: one good, one without a code
|
||||||
|
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01").mkdir(parents=True)
|
||||||
|
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
|
||||||
|
/ "S01E02 - Most!.mkv").write_bytes(b"x")
|
||||||
|
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
|
||||||
|
/ "necoZvlastniho.mkv").write_bytes(b"x")
|
||||||
|
|
||||||
|
result = file_manager.rename_all_series_to_canonical()
|
||||||
|
|
||||||
|
assert result["series"] == 2
|
||||||
|
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result["renamed"]
|
||||||
|
assert (seri / "Fargo (2014) [tmdbid-60622]" / "Season 01"
|
||||||
|
/ "S01E01 - Fargo.mkv").exists()
|
||||||
|
# emptied non-padded season folder swept
|
||||||
|
assert not (seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").exists()
|
||||||
|
# the already-canonical one is untouched, the code-less one is reported
|
||||||
|
assert ("Most!", "necoZvlastniho.mkv") in result["skipped_no_code"]
|
||||||
|
assert (seri / "Most! (2019) [tmdbid-90000]" / "Season 01"
|
||||||
|
/ "S01E02 - Most!.mkv").exists()
|
||||||
|
|
||||||
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
|
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
|
||||||
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
|
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
|
||||||
@@ -144,5 +266,5 @@ class TestFileManagerSeries:
|
|||||||
new_folder = (tmp_path / "pool" / "Seriály"
|
new_folder = (tmp_path / "pool" / "Seriály"
|
||||||
/ "Breaking Bad (2008) [tmdbid-1396]")
|
/ "Breaking Bad (2008) [tmdbid-1396]")
|
||||||
assert result["folder"] == new_folder
|
assert result["folder"] == new_folder
|
||||||
assert (new_folder / "Season 01" / "S01E01 - old.mkv").exists()
|
assert (new_folder / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
|
||||||
assert not folder.exists()
|
assert not folder.exists()
|
||||||
|
|||||||
@@ -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) == []
|
||||||
Reference in New Issue
Block a user