diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f017a0..9e35e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,13 @@ Each version entry uses these sections (include only those that apply): - Always update this file before bumping the version in `pyproject.toml` - Document changes as they are made, not all at once at release time -## Unreleased +## 1.13.0 — 2026-10-12 ### Changed +- **Custom attributes are gone from the GUI.** The attribute table in + *Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are + removed. Values already stored keep working in miniDLNA filename templates + (`File.name_context`); they just can't be edited from the app anymore. - **The pool index is keyed by the movie, not by a file path** (index **v2**, `src/core/movie.py`). A record used to describe one *file*, so a movie's metadata was a passenger on one particular file — which is why the "primary @@ -54,8 +58,106 @@ Each version entry uses these sections (include only those that apply): **migrates a v1 index on first open** — metadata only, **no file is moved** (a v1 movie already sits where v2 wants it), with the old index copied to `.Curator.!index.v1.bak` first. +- **"Odebrat z poolu" removes the whole movie** (`FileManager.remove_movie`): + on a multi-version row it deletes every version, their subtitles and the + version folder — it used to delete only the file shown in the table and + leave the rest behind as a half-empty group. Subtitles of a flat movie are + removed too now. ### Added +- **Upravit film: one "Média filmu" section with a folder mode.** A flat + movie (one file in the library root, plus subtitles) shows as "Samostatný + film"; adding a second medium moves it into its own folder automatically. + The folder has two modes, chosen with a **"Verze / Díly"** switch: + **versions** — alternatives, each with its own popisek (free text such as + "kinoverze", "zkrácená verze", or a 3D preset via **"✏️ Popisek / 3D…"**); + **parts** — pieces played back to back, listed in order and reordered with + **↑ / ↓**. "➕ Přidat médium…" adds by the chosen mode (for a flat movie the + switch decides what the second file becomes). Switching an existing folder + converts it: to parts numbers the files in their current order; to versions + makes the first file the default and asks a popisek for each other one. + New `FileManager.movie_group_mode`, `ordered_media` (parts sorted + numerically, `part2` before `part10`), `move_movie_part`, + `set_movie_group_mode`; all of them relabel the folder through temporary + names, so swapping two parts never overwrites a file. +- **Movie parts (Jellyfin multi-part / stacking)** — a film that TMDb lists + as one movie but that you have as several files. *Upravit film* gets + **"➕ Přidat díl…"**: the film moves into its own folder (Jellyfin requires + it for parts, even with no other versions) as `Folder-part1.ext`, + `Folder-part2.ext`, … and Jellyfin plays them back to back as one movie — + unlike versions, which it offers as alternatives. Under the hood a part is + a version whose popisek is `partN`, so everything that handles versions + handles parts too (removing down to one demotes to flat, subtitles follow + each part, renaming works). Any Jellyfin spelling typed as a popisek + (`part2`, `CD2`, `pt 2`, `disc-2`) is written back as `-part2` and the + existing file becomes `part1` — so the import collision dialog's "Přidat + jako verzi" with popisek `CD2` works the same way. The version table shows + "díl N", the main table "Film · N díly". Parts and versions can't be + combined in one folder yet (`add_movie_part` refuses). New + `FileManager.add_movie_part`, `movie.normalize_part_label` / `part_label`. +- **Upravit seriál: TMDb lookup, optionally from a movie id.** The series + editor gets a **"🔎 Dohledat"** button (title + year from TMDb, by the typed + id or by the title) and a **"Metadata projednou z ID filmu (ne seriálu)"** + checkbox for series that TMDb lists only as a movie (a miniseries cut + together, a documentary in parts): ticked, the lookup uses the movie + endpoints instead of the TV ones. The movie id is then **not** written into + the series folder's `[tmdbid-N]` — Jellyfin's TV provider would resolve it as + a TV id and match an unrelated show — so Jellyfin matches the series by + title and year instead (`SeriesEditDialog._lookup`, `.tmdb_id`). +- **Import movies: assign a version right in the batch** (e.g. a 2D + a 3D + file of the same film picked together). A row's new **"🔗 Přiřadit jako + verzi…"** button picks which other row in the dialog it's a version of and + a popisek (pre-filled with the 3D formát detected in its filename via + `naming.three_d_format_from_filename` — e.g. `Avatar.2009.3D.HSBS.mkv` → + `3D HSBS` — free text otherwise, same `VersionLabelDialog` as *Upravit + film → Verze*); its own název/rok/TMDb ID/ČSFD fields grey out since they + come from the film it's a version of. On import the target row lands + first via `import_movie`, then every linked row is added onto it via + `FileManager.add_movie_version` — one movie with several versions instead + of two separate (duplicate) movies. Removing a row it points to un-links + it back to standalone; a row already pointed at by others can't itself + become a version (no chains). +- **Import movies: "3D formát" per row**, for the opposite case — a film + whose *only* file is entirely 3D, with no plain version to make it a + version *of*. A combo box (pre-filled from the filename, same detection as + the popisek above) tags a standalone row's import straight to + `Title (rok) [tmdbid].3D..ext`, exactly like the 3D formát dropdown + in *Upravit film* — no more importing plain, then editing it in just to add + the tag. Disabled (and reset) the moment a row is linked as a version or + gains one as a follower, since a grouped movie's 3D-ness belongs on the + specific version's popisek instead, never on the group as a whole. +- **Duplicate check** (*Nástroje → Kontrola duplicit…*, + `FileManager.find_duplicates`). Lists movies that are in the pool more than + once — a version folder counts as one movie, never as a duplicate of itself. + Flagged when two movies share the **same TMDb id** (e.g. `X.mkv` and + `X.3D.HSBS.mkv` lying flat side by side, or one film in two libraries), the + **same title + year** (one copy paired with TMDb, the other not), or a + **byte-identical file size** (a plain copy); overlapping hits form one group + with every reason listed. Per file the dialog offers **"Sloučit jako verzi + do…"** (`FileManager.merge_as_version`: moves the file into the other movie's + version folder — promoting it from flat when needed, a 3D file pre-labelled + `3D ` — carries its subtitles along, keeps the target's metadata and + only adds missing tags / ČSFD link, and drops the duplicate's own record) or + **"Smazat vybraný z poolu…"**. +- **Multi-version movies are marked in the main table**: `▣` before the bold + name, `Film · N verze` in the Typ column, the size summed over all versions + and a tooltip listing every file; the detail panel shows the folder and the + version count. +- **Episodes split into physical parts** — an old recording cut into several + files, as opposed to `E01-E02` (several *episodes* in one file) — + get Jellyfin's own `.ptN` stacking suffix: `SxxEyy - {série}.ptN.ext`. + Recognised from *cd/dvd/part/pt/disc/disk* + a number already in the source + filename (`Show.S01E02.pt2.mkv`, `Show S01E01-part-1.mkv`, …), or typed by + hand in the import dialog's SxxEyy column as `S01E02P3` (new + `series.parse_episode_part`/`parse_episode_code`); carried through + scanning, import, "Napravit názvy souborů…" and the series tree. +- **Přehled seriálů → "Odebrat z poolu…" now targets whatever is selected in + the tree**, not always the whole series: pick a single episode to delete + just that file (+ its subtitles), a season to delete the whole `Season NN`/ + `Specials` folder, or the series itself as before. New + `FileManager.remove_episodes`/`remove_season`; a season folder left + completely empty by the removal is cleaned up, one still holding something + else is left alone. - **Multiple versions of one movie** (Jellyfin's own "multiple versions" — 2D+3D, 4K+1080p, Director's Cut, …). A movie is identified by its `(title, year, tmdbid)` and stays a **flat file** while it has exactly one @@ -71,9 +173,15 @@ Each version entry uses these sections (include only those that apply): then a small `VersionLabelDialog`: a **3D formát** preset — its label becomes exactly `3D `, the same tokens Jellyfin scans for in a flat `.3D.` 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 + `Director's Cut`), **"✏️ Přejmenovat verzi…"** (`FileManager.rename_movie_version` + — same dialog, pre-filled with the current label; renames the file + its + subtitles in place, shared metadata untouched) and **"➖ Odebrat verzi"** + (removing down to one version **demotes back to flat** automatically). A + flat movie always shows one row too — labelled **„Výchozí"**, a placeholder + since it has no on-disk label yet; renaming is disabled until a second + version exists (there's nothing to rename it *from* until then). The table + still shows **one row per movie** — a group's other versions live only in + that list, not as separate rows (`FileManager.visible_movies`; the row shown is the plain / `Výchozí` version, `primary_version`). All versions share **one metadata record** (see *Changed → index v2*), so a new version inherits the title/ @@ -82,9 +190,8 @@ Each version entry uses these sections (include only those that apply): 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). + `remove_movie_version`. Works in `mixed` libraries too (see *Fixed* below + for how a version folder there is told apart from a series). - **3D movie tagging** (`naming.py`, Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/). *Upravit film* gets a **„3D formát"** dropdown (žádný / HSBS / FSBS / HTAB / FTAB / MVC), @@ -240,6 +347,69 @@ Each version entry uses these sections (include only those that apply): `Title (rok) [tmdbid].ext`. Already-migrated files are skipped (re-runnable). `FileManager.match_pool_to_tmdb` / `assign_tmdb_id`. +### Fixed +- **Adding/removing a movie version could silently drop its 3D formát.** + Promoting a flat, 3D-only movie to a group (adding a second version) + always renamed the pre-existing file to `" - Výchozí"`, discarding + whatever `.3D.` tag it had — same the other way round: demoting + back to flat when the sole survivor's own popisek *was* a 3D formát always + produced a plain filename. Both now read the file's own current formát + (`naming.parse_3d_format` / `movie.normalize_3d_label`) and carry it over + automatically, so a movie can freely gain and lose versions — 3D-only, + 2D-only, several formats at once — without ever losing a formát that + wasn't touched in that particular step. Affects `add_movie_version` (its + `existing_label` param, and `merge_as_version`'s `keep_label` which now + derives from it too) and `remove_movie_version`. +- **A version's popisek can be left empty** — for a plain "2D" file with + nothing else to call it — instead of being rejected; it becomes + `movie.DEFAULT_VERSION_LABEL` ("Výchozí") the same way the pre-existing + file already did on promotion. Applies to `add_movie_version`, + `rename_movie_version`, `merge_as_version`, and everywhere they're driven + from the GUI (*Upravit film → Verze*, *Kontrola duplicit*, *Importovat + filmy…*'s "Přiřadit jako verzi…") — none of them require typing "Výchozí" + by hand anymore. `VersionLabelDialog`'s popisek field says so directly. +- **Renaming or demoting a version could wipe the film's tags.** Every version + holds its own in-memory copy of the shared metadata; one loaded before a tag + was added wrote its stale copy back when it was renamed, relabelled or left + as the sole survivor. Those paths now use the new `File.save_location`, which + re-reads the movie record first so only the file's own path and popisek + change. +- **A movie's version couldn't be removed when the edit dialog was open on + it.** *Upravit film* opens on the movie's primary version, and removing that + very file was blocked ("Nelze odebrat verzi, kterou právě upravuješ…") — so + in a folder whose first file alphabetically was the one to go, it couldn't go + at all. It can now: the dialog carries on with a sibling (all versions share + one movie record) and *Upravit film* applies its edits to that file. +- **Parts and versions could be mixed in one folder**, e.g. `-part1`, `-part2` + and a version labelled `díl 1` — and the version table showed both the part + and the version as "díl 1". `add_movie_version` and `rename_movie_version` + now refuse a folder holding both kinds, and the version table has separate + **Typ** (díl N / verze / Výchozí) and **Popisek** (the exact label) columns. +- **The default version of a grouped movie is named bare.** In a folder of + two or more versions the plain one is now `Folder/Folder.ext`, not + `Folder/Folder - Výchozí.ext`: "Výchozí" is only the table's name for it. + Other versions stay `Folder - .ext`. The version table and index + label read it as the default. Pool files already named `- Výchozí` are + renamed with **Nástroje → "Sjednotit názvy výchozích verzí…"** + (`FileManager.normalize_default_version_names`; subtitles and metadata follow, + a folder whose bare name is already taken is left alone). +- **Importing a film already in the pool added a root-level duplicate instead + of a version.** `pooled_with_stem` compared only the filename stem, so the + files of a grouped movie (`Folder - 3D HSBS.mkv`) never matched the film's + identity and the import wrote `Title (rok) [tmdbid].mkv` beside the folder. + Matching is now by movie identity (flat file, any version of a group, or a + 3D-tagged copy). When the batch import finds such a film it asks **"Přidat + jako verzi" / "Nahradit celý film novým" / "Přeskočit"** (no "keep both as + separate films" — same TMDb id means one movie record, so that's just a + duplicate) and, for "Přidat jako verzi", asks for each file's popisek + (pre-filled from its 3D formát). `import_movie(on_conflict="version", + version_label=…)` does the adding; `"replace"` removes a grouped movie as a + whole rather than one of its files, and "suffix" no longer lands beside a + folder of the same identity. +- Repair for an already-created root duplicate: **Nástroje → Kontrola duplicit** + flags it (same TMDb id) and **"Sloučit jako verzi do…"** folds it into the + group. + ### Changed - **Tags come from TMDb, not NFO.** *Filmy → "Načíst žánry z TMDb"* (`FileManager.refresh_tags_from_tmdb`, `tmdb.movie_details`) fetches @@ -269,6 +439,48 @@ Each version entry uses these sections (include only those that apply): offers to delete **empty** pool folders that are no longer a library, and lists non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` / `remove_empty_pool_folder`. +- **Adding a movie version in a `mixed` library silently broke the movie.** + Its version folder and a series folder are both `Title (rok) [tmdbid]/` — + indistinguishable by name — and a `mixed` library's subfolders used to be + read as series unconditionally (`load_pool_movies` only scanned that + library's **top-level** files as movies). So the movie vanished from the + Film list and reappeared, mis-parsed, as a "series" — no longer reachable + via *Upravit film*. Told apart by **shape** now instead: a series always + nests its episodes in a `Season NN`/`Specials` folder, a movie's version + folder never does (`series.looks_like_series_folder`). A `mixed` library's + canonical-shaped subfolder without one is read as a movie by + `load_pool_movies`/`is_movie_group_member`, and skipped by `scan_series` + (`skip_movie_folders=True` there, so a `movie`-and-`series` pair with the + same identity can sit side by side without either one leaking into the + other's listing) — no manual per-item type switch needed. + +### Dependencies +- **Poetry and pyenv are replaced by uv.** `pyproject.toml` was recreated with + `uv init` / `uv add` (no `[tool.poetry]`, no `[build-system]`), `poetry.lock` + is replaced by `uv.lock`, and `.venv` is built on a uv-managed Python 3.14. + Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md` + §11). Dependency lower bounds moved up to the versions uv resolved; Poetry's + upper bounds (`<7.0.0`, …) are gone. +- **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3, + python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the + whole codebase was brought in line with it — see *Changed* below. + +### Changed +- **Codebase cleaned up for ruff 0.17's default rules** (`uv run ruff check` + passes). Mostly mechanical: PEP 604/585 annotations (`X | None`, `list[...]` + instead of `Optional`/`List`), sorted imports, stale `# noqa` removed, unused + unpacked variables prefixed with `_`, silent `try/except/pass` blocks + rewritten as `contextlib.suppress`, dialog class-level tables in + `src/ui/qt_app.py` annotated `ClassVar`. `scripts/tipy_dne.py` is now + executable (it has a shebang). +- **Three blanket `except Exception` handlers are narrowed** — anything outside + the listed types now propagates instead of being swallowed: + `config.load_global_config` (`OSError`/`ValueError`/`TypeError`), + the ffprobe resolution lookup in `media_utils` + (`OSError`/`SubprocessError`/`ValueError`), and the window-geometry restore + in `qt_app` (`ValueError`/`AttributeError`). +- User-facing "missing packages" hints (ČSFD) now say `uv sync` instead of + `poetry install`. ## 1.12.0 — 2026-08-31 diff --git a/Curator.py b/Curator.py index 6284b00..963ef5c 100644 --- a/Curator.py +++ b/Curator.py @@ -1,7 +1,9 @@ # Imports -from src.ui.qt_app import run +import contextlib + from src.core.file_manager import FileManager from src.core.tag_manager import TagManager +from src.ui.qt_app import run def _use_system_certificates() -> None: @@ -11,11 +13,10 @@ def _use_system_certificates() -> None: Windows trust store but not in certifi's bundle. Best-effort; ignored if the optional `truststore` package is unavailable. """ - try: + with contextlib.suppress(Exception): import truststore + truststore.inject_into_ssl() - except Exception: - pass class State: @@ -32,4 +33,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/DESIGN_DOCUMENT_PYTHON.md b/DESIGN_DOCUMENT_PYTHON.md index cee3199..cdbf6c9 100644 --- a/DESIGN_DOCUMENT_PYTHON.md +++ b/DESIGN_DOCUMENT_PYTHON.md @@ -1,6 +1,6 @@ # Python Development Guidelines -**Document Version:** v11 +**Document Version:** v12 > **Note on Versioning:** > - This document version is independent — reused across projects @@ -129,28 +129,31 @@ Define specific exception types. Use a fail-fast approach — surface errors ear Run before every commit: ```bash -poetry run ruff check -poetry run mypy -poetry run pytest +uv run ruff check +uv run mypy +uv run pytest ``` --- -## 11. Poetry +## 11. uv ```bash -poetry install # Install all dependencies -poetry add # Add runtime dependency -poetry add --group dev # Add dev dependency -poetry remove # Remove dependency -poetry run # Run command in virtualenv +uv sync # Install all dependencies +uv add # Add runtime dependency +uv add --dev # Add dev dependency +uv remove # Remove dependency +uv run # Run command in virtualenv +uv python install # Install a uv-managed Python ``` Never edit `pyproject.toml` directly to add or remove dependencies. -### poetry.lock +The interpreter is a uv-managed Python — no pyenv, no system Python. -`poetry.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible. +### uv.lock + +`uv.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible. --- @@ -162,7 +165,7 @@ project/ ├── src/ # All application modules ├── tests/ # Tests ├── docs/ # Detailed documentation -├── .venv/ # Virtual environment (managed by Poetry) +├── .venv/ # Virtual environment (managed by uv) └── pyproject.toml # Project config and dependencies ``` @@ -193,8 +196,8 @@ When a project is distributed as a standalone executable (no Python required on Build commands: ```bash -poetry run python prebuild.py -poetry run pyinstaller ToolName.spec +uv run python prebuild.py +uv run pyinstaller ToolName.spec ``` > This section applies only to projects that produce standalone executables. Skip for libraries or web services. diff --git a/PROJECT.md b/PROJECT.md index 533dc00..586ceb9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -202,19 +202,94 @@ library manager**. Agreed shape: 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). + Curator does the promotion/demotion itself (*Upravit film → Verze*), and a + version's popisek is renamable too (`rename_movie_version`) — a flat movie + always shows one placeholder row ("Výchozí") in the list even though it + has no on-disk label yet, but renaming only applies once it's grouped. A + popisek left empty (a plain "2D" file with nothing else to call it) is + `DEFAULT_VERSION_LABEL` ("Výchozí"), not an error — everywhere a popisek is + entered (`add_movie_version`, `rename_movie_version`, `merge_as_version`, + the import dialog's version link). Promoting/demoting always carries the + file's *own* current 3D formát over automatically when no popisek is + given for it (`existing_label`/`keep_label` default to `None` → derived + via `naming.parse_3d_format`) — a movie can freely gain and lose versions + in any order (3D-only → +2D → -3D → 3D-only again, …) without a formát + ever getting silently dropped along the way. + The default (plain) version of a grouped movie carries **no label**: + `Folder/Folder.ext` (`file_manager._version_filename`); "Výchozí" is the + UI's name for it. Identity — what decides "is this film already in the + pool" — is the folder name for a grouped movie and the stem minus any + `.3D.` for a flat one (`FileManager._identity_stem`, + `pooled_with_stem`). Importing a film that's already there defaults to + adding a version, never a root-level duplicate, and "keep both as separate + films" is not offered for same-TMDb-id films (they'd share one index record). + **Parts** (one film split across files, Jellyfin "stacking") are versions + with a `partN` popisek, written as `Folder-partN.ext` instead of + `Folder - popisek.ext` (`file_manager._version_filename`, + `movie.normalize_part_label`); a film with parts always lives in its own + folder. Parts and versions in one folder are not combined yet. + *Upravit film* presents this as one "Média filmu" section: flat + ("Samostatný film") until a second medium arrives, then a folder whose + mode is switched between versions and parts (`movie_group_mode`, + `set_movie_group_mode`); parts are reordered with ↑/↓ (`move_movie_part`). + Renames after a relabel go through `File.save_location`, never a plain + `save_metadata`, so a stale sibling can't overwrite shared metadata. + Custom per-movie attributes are no longer editable in the GUI; stored ones + still feed the miniDLNA filename templates. + The table always shows one row per movie (`visible_movies`), marked `▣` / + `Film · N verze` when it has several; shared metadata lives on the movie + record (index v2), so every version reads the same. Accidental duplicates + (same tmdbid / title+year / file size as separate movies) are found by + *Nástroje → Kontrola duplicit* and folded in via `merge_as_version`. + **Not yet supported:** renaming/moving/retitling a grouped movie (remove + the version or edit files directly). + Works in `mixed` libraries too, disambiguated from a series by **shape**: + a movie's version folder and a series folder are both + `Title (rok) [tmdbid]/`, but a series always nests episodes in a + `Season NN`/`Specials` folder and a movie's version folder never does + (`series.looks_like_series_folder`) — `load_pool_movies`/ + `is_movie_group_member` read the shape and `scan_series` + (`skip_movie_folders=True` for `mixed`) skips what it recognises as a + movie, so both kinds can share one identity-named folder pair without + either leaking into the other's listing. A `series`-kind library (which + never holds movies) keeps reading every subfolder as a series regardless + of shape, so a raw not-yet-renamed import can still have loose episode + files at the series root before `Season NN` exists. + Assignable at **import** time too, not just after the fact: picking + several files for the same film in one batch (*Importovat filmy…*), a + row's "🔗 Přiřadit jako verzi…" marks it a version of another row instead + of importing it as its own movie — the earlier attempt at this (a 3D + formát combo per row) was rejected because a lone row has nothing to + attach the version *to* and just created a duplicate; picking the target + row explicitly is what that was missing. The popisek is pre-filled from + the filename (`naming.three_d_format_from_filename`) the same way + `year_from_filename` pre-fills the year. Import runs the target rows + first, then folds each linked row onto its target via + `add_movie_version`. The reverse case — a film whose only file *is* 3D, + with nothing to link it to — gets its own **3D formát combo per row** + instead (`import_movie(..., format_3d=…)`, same as *Upravit film*); + disabled the moment the row is linked as a version or gains one as a + follower, since a grouped movie's 3D-ness lives on the version's popisek, + not on the row that started the group. - Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {série}.ext` (season 0 → `Specials`). The episode filename is the code + the series title only — the release string is dropped (identity is the code; Jellyfin fetches episode metadata from TMDb via the code + `[tmdbid]`). Episode numbers are recognised from the filename and editable at import / in the series GUI; *Seriály → "Napravit názvy souborů…"* bulk-fixes the whole pool. + - **Episodes split into physical parts** (an old recording cut into + several files) get Jellyfin's own `.ptN` stacking suffix — + `SxxEyy - {série}.ptN.ext` — recognised from *cd/dvd/part/pt/disc/disk* + + a number in the source filename (`Show.S01E02.pt2.mkv`, + `Show S01E01-part-1.mkv`, …) or typed by hand at import as `S01E02P3` + (`src.core.series.parse_episode_code`/`parse_episode_part`). Distinct from + a multi-episode file (`E01-E02`, one file = several episodes) — a part is + the opposite: several files = one episode. + - **Removing a series is scoped to the tree selection**: *Přehled seriálů → + "Odebrat z poolu…"* deletes the series, a single season, or a single + episode, whichever is selected (`FileManager.remove_series`/ + `remove_season`/`remove_episodes`) — series still carry no index metadata, + so this is always just deleting files/folders from disk. - **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by `tmdbid` (yearless title search picks the wrong film, hence the earlier "weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when @@ -316,9 +391,12 @@ Note: libraries hold **flat** files for now (`pool//Title (rok) - Scanning **[done]**: a `mixed` library's top-level files load as movies (`load_pool_movies`), its subfolders as series (`load_series` / - `series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`). - Both are kind-aware over the unified `libraries` list. The library filter / - table already surfaces mixed libraries. + `series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`) — + *except* a canonical-shaped subfolder with no `Season NN`/`Specials` inside + it, which is a movie's version folder instead and loads as a movie too (see + "Multiple movie versions" above). Both are kind-aware over the unified + `libraries` list. The library filter / table already surfaces mixed + libraries. - miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as usual; series in its subfolders have no genre tags, so they're hardcopied straight into `output/Seriály//…` instead diff --git a/README.md b/README.md index b6118de..6838187 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ hardlink-tree machinery is inherited and extended into a full library workflow. ## Running ```bash -poetry install -poetry run python Curator.py +uv sync +uv run python Curator.py ``` The GUI is built with **PySide6**. @@ -46,15 +46,15 @@ The GUI is built with **PySide6**. ## Development ```bash -poetry run pytest # tests -poetry run ruff check # lint / format -poetry run mypy # type checking +uv run pytest # tests +uv run ruff check # lint / format +uv run mypy # type checking ``` ## Building a standalone executable ```bash -poetry run pyinstaller Curator.spec +uv run pyinstaller Curator.spec ``` The resulting `dist/Curator` is the distribution artifact. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b486e79 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,42 @@ +# Curator documentation + +Curator is a PySide6 desktop application that manages a Jellyfin-first movie and +series pool: it imports files, identifies them through TMDb, groups multiple +versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA / +Filmotéka tree. + +- [Architecture](architecture.md) — layers, on-disk layout, the pool index and the + rules about versions, series and subtitles. Read this first. +- [Module reference](#module-reference) — one document per source module, with a + description of the module and of every class and function. + +## Module reference + +| Source | Purpose | +|--------|---------| +| [`Curator.py`](modules/entry-point.md) | Application entry point. | +| [`src/_version.py`](modules/_version.md) | Auto-generated — do not edit manually. | +| [`src/constants.py`](modules/constants.md) | Application-level constants for Curator (build/runtime metadata). | +| [`src/core/config.py`](modules/core.config.md) | Configuration for Curator. | +| [`src/core/csfd.py`](modules/core.csfd.md) | ČSFD.cz scraper — a *title bridge* to TMDb. | +| [`src/core/file.py`](modules/core.file.md) | The `File` class: one playable movie file as the UI sees it. | +| [`src/core/file_manager.py`](modules/core.file_manager.md) | `FileManager`: the application's service layer. | +| [`src/core/hardlink_manager.py`](modules/core.hardlink_manager.md) | Hardlink Manager for Curator | +| [`src/core/integrity.py`](modules/core.integrity.md) | Video integrity checking via FFmpeg. | +| [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. | +| [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. | +| [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. | +| [`src/core/media_utils.py`](modules/core.media_utils.md) | Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/p` tag. | +| [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. | +| [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. | +| [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. | +| [`src/core/nfo.py`](modules/core.nfo.md) | NFO reader — Curator's metadata source after ČSFD. | +| [`src/core/pool_index.py`](modules/core.pool_index.md) | Unified metadata index for the Curator pool. | +| [`src/core/series.py`](modules/core.series.md) | Series naming + scanning for the Jellyfin TV layout. | +| [`src/core/subtitles.py`](modules/core.subtitles.md) | External subtitle sidecars — attach, discover, keep in sync on rename. | +| [`src/core/tag.py`](modules/core.tag.md) | The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). | +| [`src/core/tag_manager.py`](modules/core.tag_manager.md) | `TagManager`: the registry of all tag categories and their tags. | +| [`src/core/tmdb.py`](modules/core.tmdb.md) | TMDb (The Movie Database) client — Curator's identity source. | +| [`src/core/transforms.py`](modules/core.transforms.md) | Tag-schema value transforms for miniDLNA folder names. | +| [`src/core/utils.py`](modules/core.utils.md) | Small filesystem helpers shared across the core modules. | +| [`src/ui/qt_app.py`](modules/ui.qt_app.md) | PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..978d145 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,196 @@ +# Architecture + +This document describes how Curator is put together: the layers, what owns what +on disk, and the handful of rules the rest of the code relies on. The per-module +API reference is in [`modules/`](README.md). + +## Layers + +``` +Curator.py entry point — certificates, State, run() + │ + ▼ +src/ui/qt_app.py PySide6 GUI: QtApp (main window) + dialogs + │ never touches the filesystem directly + ▼ +src/core/file_manager.py FileManager — the service layer + │ owns: filelist, pool index, library config, global config + │ + ├── domain model file.py (File), movie.py (Movie, MediaFile), + │ series.py (Series, Episode), tag.py, tag_manager.py + ├── persistence pool_index.py (.Curator.!index), config.py + │ legacy_meta.py (reads pre-index layouts) + ├── naming naming.py (movie stems, 3D), series.py (SxxEyy), + │ subtitles.py (sidecar names), movie.identity_key + ├── external services tmdb.py, csfd.py, jellyfin.py, nfo.py (read-only) + └── output and checks hardlink_manager.py (miniDLNA / Filmotéka), + transforms.py, integrity.py (ffmpeg), migrate.py +``` + +Dependencies only point downwards. The GUI calls `FileManager` methods and +receives plain objects back; the core modules never import Qt. + +### Entry point + +`Curator.py` builds a `State` (one `TagManager` and one `FileManager`) and hands +both to `src.ui.qt_app.run`. Before that it asks the OS certificate store for +HTTPS, because ČSFD and TMDb are reached from behind corporate proxies. + +### GUI + +`QtApp` is the main window: the menu bar, a tag sidebar (an AND filter over the +movie list), the movie and series table, a detail panel and the status bar. Each +menu action is a method on `QtApp`; most of them open a dialog and then call one +`FileManager` method. The dialogs are separate classes in the same module — +import, edit, duplicate check, series overview, library organiser and so on. + +### Service layer + +`FileManager` is the only object that changes the pool. Everything the user can +do maps onto one of its public methods, for example `import_movie`, +`add_movie_version`, `remove_movie_version`, `merge_as_version`, +`apply_movie_edits`, `import_episodes`, the miniDLNA scope methods (`minidlna_scope`, +`minidlna_prune_targets`) and `find_duplicates`. Methods that change files also keep the pool index and the +in-memory `filelist` in step. + +## What lives where on disk + +The pool is one folder. Each top-level folder inside it is a **library**, and each +library has a **kind** that decides how Curator treats it: + +| Kind | Holds | Layout | +|------|-------|--------| +| `movie` | single-file movies and grouped movies | `Title (rok) [tmdbid].ext` or `Title (rok) [tmdbid]/…` | +| `series` | series trees | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` | +| `mixed` | movies and series in one folder | movies flat or in a movie folder, series folders with `Season NN` inside | +| `homevideo` | files without TMDb ids | mirrored as-is | + +The default pool has `Filmy` (movie) and `Seriály` (series). Library +configuration lives in the pool index, not in the global config. + +Two more files sit in the pool root: + +- `.Curator.!index` — the pool index (JSON, version 2). See below. +- nothing else: there are no per-file sidecars. Curator's own metadata is in the + index; Jellyfin writes its NFO and artwork next to the media. + +Outside the pool, the Filmotéka / miniDLNA output is a tree of hardlinks made by +`hardlink_manager`. It never holds metadata of its own. + +## The pool index + +`pool_index.py` keeps one JSON document per pool: + +```json +{ + "version": 2, + "settings": { "libraries": [...], "tag_schema": [...], "minidlna_libraries": null }, + "movies": { + "tmdb-19995": { + "title": "Avatar", "year": "2009", "tmdb_id": 19995, + "library": "Filmy", + "tags": ["Žánr/Sci-Fi"], "csfd_url": null, "attributes": {}, "date": null, + "media": [ + {"role": "video", "label": "", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995].mkv"}, + {"role": "video", "label": "3D HSBS", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"} + ] + } + } +} +``` + +The key of a movie record is its **identity**: + +- `tmdb-` when the TMDb id is known; +- otherwise `name-` built from the title and year, with diacritics folded + (`identity_key` in `movie.py`). + +Everything about a film — title, tags, ČSFD link, attributes, date added — is on +that one record. Its `media` list holds every playable file of the film. A version +therefore never carries metadata of its own, and removing one version never loses +the metadata of the others. + +Version 1 of the index was keyed by file path. `PoolIndex.load` migrates it on +first open: metadata only, no file is moved, and the old file is kept as +`.Curator.!index.v1.bak`. + +## Movies and versions + +A film starts as one flat file. Adding a second file for the same film turns it +into a **grouped movie**: a folder named after the film, with every file in it +named after that folder. + +``` +Filmy/ + Avatar (2009) [tmdbid-19995]/ + Avatar (2009) [tmdbid-19995].mkv ← default version, no label + Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv ← version with popisek "3D HSBS" + Avatar (2009) [tmdbid-19995] - 4K.mkv + Avatar (2009) [tmdbid-19995].cs.srt ← subtitles follow their video +``` + +Rules that the code depends on: + +- **Default version.** The plain version has no label. In the UI it is shown as + "Výchozí". A flat movie that has been promoted keeps its 3D formát, if it had one, + as its label; otherwise it becomes the bare default. +- **3D formát.** A version whose popisek is `3D ` is 3D; a flat movie + carries the same information as `.3D.` in its name. Promotion and + demotion carry the formát across, so a movie that ends up with only a 3D version + is flat again with its tag intact. +- **Demotion.** When one version is left, the movie goes back to a flat file named + after the folder (plus the 3D tag of the survivor, if any) and the folder is removed. +- **Identity.** A file belongs to the film whose identity matches its folder name + (grouped) or its own stem minus any `.3D.` tag (flat). Duplicate checking, + import collision handling and index lookups all use this rule. +- **Mixed libraries.** A movie's folder and a series folder have the same outer + name. They are told apart by content: a series always nests episodes in a + `Season NN` or `Specials` subfolder, a movie folder never does + (`series.looks_like_series_folder`). + +## Series + +A series is a folder with `Season NN` (and `Specials` for season 0) subfolders. +Episodes are named `SxxEyy - Series.ext`; an episode split across several physical +files gets Jellyfin's stacking suffix `.ptN`. Series carry no index record: they are +scanned from disk each time (`series.scan_series`), so deleting an episode file is +the whole of removing it. + +## Subtitles + +Subtitles are sidecar files that share the video's stem, with an optional language +and flag: `Name.cs.srt`, `Name.cs.forced.srt`. They are not index entries. Every +operation that renames or moves a video calls `subtitles.rename_subtitles_with`, +so the sidecars keep their match. + +## Output and external services + +- **miniDLNA / Filmotéka** — `FileManager.minidlna_scope` decides which libraries + take part; `HardlinkManager` builds the genre tree and mirrors the copy-as-is + libraries. Libraries left out of scope have their old links swept on the next run. +- **TMDb** (`tmdb.py`) — the identity source. Turns a TMDb id or a title/year search + into a canonical title and year, and supplies genres and years for tags. +- **ČSFD** (`csfd.py`) — a bridge for Czech titles: filename → ČSFD film → original + title and year → TMDb. +- **Jellyfin** (`jellyfin.py`) — library scan trigger and a read-back of what + Jellyfin sees, used to verify the pool. +- **NFO** (`nfo.py`) — read-only metadata written by Jellyfin, used to fill fields + of files imported without them. + +## Configuration + +Two stores, by responsibility (see `config.py`): + +- the **global config** (`.Curator.!gtag`, next to `Curator.py`) holds application + state only — window geometry, recent folders and the pool and output paths; +- the **pool index** holds everything that describes a pool — libraries, tag schema, + miniDLNA selection and all movie records. + +Secrets come from `.env`: `TMDB_API_KEY`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`, +and an optional debug flag read by `constants.py`. + +## Testing + +Tests live in `tests/` and run with pytest. The core modules are tested directly +against temporary pools; the Qt dialogs are exercised headless +(`QT_QPA_PLATFORM=offscreen`) through their methods. diff --git a/docs/modules/_version.md b/docs/modules/_version.md new file mode 100644 index 0000000..790b452 --- /dev/null +++ b/docs/modules/_version.md @@ -0,0 +1,5 @@ +# `src/_version.py` + +Auto-generated — do not edit manually. + +Source: [`src/_version.py`](../../src/_version.py) diff --git a/docs/modules/constants.md b/docs/modules/constants.md new file mode 100644 index 0000000..c0800a5 --- /dev/null +++ b/docs/modules/constants.md @@ -0,0 +1,34 @@ +# `src/constants.py` + +Application-level constants for Curator (build/runtime metadata). + +This module derives the app version and debug flag. The version is loaded from +`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for +frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`). + +It is the single source of truth for app metadata used across the GUI and build +tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`). + +Source: [`src/constants.py`](../../src/constants.py) + +## Functions + +### `get_version` + +*function · line 26* + +```python +get_version() -> str +``` + +Return the project version (pyproject → _version.py → unknown). + +### `get_debug_mode` + +*function · line 55* + +```python +get_debug_mode() -> bool +``` + +Return True when ENV_DEBUG is set to a truthy value in the environment. diff --git a/docs/modules/core.config.md b/docs/modules/core.config.md new file mode 100644 index 0000000..c4e8d2e --- /dev/null +++ b/docs/modules/core.config.md @@ -0,0 +1,57 @@ +# `src/core/config.py` + +Configuration for Curator. + +Two stores, by responsibility: + +1. Global config (`.Curator.!gtag` next to Curator.py) — **application data + only**: window state, MRU folders, and the pointers to where the pool and the + Filmotéka output live (`pool_dir` / `filmoteka_dir`). Those pointers must + stay here because the pool index is found *through* them. +2. Pool index (`/.Curator.!index`, see `pool_index.py`) — everything + describing a particular library: per-movie metadata **and** library-level + settings (`tag_schema`, `copyasis_folders`). It travels with the pool. + +Source: [`src/core/config.py`](../../src/core/config.py) + +## Functions + +### `load_global_config` + +*function · line 68* + +```python +load_global_config() -> dict +``` + +Load global application config + +### `save_global_config` + +*function · line 84* + +```python +save_global_config(cfg: dict) +``` + +Save global application config + +### `load_config` + +*function · line 94* + +```python +load_config() +``` + +Legacy function - returns global config + +### `save_config` + +*function · line 99* + +```python +save_config(cfg: dict) +``` + +Legacy function - saves global config diff --git a/docs/modules/core.csfd.md b/docs/modules/core.csfd.md new file mode 100644 index 0000000..6fdd6d1 --- /dev/null +++ b/docs/modules/core.csfd.md @@ -0,0 +1,251 @@ +# `src/core/csfd.py` + +ČSFD.cz scraper — a *title bridge* to TMDb. + +Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech +distribution title is unreliable (remakes, localized names, "weird" hits). +ČSFD, on the other hand, indexes Czech release names well **and** lists a +film's *original* title. So the bridge is:: + + filename → ČSFD search → ČSFD film page → original title + year + → TMDb search → tmdbid + +This module only scrapes ČSFD; the TMDb half lives in +`src.core.file_manager` (`FileManager.resolve_via_csfd`). Nothing +here feeds tags/genres — those still come from TMDb via Jellyfin/NFO. + +ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain +request gets a 200 with a JS challenge instead of the movie. We detect it, +solve the SHA-256 PoW the way the bundled worker JS does, and replay the +request through a shared `requests.Session` (so a batch pays the PoW +cost only once). + +Source: [`src/core/csfd.py`](../../src/core/csfd.py) + +## Classes + +### `CsfdError` + +*class · line 62* + +```python +class CsfdError(RuntimeError): +``` + +A ČSFD lookup could not be completed (deps missing, network, anti-bot). + +### `CsfdFilm` + +*class · line 67* + +```python +class CsfdFilm: +``` + +The slice of a ČSFD film page Curator needs to reach a TMDb id. + +#### `CsfdFilm.tmdb_query` + +*property · line 77* + +```python +tmdb_query -> tuple[str, Optional[str]] +``` + +`(title, year)` best suited for a TMDb search — original title first. + +## Functions + +### `dependencies_available` + +*function · line 82* + +```python +dependencies_available() -> bool +``` + +True when `requests` + `beautifulsoup4` are importable. + +### `_check_dependencies` + +*function · line 87* + +```python +_check_dependencies() -> None +``` + +Raise `CsfdError` when the optional `requests` / `beautifulsoup4` packages are missing (they are needed only for ČSFD scraping). + +### `_extract_csfd_id` + +*function · line 95* + +```python +_extract_csfd_id(url: str) -> Optional[int] +``` + +Pull the numeric film id out of a ČSFD URL, or `None`. + +### `_extract_json_blob` + +*function · line 105* + +```python +_extract_json_blob(html: str, element_id: str) +``` + +Return the parsed JSON from an Anubis `', html, - re.S, + re.DOTALL, ) if not match: return None @@ -190,19 +190,20 @@ def _get_page(session, url: str): # Page parsing # --------------------------------------------------------------------------- -def _parse_json_ld(soup) -> tuple[str, Optional[str]]: + +def _parse_json_ld(soup) -> tuple[str, str | None]: """``(name, year)`` from the film page's JSON-LD (name is the Czech title).""" for script in soup.find_all("script", type="application/ld+json"): try: data = json.loads(script.string or "") - except (json.JSONDecodeError, TypeError): + 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 + year: str | None = None for key in ("dateCreated", "datePublished"): match = re.search(r"(19|20)\d{2}", str(data.get(key) or "")) if match: @@ -212,7 +213,7 @@ def _parse_json_ld(soup) -> tuple[str, Optional[str]]: return "", None -def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]: +def _parse_origin(soup) -> tuple[str | None, str | None]: """``(origin_country, year)`` from the ``.origin`` line. ČSFD separates values with inline bullet ```` elements (no commas), @@ -224,16 +225,15 @@ def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]: 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 + country: str | None = None + year: str | None = None for token in tokens: if not token: continue if year is None and re.fullmatch(r"(19|20)\d{2}", token): year = token continue - if (country is None and not token[0].isdigit() - and re.search(r"[^\W\d_]", token)): + if country is None and not token[0].isdigit() and re.search(r"[^\W\d_]", token): country = token.split("/")[0].strip() return country, year @@ -242,7 +242,7 @@ def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]: _CZ_ORIGIN_ALIASES = {"česko", "československo"} -def _parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]: +def _parse_original_title(soup, origin_country: str | None) -> str | None: """The film's original title from ``.film-names`` — or ``None`` to use the CZ title. Each ``
  • `` carries a country/language flag (``img.flag`` ``title``) and a @@ -267,8 +267,9 @@ def _parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]: 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): + if here == wanted or ( + wanted in _CZ_ORIGIN_ALIASES and here in _CZ_ORIGIN_ALIASES + ): return text return None @@ -352,17 +353,19 @@ def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]: 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), - )) + 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]: +def find_csfd_url(query: str, session=None) -> str | None: """Return the first ČSFD film URL matching ``query``, or ``None``. ČSFD's full-text search treats a trailing year as a search term (not a @@ -382,13 +385,60 @@ def find_csfd_url(query: str, session=None) -> Optional[str]: # 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", + "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) @@ -407,7 +457,7 @@ def clean_filename_to_query(filename: str) -> str: tokens = [t for t in re.split(r"[.\s_]+", stem) if t] title_words: list[str] = [] - year: Optional[str] = None + year: str | None = None for token in tokens: bare = token.strip("()[]{}") if _YEAR_RE.match(bare): diff --git a/src/core/file.py b/src/core/file.py index ea1f626..47fedb6 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -1,4 +1,6 @@ +import re from pathlib import Path + from .tag import Tag @@ -46,8 +48,12 @@ class File: 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) + 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) @@ -58,8 +64,20 @@ class File: 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:] + part = ( + re.fullmatch( + rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})", + self.file_path.stem, + re.IGNORECASE, + ) + if grouped + else None + ) + if part: + # one part of a film split across files: "-part2" + label = f"part{int(part.group(1))}" + elif grouped and self.file_path.stem.startswith(f"{parent} - "): + label = self.file_path.stem[len(parent) + 3 :] elif parse_3d_format(self.file_path.stem): label = parse_3d_format(self.file_path.stem) or "" return identity_key(tmdb_id, title, year), title, year, tmdb_id, label @@ -109,7 +127,9 @@ class File: "new": self.new, "ignored": self.ignored, # ukládáme full_path tagů - "tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], + "tags": [ + tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags + ], # which of the tags were derived automatically (from the NFO) "auto_tags": sorted(self.auto_tag_paths), # date může být None @@ -137,7 +157,9 @@ class File: raw_meta = data.get("meta") if raw_meta is None: # legacy: pull year/rating out of the old csfd cache old = data.get("csfd_cache") or {} - raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None + raw_meta = ( + {"year": old.get("year"), "rating": old.get("rating")} if old else None + ) self.meta = dict(raw_meta) if raw_meta else None if not self.tagmanager: @@ -198,6 +220,7 @@ class File: if self.added: try: from datetime import datetime + return datetime.fromisoformat(self.added).timestamp() except ValueError: pass @@ -223,20 +246,23 @@ class File: if year is None: # Last resort: recover it from a canonical "Title (YYYY)" filename. from .naming import parse_pool_stem + parsed = parse_pool_stem(self.file_path.stem) if parsed is not None: year = parsed[1] rating = cache.get("rating") # user attributes first; core fields take precedence over same-named keys context = dict(self.attributes) - context.update({ - "title": self.title or self.file_path.stem, - "year": "" if year is None else str(year), - "rating": "" if rating is None else rating, - "ext": self.file_path.suffix, - "stem": self.file_path.stem, - "filename": self.filename, - }) + context.update( + { + "title": self.title or self.file_path.stem, + "year": "" if year is None else str(year), + "rating": "" if rating is None else rating, + "ext": self.file_path.suffix, + "stem": self.file_path.stem, + "filename": self.filename, + } + ) return context def set_attribute(self, key: str, value: str | None) -> None: @@ -250,6 +276,19 @@ class File: self.attributes.pop(key, None) self.save_metadata() + def save_location(self) -> None: + """Persist a rename/move of this file without touching shared metadata. + + Every version of a film holds its own in-memory copy of the movie + record's shared fields (title, tags, ČSFD, …). A sibling loaded before + someone changed those fields would write its stale copy back on a plain + :meth:`save_metadata`. Re-reading the record first means only this + file's own media entry — path and popisek — actually changes. + """ + if self.index is not None and self.index.find_movie_by_media(self.file_path): + self.get_metadata() + self.save_metadata() + def delete_metadata(self) -> None: """Drop this file from its movie record; the last one takes the record. @@ -316,6 +355,7 @@ class File: if schema is None: from .config import DEFAULT_TAG_SCHEMA + schema = DEFAULT_TAG_SCHEMA self.meta = {"year": nfo.year, "rating": nfo.rating} @@ -331,8 +371,11 @@ class File: continue # user-only category category = entry["category"] for value in nfo.field_values(field): - tag_obj = (self.tagmanager.add_tag(category, value) - if self.tagmanager else Tag(category, value)) + tag_obj = ( + self.tagmanager.add_tag(category, value) + if self.tagmanager + else Tag(category, value) + ) if tag_obj not in self.tags: self.tags.append(tag_obj) tags_added.append(tag_obj.full_path) @@ -347,6 +390,7 @@ class File: def add_tag(self, tag): # tag může být Tag nebo string from .tag import Tag as TagClass + if isinstance(tag, str): if "/" in tag and self.tagmanager: category, name = tag.split("/", 1) @@ -375,4 +419,4 @@ class File: return if tag_obj in self.tags: self.tags.remove(tag_obj) - self.save_metadata() \ No newline at end of file + self.save_metadata() diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 8f71c30..314a788 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -1,20 +1,26 @@ -from pathlib import Path -from datetime import datetime -from collections import Counter +import re import shutil -from .file import File -from .tag_manager import TagManager -from .pool_index import PoolIndex -from .utils import list_files -from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult -from .naming import ( - canonical_pool_stem, is_canonical_pool_stem, is_valid_year, parse_3d_format, - parse_pool_stem, parse_tmdb_id) +from collections import Counter +from collections.abc import Callable, Iterable, Mapping +from datetime import datetime +from pathlib import Path + +from src.core.config import DEFAULT_TAG_SCHEMA, load_global_config, save_global_config + from . import subtitles -from typing import Callable, Iterable -from src.core.config import ( - load_global_config, save_global_config, DEFAULT_TAG_SCHEMA +from .file import File +from .integrity import IntegrityResult, check_video_integrity, ffmpeg_available +from .naming import ( + canonical_pool_stem, + is_canonical_pool_stem, + is_valid_year, + parse_3d_format, + parse_pool_stem, + parse_tmdb_id, ) +from .pool_index import PoolIndex +from .tag_manager import TagManager +from .utils import list_files # Top-level folders inside the managed pool POOL_MOVIES = "Filmy" @@ -48,11 +54,60 @@ METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index") # Sidecars Jellyfin writes next to a movie (NFO + artwork/subtitles). These are # not movies and must be skipped when scanning a library. SIDECAR_SUFFIXES = ( - ".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", - ".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt", + ".nfo", + ".jpg", + ".jpeg", + ".png", + ".webp", + ".bmp", + ".gif", + ".tbn", + ".srt", + ".ass", + ".ssa", + ".sub", + ".idx", + ".vtt", ) +def _version_filename(stem: str, label: str, suffix: str) -> str: + """Filename of one version of a movie folder. + + The default version ("Výchozí", or no label at all) carries no label — + it's just ````, the same name the movie had flat. A part + label (``part2``, ``CD2``, … see :func:`movie.normalize_part_label`) is + ``-part``. Any other version is `` -