From 79c9e34a5d39682a6ca00087decb46faadaf903e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Tue, 8 Sep 2026 07:37:40 +0200 Subject: [PATCH] Add edit dialogs, library organizer, and TMDb pool migration --- CHANGELOG.md | 61 ++++ PROJECT.md | 112 ++++++- src/core/file.py | 6 + src/core/file_manager.py | 308 ++++++++++++++++-- src/core/migrate.py | 62 ++++ src/core/series.py | 30 +- src/ui/qt_app.py | 617 +++++++++++++++++++++++++++++++++---- tests/test_file_manager.py | 138 ++++++++- tests/test_migrate.py | 95 ++++++ tests/test_series.py | 52 +++- 10 files changed, 1371 insertions(+), 110 deletions(-) create mode 100644 src/core/migrate.py create mode 100644 tests/test_migrate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 075acd6..863b97c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,67 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +### Fixed +- **Movies can live in mixed libraries.** The import/edit/move dialogs and the + library filter offered only `movie`-kind libraries (falling back to a default + "Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put + a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**), + and `load_pool_movies` scans a mixed library's **top-level** files as movies + (its subfolders are series). Movies can be imported into / moved between mixed + libraries. +- **Cleanup of leftover library folders.** After editing libraries, Curator now + 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`. + +### Added +- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at + import (new 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. +- **Series import into `mixed` libraries.** `series_libraries` (import target + + `load_series` scan) now covers kind `series` **and** `mixed`, so episodes can be + imported into a mixed library and show up in the series overview/editor. + +### Changed +- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**. + (Genre tags come from the NFO; manual tag editing isn't needed in the Jellyfin + flow.) +- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext` + (was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The + `SxxEyy` code is stripped from the original before re-applying, so repeated + renames don't double it (`series.episode_body`); `episode_filename` / + `is_canonical_episode` and the series import/rename/edit paths follow. + +### Added +- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's + top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed / + homevideo** — stored in a unified ``libraries`` setting in the pool index + (`FileManager.libraries` / `set_libraries`; the legacy `movie_libraries` / + `copyasis_folders` are now derived from it and synthesised for old pools). + Setting the pool creates a folder for every library. Replaces the separate + "Knihovny (rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and + **homevideo** can be defined now; their scanning/generation lands in the next + phases (for now only movie + series libraries are processed). +- **Pool migration to TMDb** (phase 6; `src/core/migrate.py`, *Nástroje → + "Spárovat pool s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) + against TMDb by title + year and proposes an id, filling a missing year from + TMDb. Classification is conservative — **jisté** (exact diacritics-insensitive + title + matching year, unique), **nejednoznačné** (pick from candidates), + **bez shody**. A review dialog lets you apply just the confident ones or the + ones you pick; renames to `Title (rok) [tmdbid].ext`. Already-migrated files are + skipped, so it is re-runnable. `FileManager.match_pool_to_tmdb` / + `assign_tmdb_id` drive it. +- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click a row) + opens one dialog for every Curator-owned parameter of a movie — title, year, + TMDb ID (with "Dohledat z TMDb"), library, date, free-form attributes and tags + — and applies them at once (rename `Title (rok) [tmdbid].ext`, move between + libraries, set metadata) via `FileManager.apply_movie_edits`. In *Seriály → + "Přehled seriálů…"* a series now opens an editor (double-click / "Upravit + seriál…"): edit the series' title/year/TMDb ID/library **and each episode's + season/episode number**, which renames the folder and lays the episodes out as + `Series SxxEyy.ext` (`FileManager.apply_series_edits`). The double-click on a + movie now edits it; "Otevřít" (playback) stays in the context menu. + ## 1.12.0 — 2026-08-31 ### Added diff --git a/PROJECT.md b/PROJECT.md index e260011..61a38ff 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -169,33 +169,119 @@ movie table, and one-click Filmotéka generation. Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first library manager**. Agreed shape: -- **Pool = the single Jellyfin library** (merged; no separate output library). - Files are named `Title (rok) [tmdbid-N].ext` — the form Jellyfin matches on. - Libraries (Filmy/Kids/Dokumenty/Anime/Seriály) are physical partitions. +- **Pool = the Jellyfin library set.** Files are named `Title (rok) [tmdbid-N].ext` + — the form Jellyfin matches on. Libraries are physical top-level pool folders, + each with a **kind** (`libraries` setting; managed in *Pool → Knihovny…*): + - `movie` — single-file movies, TMDb naming (done) + - `series` — series/season/episode tree, mirrored 1:1 (done) + - `mixed` — movies **and** series in one folder (Jellyfin "Mixed" type; + supported-but-discouraged by Jellyfin, but low-risk here thanks to the strict + structure, and fully reversible — see decisions). **Scanning TBD (phase 9).** + - `homevideo` — files with no TMDb (Jellyfin "Home videos" type). **TBD (ph. 8).** +- **Target library layout (the user's plan):** `Hlavní` (mixed, incl. kids), + `Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda` + (homevideo). Kids are **not** a separate folder — see decisions. +- **Naming convention:** + - Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. + - Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext` + (season 0 → `Specials`); the episode keeps its original filename after the + `SxxEyy` code. Episode numbers are recognised from the filename; editable in + the series GUI. + - **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by + `tmdbid` (yearless title search picks the wrong film, hence the earlier + "weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when + TMDb has no cs title); last fallback = the original filename. The filename is + always editable in the GUI. - **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to) writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall. - Curator only resolves a **TMDb id → title + year** for naming (`tmdb.py`); - ČSFD is dropped (it exposes no TMDb/IMDb id anyway — verified). + Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is + kept **only** as an optional CZ-title source for the filename (not for tags/ + matching — it exposes no TMDb/IMDb id, verified). +- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link** + (CZ-title source/override); filename hand-editable before/after import. - **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser, but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` + `naming.py`), not from ČSFD tags. - **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections; key + URL in `.env`. NFO on disk stays authoritative. -Phasing: (1) TMDb identity + tmdbid naming + import rework [done]; (2) movie -libraries as physical partitions + import-into / move-between [done]; (3) Jellyfin -API — scan + verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD -removed [done]; (5) series model — SxxEyy layout, import, rename [done]; -(6) migrate the existing 589 movies (title+year → TMDb search). Note: libraries hold **flat** files for now -(`pool//Title (rok) [tmdbid].ext`); per-movie folders are a later -refinement. +### Decisions (locked) + +- **Kids = Jellyfin parental rating, not a folder** (variant B). Kids content + stays in the normal libraries; a child Jellyfin account gets a max-rating limit + with **"block unrated" ON**. Ratings come from TMDb via Jellyfin. Caveat: Czech + certifications are sparse — set the library metadata country to one with fuller + ratings (US) and fix odd cases in Jellyfin (Identify + lock). +- **Mixed libraries are OK and reversible.** Jellyfin discourages them (unreliable + metadata) but our disciplined structure (series = `Season/SxxEyy`, movie = + single file + `[tmdbid]`) gives the scanner clear signals. Because metadata is + **NFO on disk** + **tmdbid in the name**, switching mixed↔separate later is just + moving folders (Jellyfin re-reads NFO, no re-fetch, no loss). +- **Home videos** (Chajda) use Jellyfin's **"Home videos"** content type — no + online lookup, just serves the files. +- **Always match by tmdbid, not by yearless title.** TMDb cs-CZ titles are good + when the right film is matched (lookup by id, or search with a year); a bare + title search trips on remakes/same-name films (verified: Home Alone → 2021 + reboot, Vejška → 2006). Import supplies the id; migration searches with a year. + +### Phasing + +(1) TMDb identity + tmdbid naming + import rework [done]; (2) movie libraries as +physical partitions + import-into / move-between [done]; (3) Jellyfin API — scan + +verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done]; +(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing +pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs + +library organizer (unified `libraries` model with kinds) [done]. +Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs); +**(9) mixed-library scanning + generation** (movies + series in one folder). + +Note: libraries hold **flat** files for now (`pool//Title (rok) +[tmdbid].ext`); per-movie folders are a later refinement. ## Tasks -# (no open tasks — see Done) +### Phase 8 — import without TMDb / without year (homevideo, no-TMDb docs) + +- Relax the import dialog: allow a row with **no year and no tmdbid** (currently + a 4-digit year is mandatory). Pool name then falls back to `Title.ext` + (movies) / `Series/Season NN/SxxEyy.ext` (series), no `[tmdbid]`. +- Drive this by **library kind**: `homevideo` (and no-TMDb rows in `mixed`) + skip the TMDb requirement; `movie` keeps requiring a year (as today). +- `homevideo` library scanning: load its files as items (flat), no TMDb naming, + no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle. +- Tests: import with no year/id lands correctly; homevideo library loads. + +### Phase 9 — mixed-library scanning + generation + +- Scan a `mixed` library by structure: a top-level **single video file = movie**; + a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse + `series.scan_series` for the series part and the flat movie scan for the rest. +- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`, + dispatch by kind); today they read `movie_libraries` / `copyasis_folders`. +- miniDLNA generation + NFO refresh must cover mixed libraries too. +- The library filter / table should surface mixed libraries once loadable. +- Tests: a mixed folder with both a movie and a series loads/generates correctly. + +### Later / optional + +- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good + when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the + Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the + filename in the GUI. The ČSFD **link** is already stored per movie + (`File.csfd_url`, set at import / in the edit dialog) as a pointer for later — + revisit fetching only if TMDb proves insufficient in practice. +- Per-movie folders (`pool//Title (rok) [tmdbid]/…`) instead of flat files. +- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `` in NFO. +- Optional common umbrella folder above libraries (`pool/Média/`) — needs + path-aware `library_of` (currently single-segment). ## Done +- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie + libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD + removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs +- Library organizer (`libraries` unified model with kinds movie/series/mixed/ + homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived - Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus "Přejmenovat dle ČSFD (rok)" to bring older files up to the convention diff --git a/src/core/file.py b/src/core/file.py index 0663d88..0113c86 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -18,6 +18,9 @@ class File: self.date: str | None = None # movie-library fields self.title: str | None = None + # Optional ČSFD film URL — a second database pointer kept alongside the + # TMDb id (which lives in the filename). Stored only, not fetched. + self.csfd_url: str | None = None # ISO timestamp of when the movie was added to the pool (set on import); # None for older/unknown → falls back to the file's ctime. self.added: str | None = None @@ -46,6 +49,7 @@ class File: self.tags = [] self.date = None self.title = None + self.csfd_url = None self.meta = None self.auto_tag_paths = set() self.attributes = {} @@ -62,6 +66,7 @@ class File: # date může být None "date": self.date, "title": self.title, + "csfd_url": self.csfd_url, "attributes": self.attributes, "added": self.added, } @@ -75,6 +80,7 @@ class File: self.tags = [] self.date = data.get("date", None) self.title = data.get("title", None) + self.csfd_url = data.get("csfd_url", None) # provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", []))) self.attributes = dict(data.get("attributes", {})) diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 556a9a1..6f38ff3 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -17,12 +17,23 @@ from src.core.config import ( POOL_MOVIES = "Filmy" POOL_SERIES = "Seriály" +# A pool library (top-level folder) has one of these kinds: +# movie — single-file movies, TMDb naming (Title (rok) [tmdbid].ext) +# series — series/season/episode tree, mirrored as-is (Jellyfin Shows) +# mixed — both movies and series in one folder (Jellyfin "Mixed"; wiring TBD) +# homevideo — files without TMDb (home videos; Jellyfin "Home videos"; wiring TBD) +LIBRARY_KINDS = ("movie", "series", "mixed", "homevideo") +_DEFAULT_LIBRARIES = [ + {"name": POOL_MOVIES, "kind": "movie"}, + {"name": POOL_SERIES, "kind": "series"}, +] + # Library-level settings that live in the pool index (not the global config). +# ``libraries`` is the source of truth; the legacy ``movie_libraries`` / +# ``copyasis_folders`` are kept only to synthesise ``libraries`` for old pools. _LIBRARY_SETTINGS_DEFAULTS = { "tag_schema": DEFAULT_TAG_SCHEMA, "copyasis_folders": [POOL_SERIES], - # Top-level pool folders that hold movies — each is a Jellyfin library - # (physical partition). "Filmy" is the default; users add Kids/Dokumenty/… "movie_libraries": [POOL_MOVIES], } @@ -117,26 +128,100 @@ class FileManager: raise RuntimeError("Pool není nastaven — není kam uložit nastavení.") idx.set_setting(key, value) + @property + def libraries(self) -> list[dict]: + """All pool libraries as ``[{"name", "kind"}, …]`` (source of truth). + + For pools predating the unified model this is synthesised from the legacy + ``movie_libraries`` (→ kind ``movie``) + ``copyasis_folders`` (→ ``series``). + """ + idx = self._ensure_index() + raw = idx.get_setting("libraries", None) if idx is not None else None + if raw is None: + raw = ( + [{"name": n, "kind": "movie"} for n in self._get_setting("movie_libraries")] + + [{"name": n, "kind": "series"} for n in self._get_setting("copyasis_folders")] + ) + out: list[dict] = [] + seen: set[str] = set() + for spec in raw: + name = (spec.get("name") or "").strip() + kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie" + if name and name.lower() not in seen: + out.append({"name": name, "kind": kind}) + seen.add(name.lower()) + return out or list(_DEFAULT_LIBRARIES) + + def set_libraries(self, specs: list[dict]) -> None: + """Replace the library list (list of ``{"name", "kind"}``) and persist it.""" + cleaned: list[dict] = [] + seen: set[str] = set() + for spec in specs: + name = (spec.get("name") or "").strip() + kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie" + if name and name.lower() not in seen: + cleaned.append({"name": name, "kind": kind}) + seen.add(name.lower()) + self._set_setting("libraries", cleaned or list(_DEFAULT_LIBRARIES)) + + def libraries_of_kind(self, *kinds: str) -> list[str]: + """Names of libraries matching any of ``kinds``.""" + return [lib["name"] for lib in self.libraries if lib["kind"] in kinds] + @property def copyasis_folders(self) -> list[str]: - """Names of pool subfolders mirrored 1:1 (copy-as-is) into the output.""" - return self._get_setting("copyasis_folders") + """Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries.""" + return self.libraries_of_kind("series") def set_copyasis_folders(self, names: list[str]) -> None: - """Set the copy-as-is subfolder list and persist it.""" + """Set the series (copy-as-is) libraries, keeping the other kinds.""" cleaned = [n.strip() for n in names if n.strip()] - self._set_setting("copyasis_folders", cleaned) + others = [lib for lib in self.libraries if lib["kind"] != "series"] + self.set_libraries(others + [{"name": n, "kind": "series"} for n in cleaned]) @property def movie_libraries(self) -> list[str]: - """Top-level pool folders holding movies (each a Jellyfin library).""" - libs = self._get_setting("movie_libraries") - return libs if libs else [POOL_MOVIES] + """Pool folders holding single-file movies (kind ``movie``).""" + return self.libraries_of_kind("movie") or [POOL_MOVIES] + + @property + def libraries_accepting_movies(self) -> list[str]: + """Libraries a movie can live in / be assigned to — kind movie or mixed.""" + return self.libraries_of_kind("movie", "mixed") or [POOL_MOVIES] def set_movie_libraries(self, names: list[str]) -> None: - """Set the movie-library list (never empty — falls back to Filmy).""" - cleaned = [n.strip() for n in names if n.strip()] - self._set_setting("movie_libraries", cleaned or [POOL_MOVIES]) + """Set the movie libraries, keeping the other kinds; never empty.""" + cleaned = [n.strip() for n in names if n.strip()] or [POOL_MOVIES] + others = [lib for lib in self.libraries if lib["kind"] != "movie"] + self.set_libraries([{"name": n, "kind": "movie"} for n in cleaned] + others) + + def orphan_pool_folders(self) -> list[tuple[str, bool]]: + """Top-level pool folders that aren't a configured library. + + Returns ``(name, is_empty)`` pairs. Only these are candidates for cleanup; + non-empty ones are reported but never auto-deleted (they may hold media). + """ + pool = self.pool_dir + if not (pool and pool.is_dir()): + return [] + known = {lib["name"] for lib in self.libraries} + orphans: list[tuple[str, bool]] = [] + for entry in sorted(pool.iterdir()): + if entry.is_dir() and entry.name not in known: + orphans.append((entry.name, not any(entry.iterdir()))) + return orphans + + def remove_empty_pool_folder(self, name: str) -> bool: + """Delete an **empty** orphan folder in the pool root. Returns True if removed.""" + pool = self.pool_dir + if not pool: + return False + known = {lib["name"] for lib in self.libraries} + target = pool / name + if (target.is_dir() and name not in known and not any(target.iterdir())): + target.rmdir() + return True + return False def library_of(self, file_obj: File) -> str | None: """Which library (top-level pool folder) a pooled movie lives in.""" @@ -200,8 +285,8 @@ class FileManager: self.global_config["pool_dir"] = str(pool_dir) save_global_config(self.global_config) self._open_index(pool_dir) # settings now readable through the index - for name in self.movie_libraries + self.copyasis_folders: - (pool_dir / name).mkdir(parents=True, exist_ok=True) + for lib in self.libraries: + (pool_dir / lib["name"]).mkdir(parents=True, exist_ok=True) def set_filmoteka_dir(self, filmoteka_dir: Path) -> None: """Set the Filmotéka output folder (generated hardlink tree).""" @@ -222,17 +307,24 @@ class FileManager: return self._open_index(pool) - for library in self.movie_libraries: - lib_dir = pool / library + for lib in self.libraries: + if lib["kind"] not in ("movie", "mixed"): + continue + lib_dir = pool / lib["name"] if not lib_dir.is_dir(): continue - for each in list_files(lib_dir): + # A mixed library keeps movies at the top level and series in + # subfolders (Season NN) → only its top-level files are movies. + if lib["kind"] == "mixed": + candidates = [p for p in sorted(lib_dir.iterdir()) if p.is_file()] + else: + candidates = list_files(lib_dir) + for each in candidates: if each.name.endswith(METADATA_SUFFIXES): continue if each.suffix.lower() in SIDECAR_SUFFIXES: continue - file_obj = File(each, self.tagmanager, index=self.index) - self.filelist.append(file_obj) + self.filelist.append(File(each, self.tagmanager, index=self.index)) def check_data_consistency(self) -> dict: """Compare the pool index against the actual files in the movie libraries. @@ -255,11 +347,15 @@ class FileManager: index_keys = set(index.records.keys()) disk_keys = set() - for library in self.movie_libraries: - lib_dir = pool / library + for lib in self.libraries: + if lib["kind"] not in ("movie", "mixed"): + continue + lib_dir = pool / lib["name"] if not lib_dir.is_dir(): continue - for f in list_files(lib_dir): + files = ([p for p in lib_dir.iterdir() if p.is_file()] + if lib["kind"] == "mixed" else list_files(lib_dir)) + for f in files: if f.name.endswith(METADATA_SUFFIXES): continue if f.suffix.lower() in SIDECAR_SUFFIXES: @@ -421,7 +517,7 @@ class FileManager: self, source: Path, title: str, move: bool = False, on_conflict: str = "suffix", year: int | str | None = None, tmdb_id: int | str | None = None, - library: str | None = None, + library: str | None = None, csfd_url: str | None = None, ) -> File | None: """Bring a video file into a movie library and index its metadata. @@ -487,6 +583,7 @@ class FileManager: file_obj = File(target, self.tagmanager, index=self.index) file_obj.title = safe_title + file_obj.csfd_url = csfd_url or None file_obj.added = datetime.now().isoformat(timespec="seconds") file_obj.save_metadata() @@ -650,7 +747,7 @@ class FileManager: if pool is None: raise RuntimeError("Pool není nastaven.") new_library = new_library.strip() - if new_library not in self.movie_libraries: + if new_library not in self.libraries_accepting_movies: raise ValueError(f"Neznámá knihovna „{new_library}“.") old_path = file_obj.file_path @@ -670,6 +767,123 @@ class FileManager: self.on_files_changed(self.filelist) return file_obj + def apply_movie_edits( + self, file_obj: File, *, title: str, year: int | str | None = None, + tmdb_id: int | str | None = None, library: str | None = None, + date: str | None = None, attributes: dict | None = None, + tags: list[str] | None = None, csfd_url: str | None = None, + ) -> File: + """Apply all editable parameters of a movie at once (rename/move + metadata). + + Renames the file to ``Title (rok) [tmdbid].ext`` (year permitting), moves + it into ``library``, and sets ``title`` / ``date`` / ``attributes`` / + ``tags`` (a full ``["Category/Name", …]`` list; NFO-tag provenance is kept + for the ones that remain). No-ops that don't change the path skip the move. + + Raises: + FileExistsError: the target name is already taken. + """ + pool = self.pool_dir + if pool is None: + raise RuntimeError("Pool není nastaven.") + title = (title or "").strip() or file_obj.file_path.stem + year_s = str(year).strip() if year not in (None, "") else "" + stem = (canonical_pool_stem(title, year_s, tmdb_id) + if is_valid_year(year_s) else title) + lib = ((library or "").strip() + or self.library_of(file_obj) or self.movie_libraries[0]) + old_path = file_obj.file_path + new_path = pool / lib / f"{stem}{old_path.suffix}" + if new_path != old_path: + if new_path.exists(): + raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.") + new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old_path), str(new_path)) + file_obj.relocate(new_path) + + file_obj.title = title + if date is not None: + file_obj.date = date or None + if csfd_url is not None: + file_obj.csfd_url = csfd_url or None + if attributes is not None: + file_obj.attributes = dict(attributes) + if tags is not None: + new_tags: list = [] + seen: set[str] = set() + for full_path in tags: + full_path = full_path.strip() + if "/" not in full_path or full_path in seen: + continue + cat, name = full_path.split("/", 1) + new_tags.append(self.tagmanager.add_tag(cat.strip(), name.strip())) + seen.add(full_path) + file_obj.tags = new_tags + file_obj.auto_tag_paths = {p for p in file_obj.auto_tag_paths if p in seen} + + file_obj.save_metadata() + if self.on_files_changed: + self.on_files_changed(self.filelist) + return file_obj + + def match_pool_to_tmdb( + self, files: list[File] | None = None, + on_progress: Callable[[int, int, File], None] | None = None, + should_cancel: Callable[[], bool] | None = None, + ) -> list: + """Propose a TMDb id for every movie that doesn't have one yet. + + Searches TMDb by each movie's title + year and classifies the result + (see :func:`migrate.classify`). Movies whose filename already carries a + ``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``. + """ + from . import tmdb + from .migrate import MatchProposal, classify + targets = files if files is not None else self.filelist + proposals: list = [] + total = len(targets) + for i, f in enumerate(targets, 1): + if should_cancel is not None and should_cancel(): + break + if parse_tmdb_id(f.file_path.stem) is not None: + if on_progress is not None: + on_progress(i, total, f) + continue # already migrated + ctx = f.name_context() + title = (f.title or ctx.get("title") or f.file_path.stem).strip() + parsed = parse_pool_stem(title) + if parsed is not None: + title = parsed[0] + year = str(ctx.get("year") or "").strip() or None + try: + candidates = tmdb.search_movie(title, year=year, limit=5) + status, chosen = classify(title, year, candidates) + proposals.append(MatchProposal( + f, f.filename, title, year, status, chosen, candidates)) + except tmdb.TmdbError as exc: + proposals.append(MatchProposal( + f, f.filename, title, year, "error", None, [], str(exc))) + if on_progress is not None: + on_progress(i, total, f) + return proposals + + def assign_tmdb_id( + self, file_obj: File, tmdb_id: int | str, year: int | str | None = None + ) -> File: + """Attach a TMDb id to a movie and rename it to the canonical convention. + + ``year`` (typically from the TMDb match) fills a missing local year so the + file becomes ``Title (rok) [tmdbid].ext``. Title/library/tags/date are kept. + """ + ctx = file_obj.name_context() + title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip() + parsed = parse_pool_stem(title) + if parsed is not None: + title = parsed[0] + year_s = str(year).strip() if year not in (None, "") else str(ctx.get("year") or "") + return self.apply_movie_edits( + file_obj, title=title, year=year_s or None, tmdb_id=tmdb_id) + def refresh_tags_from_nfo(self, files: list[File] | None = None) -> dict: """(Re)derive each movie's auto tags from its NFO (written by Jellyfin). @@ -700,8 +914,8 @@ class FileManager: @property def series_libraries(self) -> list[str]: - """Pool folders that hold series (the copy-as-is mirrors, e.g. Seriály).""" - return self.copyasis_folders + """Pool folders that can hold series — kind ``series`` or ``mixed``.""" + return self.libraries_of_kind("series", "mixed") def load_series(self) -> list: """Scan every series library into a list of ``series.Series``.""" @@ -748,14 +962,14 @@ class FileManager: season, ep = code dest_dir = pool / lib / stem / season_folder_name(season) dest_dir.mkdir(parents=True, exist_ok=True) - target = dest_dir / episode_filename(title, season, ep, src.suffix) + target = dest_dir / episode_filename(season, ep, src.stem, src.suffix) if target.exists(): if on_conflict == "skip": continue if on_conflict == "replace": target.unlink() else: # suffix - base = episode_filename(title, season, ep, "") + base = episode_filename(season, ep, src.stem, "") counter = 1 while target.exists(): target = dest_dir / f"{base}_{counter}{src.suffix}" @@ -789,7 +1003,7 @@ class FileManager: continue dest_dir = series.folder / season_folder_name(ep.season) new_path = dest_dir / episode_filename( - series.title, ep.season, ep.episode, ep.path.suffix) + ep.season, ep.episode, ep.path.stem, ep.path.suffix) if new_path == ep.path: continue if new_path.exists(): @@ -805,6 +1019,42 @@ class FileManager: conflicts.append((ep.path.name, str(exc))) return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts} + def apply_series_edits( + self, series, *, title: str, year: int | str | None = None, + tmdb_id: int | str | None = None, library: str | None = None, + ) -> dict: + """Rename/move a series folder and re-title its episodes to match. + + Renames the series folder to ``Title (rok) [tmdbid]`` (moving it into + ``library`` if changed), then re-derives the series and renames every + episode to ``NewTitle SxxEyy.ext`` in the right Season folder. + + Returns ``{folder, episodes}`` (``episodes`` = rename_episodes result). + + Raises: + FileExistsError: the target series folder already exists. + """ + from .series import scan_series, series_folder_stem + pool = self.pool_dir + if pool is None: + raise RuntimeError("Pool není nastaven.") + title = (title or "").strip() or series.title + new_stem = series_folder_stem(title, year or None, tmdb_id or None) + lib = ((library or "").strip() or series.library) + old_folder = series.folder + new_folder = pool / lib / new_stem + if new_folder != old_folder: + if new_folder.exists(): + raise FileExistsError(f"Složka „{new_stem}“ už existuje.") + new_folder.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old_folder), str(new_folder)) + + target = next((s for s in scan_series(new_folder.parent, lib) + if s.folder == new_folder), None) + result = (self.rename_episodes_to_canonical(target) if target + else {"renamed": [], "skipped": [], "conflicts": []}) + return {"folder": new_folder, "episodes": result} + def assign_tag_to_file_objects(self, files_objs: list[File], tag): """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" for f in files_objs: diff --git a/src/core/migrate.py b/src/core/migrate.py new file mode 100644 index 0000000..41dbfc2 --- /dev/null +++ b/src/core/migrate.py @@ -0,0 +1,62 @@ +"""One-off migration of an existing pool to the Jellyfin naming convention. + +Older pools carry files like ``Title.ext`` / ``Title (rok).ext`` with **no TMDb +id**. This matches each such movie against TMDb by title + year and proposes a +``tmdbid`` (and the missing year, which TMDb supplies), so the file can be renamed +to ``Title (rok) [tmdbid].ext`` and Jellyfin can match it deterministically. + +The classification is deliberately **conservative**: only an exact +(diacritics-insensitive) title match with a matching year and a single candidate +is ``auto``; everything else is ``ambiguous`` for the user to confirm. +""" +import re +import unicodedata +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class MatchProposal: + """One movie's proposed TMDb match.""" + + file: object # core.file.File + current_name: str + title: str + year: Optional[str] + status: str # "auto" | "ambiguous" | "no_match" | "error" + chosen: object = None # tmdb.TmdbResult preselected for the user, or None + candidates: list = field(default_factory=list) # list[tmdb.TmdbResult] + error: str = "" + + +def normalize(text: str) -> str: + """Fold a title for comparison: strip diacritics/punctuation, lowercase.""" + text = unicodedata.normalize("NFD", text or "") + text = "".join(c for c in text if not unicodedata.combining(c)) + text = re.sub(r"[^0-9a-zA-Z ]+", " ", text.lower()) + return re.sub(r"\s+", " ", text).strip() + + +def _year_close(a: Optional[str], b: Optional[str]) -> bool: + if not (a and b and str(a).isdigit() and str(b).isdigit()): + return False + return abs(int(a) - int(b)) <= 1 + + +def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]: + """Return ``(status, chosen)`` for a title/year against TMDb candidates. + + ``auto`` requires an exact normalized-title match, a year within ±1, and that + exactly one candidate qualifies. Otherwise ``ambiguous`` (top hit preselected) + or ``no_match`` when there are no candidates. + """ + if not candidates: + return "no_match", None + nt = normalize(title) + exact_year = [ + c for c in candidates + if normalize(c.title) == nt and _year_close(c.year, year) + ] + if len(exact_year) == 1: + return "auto", exact_year[0] + return "ambiguous", candidates[0] diff --git a/src/core/series.py b/src/core/series.py index e1303ac..6a47ce2 100644 --- a/src/core/series.py +++ b/src/core/series.py @@ -76,9 +76,27 @@ def season_folder_name(season: int) -> str: return "Specials" if season == 0 else f"Season {season:02d}" -def episode_filename(series_title: str, season: int, episode: int, ext: str) -> str: - """Canonical episode filename ``Series SxxEyy.ext``.""" - return f"{series_title} S{season:02d}E{episode:02d}{ext}" +def episode_body(stem: str) -> str: + """The descriptive part of an episode filename, minus a leading ``SxxEyy`` code. + + Keeps the original (release) filename so it survives into the canonical name, + while stripping an already-applied ``SxxEyy - `` prefix so repeated renames + don't double it. Returns ``""`` when the stem was only the code. + """ + s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token + s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs + return s.strip(" ._-") + + +def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str: + """Canonical episode filename ``SxxEyy - {original name}.ext``. + + Keeps the original filename after the code; when the original was only the + code (nothing descriptive left) it collapses to ``SxxEyy.ext``. + """ + code = f"S{season:02d}E{episode:02d}" + body = episode_body(orig_stem) + return f"{code} - {body}{ext}" if body else f"{code}{ext}" def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str: @@ -97,13 +115,13 @@ def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title -def is_canonical_episode(path: Path, series_title: str) -> bool: - """True when ``path`` already follows ``Series SxxEyy`` in the right season.""" +def is_canonical_episode(path: Path) -> bool: + """True when ``path`` already follows ``SxxEyy - name`` in the right season.""" parsed = parse_episode(path.name) if parsed is None: return False season, episode = parsed - expected = episode_filename(series_title, season, episode, path.suffix) + expected = episode_filename(season, episode, path.stem, path.suffix) if path.name != expected: return False return path.parent.name == season_folder_name(season) diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 75a77cf..2cf403c 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -21,6 +21,7 @@ from PySide6.QtWidgets import ( QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog, + QFormLayout, QSpinBox, ) from src.core.file_manager import FileManager @@ -29,7 +30,8 @@ from src.core.file import File from src.core.tag import Tag from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager -from src.core.naming import canonical_pool_stem, is_valid_year, year_from_filename +from src.core.naming import ( + canonical_pool_stem, is_valid_year, parse_tmdb_id, year_from_filename) from src.core import series # Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV @@ -73,16 +75,17 @@ class ImportMoviesDialog(QDialog): info.setWordWrap(True) layout.addWidget(info) - self.table = QTableWidget(0, 5) + self.table = QTableWidget(0, 6) self.table.setHorizontalHeaderLabels( - ["Soubor", "Název", "Rok", "TMDb ID", ""]) + ["Soubor", "Název", "Rok", "TMDb ID", "ČSFD odkaz", ""]) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeToContents) - header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.Stretch) + header.setSectionResizeMode(5, QHeaderView.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() @@ -143,6 +146,9 @@ class ImportMoviesDialog(QDialog): tmdb_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) tmdb_edit.textEdited.connect(lambda _t, e=tmdb_edit: self._set_suggested(e, False)) + csfd_edit = QLineEdit() + csfd_edit.setPlaceholderText("ČSFD odkaz (volitelné, jen uloží se)") + remove_btn = QPushButton("✕") remove_btn.setFixedWidth(28) remove_btn.setToolTip("Odebrat tento soubor z importu") @@ -150,11 +156,12 @@ class ImportMoviesDialog(QDialog): self.table.setCellWidget(row, 1, title_edit) self.table.setCellWidget(row, 2, year_edit) self.table.setCellWidget(row, 3, tmdb_edit) - self.table.setCellWidget(row, 4, remove_btn) - self._rows.append((source, title_edit, year_edit, tmdb_edit)) + self.table.setCellWidget(row, 4, csfd_edit) + self.table.setCellWidget(row, 5, remove_btn) + self._rows.append((source, title_edit, year_edit, tmdb_edit, csfd_edit)) self._mark_collision(title_edit) - def _row_of(self, title_edit: QLineEdit) -> tuple[Path, QLineEdit, QLineEdit, QLineEdit] | None: + def _row_of(self, title_edit: QLineEdit): for entry in self._rows: if entry[1] is title_edit: return entry @@ -174,7 +181,7 @@ class ImportMoviesDialog(QDialog): entry = self._row_of(title_edit) if entry is None: return - _src, _title, year_edit, tmdb_edit = entry + _src, _title, year_edit, tmdb_edit, _csfd = entry stem = self._candidate_stem( title_edit.text(), year_edit.text(), tmdb_edit.text()) clashes = stem is not None and stem.lower() in self.existing_names @@ -221,7 +228,7 @@ class ImportMoviesDialog(QDialog): found = 0 QApplication.setOverrideCursor(Qt.WaitCursor) try: - for _src, title_edit, year_edit, tmdb_edit in self._rows: + for _src, title_edit, year_edit, tmdb_edit, _csfd in self._rows: try: if tmdb_edit.text().strip().isdigit(): hit = tmdb.lookup_movie(int(tmdb_edit.text().strip())) @@ -251,7 +258,7 @@ class ImportMoviesDialog(QDialog): def accept(self) -> None: # noqa: D401 — Qt override """Block the import until every row has a title and a four-digit year.""" bad_year: list[str] = [] - for source, title_edit, year_edit, _tmdb in self._rows: + for source, title_edit, year_edit, _tmdb, _csfd in self._rows: title = title_edit.text().strip() or source.stem if not is_valid_year(year_edit.text()): bad_year.append(title) @@ -273,13 +280,13 @@ class ImportMoviesDialog(QDialog): def selected_library(self) -> str: return self.library_combo.currentText() - def entries(self) -> list[tuple[Path, str, str, str]]: - """Return (source, title, year, tmdb_id) per row; title falls back to stem.""" - result: list[tuple[Path, str, str, str]] = [] - for source, title_edit, year_edit, tmdb_edit in self._rows: + def entries(self) -> list[tuple[Path, str, str, str, str]]: + """Return (source, title, year, tmdb_id, csfd_url) per row.""" + result: list[tuple[Path, str, str, str, str]] = [] + for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows: title = title_edit.text().strip() or source.stem - result.append( - (source, title, year_edit.text().strip(), tmdb_edit.text().strip())) + result.append((source, title, year_edit.text().strip(), + tmdb_edit.text().strip(), csfd_edit.text().strip())) return result @@ -462,10 +469,14 @@ class SeriesOverviewDialog(QDialog): self.tree = QTreeWidget() self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"]) self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + self.tree.itemDoubleClicked.connect(lambda _i, _c: self._edit_selected()) layout.addWidget(self.tree) btns = QHBoxLayout() - rename_btn = QPushButton("Přejmenovat epizody vybraného seriálu") + edit_btn = QPushButton("Upravit seriál…") + edit_btn.clicked.connect(self._edit_selected) + btns.addWidget(edit_btn) + rename_btn = QPushButton("Přejmenovat epizody") rename_btn.clicked.connect(self._rename_selected) btns.addWidget(rename_btn) btns.addStretch(1) @@ -493,7 +504,7 @@ class SeriesOverviewDialog(QDialog): snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"]) top.addChild(snode) for ep in eps: - ok = series.is_canonical_episode(ep.path, ser.title) + ok = series.is_canonical_episode(ep.path) enode = QTreeWidgetItem( [f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"]) if not ok: @@ -519,6 +530,434 @@ class SeriesOverviewDialog(QDialog): f"Přejmenováno: {len(result['renamed'])}, " f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.") + def _edit_selected(self) -> None: + idx = self._selected_series_index() + if idx is None: + QMessageBox.information(self, "Seriály", "Vyber seriál.") + return + ser = self._series[idx] + dialog = SeriesEditDialog(self, ser, self.filehandler) + if dialog.exec() != QDialog.Accepted: + return + # 1) apply per-episode season/episode overrides, 2) normalize episode files, + # 3) rename/move the series folder + retitle episodes to the new name. + for ep, season, number in dialog.episode_numbers(): + ep.season, ep.episode = season, number + self.filehandler.rename_episodes_to_canonical(ser) + try: + self.filehandler.apply_series_edits( + ser, title=dialog.title, year=dialog.year or None, + tmdb_id=dialog.tmdb_id or None, library=dialog.library) + except (ValueError, FileExistsError, OSError) as exc: + QMessageBox.warning(self, "Úprava seriálu selhala", str(exc)) + self._reload() + + +class MovieEditDialog(QDialog): + """One dialog for every editable parameter of a movie.""" + + def __init__(self, parent: QWidget, file_obj: File, filehandler: FileManager) -> None: + super().__init__(parent) + self.setWindowTitle("Upravit film") + self.setMinimumWidth(540) + self.file_obj = file_obj + self.filehandler = filehandler + ctx = file_obj.name_context() + + layout = QVBoxLayout(self) + form = QFormLayout() + self.title_edit = QLineEdit(file_obj.title or file_obj.file_path.stem) + form.addRow("Název:", self.title_edit) + self.year_edit = QLineEdit(str(ctx.get("year") or "")) + self.year_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit)) + form.addRow("Rok:", self.year_edit) + + tmdb_row = QHBoxLayout() + self.tmdb_edit = QLineEdit(str(parse_tmdb_id(file_obj.file_path.stem) or "")) + self.tmdb_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)) + tmdb_row.addWidget(self.tmdb_edit) + find_btn = QPushButton("Dohledat z TMDb") + find_btn.clicked.connect(self._lookup) + tmdb_row.addWidget(find_btn) + tmdb_wrap = QWidget() + tmdb_wrap.setLayout(tmdb_row) + form.addRow("TMDb ID:", tmdb_wrap) + + self.library_combo = QComboBox() + self.library_combo.addItems(filehandler.libraries_accepting_movies) + cur_lib = filehandler.library_of(file_obj) + if cur_lib: + i = self.library_combo.findText(cur_lib) + if i >= 0: + self.library_combo.setCurrentIndex(i) + form.addRow("Knihovna:", self.library_combo) + + self.date_edit = QLineEdit(file_obj.date or "") + self.date_edit.setPlaceholderText("YYYY-MM-DD") + form.addRow("Datum přidání:", self.date_edit) + + self.csfd_edit = QLineEdit(file_obj.csfd_url or "") + self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/… (jen uloží se)") + form.addRow("ČSFD odkaz:", self.csfd_edit) + layout.addLayout(form) + + layout.addWidget(QLabel("Atributy (pro šablony názvů, např. collection_sort):")) + self.attr_table = QTableWidget(0, 2) + self.attr_table.setHorizontalHeaderLabels(["Klíč", "Hodnota"]) + self.attr_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.attr_table.setMaximumHeight(140) + for key, value in file_obj.attributes.items(): + self._add_attr(key, value) + layout.addWidget(self.attr_table) + attr_btns = QHBoxLayout() + add_a = QPushButton("➕ Atribut") + add_a.clicked.connect(lambda: self._add_attr("", "")) + del_a = QPushButton("➖ Odebrat") + del_a.clicked.connect(self._remove_attr) + attr_btns.addWidget(add_a) + attr_btns.addWidget(del_a) + attr_btns.addStretch(1) + layout.addLayout(attr_btns) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _add_attr(self, key: str, value: str) -> None: + row = self.attr_table.rowCount() + self.attr_table.insertRow(row) + self.attr_table.setItem(row, 0, QTableWidgetItem(key)) + self.attr_table.setItem(row, 1, QTableWidgetItem(value)) + + def _remove_attr(self) -> None: + row = self.attr_table.currentRow() + if row >= 0: + self.attr_table.removeRow(row) + + def _lookup(self) -> None: + from src.core import tmdb + if not tmdb.api_available(): + QMessageBox.warning(self, "TMDb", "Není nastaven TMDB_API_KEY (.env).") + return + try: + if self.tmdb_edit.text().strip().isdigit(): + hit = tmdb.lookup_movie(int(self.tmdb_edit.text().strip())) + else: + hits = tmdb.search_movie( + self.title_edit.text(), + year=self.year_edit.text().strip() or None, limit=1) + hit = hits[0] if hits else None + except tmdb.TmdbError as exc: + QMessageBox.warning(self, "TMDb", str(exc)) + return + if hit is None: + QMessageBox.information(self, "TMDb", "Nic nenalezeno.") + return + self.tmdb_edit.setText(str(hit.tmdb_id)) + self.title_edit.setText(hit.title) + if hit.year: + self.year_edit.setText(hit.year) + + # --- getters consumed by the handler --- + @property + def title(self) -> str: + return self.title_edit.text().strip() + + @property + def year(self) -> str: + return self.year_edit.text().strip() + + @property + def tmdb_id(self) -> str: + return self.tmdb_edit.text().strip() + + @property + def library(self) -> str: + return self.library_combo.currentText() + + @property + def date(self) -> str: + return self.date_edit.text().strip() + + @property + def attributes(self) -> dict: + out: dict[str, str] = {} + for row in range(self.attr_table.rowCount()): + key_item = self.attr_table.item(row, 0) + val_item = self.attr_table.item(row, 1) + key = key_item.text().strip() if key_item else "" + if key: + out[key] = val_item.text().strip() if val_item else "" + return out + + @property + def csfd_url(self) -> str: + return self.csfd_edit.text().strip() + + +class SeriesEditDialog(QDialog): + """Edit a series (title/year/id/library) and each episode's SxxEyy.""" + + def __init__(self, parent: QWidget, ser, filehandler: FileManager) -> None: + super().__init__(parent) + self.setWindowTitle(f"Upravit seriál — {ser.title}") + self.setMinimumSize(640, 520) + self.series = ser + self._spins: list[tuple] = [] # (episode, season_spin, episode_spin) + + layout = QVBoxLayout(self) + form = QFormLayout() + self.title_edit = QLineEdit(ser.title) + form.addRow("Název:", self.title_edit) + self.year_edit = QLineEdit(ser.year or "") + self.year_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit)) + form.addRow("Rok:", self.year_edit) + self.tmdb_edit = QLineEdit(str(ser.tmdb_id or "")) + self.tmdb_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)) + form.addRow("TMDb ID:", self.tmdb_edit) + self.library_combo = QComboBox() + self.library_combo.addItems(filehandler.series_libraries) + i = self.library_combo.findText(ser.library) + if i >= 0: + self.library_combo.setCurrentIndex(i) + form.addRow("Knihovna:", self.library_combo) + layout.addLayout(form) + + layout.addWidget(QLabel("Epizody — uprav sezónu/díl (uloží se jako SxxEyy):")) + table = QTableWidget(0, 3) + table.setHorizontalHeaderLabels(["Soubor", "Sezóna", "Díl"]) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + table.setEditTriggers(QAbstractItemView.NoEditTriggers) + for ep in ser.episodes: + row = table.rowCount() + table.insertRow(row) + name_item = QTableWidgetItem(ep.path.name) + name_item.setFlags(Qt.ItemIsEnabled) + table.setItem(row, 0, name_item) + season_spin = QSpinBox() + season_spin.setRange(0, 99) + season_spin.setValue(ep.season if ep.season is not None else 1) + table.setCellWidget(row, 1, season_spin) + ep_spin = QSpinBox() + ep_spin.setRange(0, 999) + ep_spin.setValue(ep.episode if ep.episode is not None else 1) + table.setCellWidget(row, 2, ep_spin) + self._spins.append((ep, season_spin, ep_spin)) + layout.addWidget(table) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def accept(self) -> None: # noqa: D401 — Qt override + if not self.title_edit.text().strip(): + QMessageBox.warning(self, "Seriál", "Zadej název seriálu.") + return + super().accept() + + def episode_numbers(self) -> list[tuple]: + return [(ep, s.value(), e.value()) for ep, s, e in self._spins] + + @property + def title(self) -> str: + return self.title_edit.text().strip() + + @property + def year(self) -> str: + return self.year_edit.text().strip() + + @property + def tmdb_id(self) -> str: + return self.tmdb_edit.text().strip() + + @property + def library(self) -> str: + return self.library_combo.currentText() + + +class LibraryOrganizerDialog(QDialog): + """Manage the pool's libraries (the top-level folders): name + kind.""" + + KINDS = [ + ("Filmy (jednotlivé filmy)", "movie"), + ("Seriály", "series"), + ("Mix (filmy + seriály)", "mixed"), + ("Domácí videa (bez TMDb)", "homevideo"), + ] + + def __init__(self, parent: QWidget, libraries: list[dict]) -> None: + super().__init__(parent) + self.setWindowTitle("Knihovny poolu") + self.setMinimumSize(560, 420) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel( + "Knihovny = složky v kořeni poolu, každá je jedna knihovna v Jellyfinu.\n" + "Typ určuje, jak Curator složku zpracuje.")) + + self.table = QTableWidget(0, 2) + self.table.setHorizontalHeaderLabels(["Název složky", "Typ"]) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + layout.addWidget(self.table) + for lib in libraries: + self._add_row(lib.get("name", ""), lib.get("kind", "movie")) + + row = QHBoxLayout() + add_btn = QPushButton("➕ Přidat knihovnu") + add_btn.clicked.connect(lambda: self._add_row("", "movie")) + del_btn = QPushButton("➖ Odebrat") + del_btn.clicked.connect(self._remove_row) + row.addWidget(add_btn) + row.addWidget(del_btn) + row.addStretch(1) + layout.addLayout(row) + + note = QLabel( + "Pozn.: „Mix“ zpracuje filmy (v kořeni) i seriály (v podsložkách); " + "„Domácí videa“ přibudou v další fázi. Po uložení nabídnu smazání " + "prázdných složek, které už nejsou knihovnou (s obsahem nechám být).") + note.setWordWrap(True) + note.setStyleSheet("color: #888;") + layout.addWidget(note) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _add_row(self, name: str, kind: str) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + self.table.setItem(row, 0, QTableWidgetItem(name)) + combo = QComboBox() + for label, value in self.KINDS: + combo.addItem(label, value) + idx = next((i for i, (_l, v) in enumerate(self.KINDS) if v == kind), 0) + combo.setCurrentIndex(idx) + self.table.setCellWidget(row, 1, combo) + + def _remove_row(self) -> None: + row = self.table.currentRow() + if row >= 0: + self.table.removeRow(row) + + def libraries(self) -> list[dict]: + result: list[dict] = [] + for row in range(self.table.rowCount()): + name_item = self.table.item(row, 0) + name = name_item.text().strip() if name_item else "" + if not name: + continue + combo = self.table.cellWidget(row, 1) + result.append({"name": name, "kind": combo.currentData()}) + return result + + +class MigrateDialog(QDialog): + """Review TMDb match proposals for an existing pool and apply the ids.""" + + _STATUS = {"auto": "jisté", "ambiguous": "nejednoznačné", + "no_match": "bez shody", "error": "chyba"} + + def __init__(self, parent: QWidget, filehandler: FileManager, + proposals: list) -> None: + super().__init__(parent) + self.setWindowTitle("Spárovat pool s TMDb (migrace)") + self.setMinimumSize(820, 560) + self.filehandler = filehandler + self._rows: list[tuple] = [] # (proposal, combo | None) + + counts: dict[str, int] = {} + for p in proposals: + counts[p.status] = counts.get(p.status, 0) + 1 + layout = QVBoxLayout(self) + layout.addWidget(QLabel( + "Návrhy párování (zelené jisté jsou předvybrané). Zkontroluj, uprav " + "u nejednoznačných a přejmenuj. " + + " · ".join(f"{self._STATUS.get(k, k)}: {v}" + for k, v in sorted(counts.items())))) + + table = QTableWidget(len(proposals), 3) + table.setHorizontalHeaderLabels(["Soubor", "Stav", "Kandidát z TMDb"]) + table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + table.setEditTriggers(QAbstractItemView.NoEditTriggers) + for row, p in enumerate(proposals): + name_item = QTableWidgetItem(p.current_name) + name_item.setFlags(Qt.ItemIsEnabled) + table.setItem(row, 0, name_item) + status_item = QTableWidgetItem(self._STATUS.get(p.status, p.status)) + status_item.setFlags(Qt.ItemIsEnabled) + if p.status == "auto": + status_item.setForeground(Qt.darkGreen) + elif p.status in ("no_match", "error"): + status_item.setForeground(Qt.red) + table.setItem(row, 1, status_item) + + if p.candidates: + combo = QComboBox() + for c in p.candidates: + combo.addItem( + f"{c.title} ({c.year or '?'}) · tmdb {c.tmdb_id}", c) + combo.addItem("(přeskočit)", None) + if p.chosen is not None: + idx = next((i for i, c in enumerate(p.candidates) + if c.tmdb_id == p.chosen.tmdb_id), 0) + combo.setCurrentIndex(idx if p.status == "auto" else idx) + if p.status != "auto": + combo.setCurrentIndex(combo.count() - 1) # default: skip + table.setCellWidget(row, 2, combo) + self._rows.append((p, combo)) + else: + cell = QTableWidgetItem(p.error or "nic nenalezeno") + cell.setFlags(Qt.ItemIsEnabled) + table.setItem(row, 2, cell) + self._rows.append((p, None)) + layout.addWidget(table) + + btns = QHBoxLayout() + auto_btn = QPushButton("Přejmenovat jen jisté") + auto_btn.clicked.connect(lambda: self._apply(only_auto=True)) + btns.addWidget(auto_btn) + sel_btn = QPushButton("Přejmenovat vybrané") + sel_btn.clicked.connect(lambda: self._apply(only_auto=False)) + btns.addWidget(sel_btn) + btns.addStretch(1) + close_btn = QPushButton("Zavřít") + close_btn.clicked.connect(self.accept) + btns.addWidget(close_btn) + layout.addLayout(btns) + + def _apply(self, only_auto: bool) -> None: + done = 0 + errors: list[str] = [] + for proposal, combo in self._rows: + if only_auto and proposal.status != "auto": + continue + candidate = combo.currentData() if combo is not None else None + if candidate is None: + continue + try: + self.filehandler.assign_tmdb_id( + proposal.file, candidate.tmdb_id, candidate.year) + done += 1 + except (ValueError, FileExistsError, OSError) as exc: + errors.append(f"{proposal.current_name}: {exc}") + msg = f"Přejmenováno {done} filmů." + if errors: + msg += "\n\nChyby:\n" + "\n".join(errors[:8]) + QMessageBox.warning(self, "Migrace s chybami", msg) + else: + QMessageBox.information(self, "Migrace", msg) + self.accept() + class AssignTagsDialog(QDialog): """Tri-state bulk tag assignment over the selected movies. @@ -830,7 +1269,7 @@ class QtApp(QMainWindow): pool_menu = bar.addMenu("&Pool") self._add_action(pool_menu, "Nastavit pool…", self.set_pool, "Ctrl+P") self._add_action(pool_menu, "Nastavit Filmotéku (výstup)…", self.set_filmoteka) - self._add_action(pool_menu, "Knihovny (rozdělení)…", self.edit_libraries) + self._add_action(pool_menu, "Knihovny…", self.edit_libraries) pool_menu.addSeparator() self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5") pool_menu.addSeparator() @@ -838,6 +1277,7 @@ class QtApp(QMainWindow): movie_menu = bar.addMenu("&Filmy") self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I") + self._add_action(movie_menu, "Upravit film…", self.edit_movie, "Return") self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2") self._add_action(movie_menu, "Přesunout do knihovny…", self.move_to_library) self._add_action( @@ -856,7 +1296,6 @@ class QtApp(QMainWindow): film_menu = bar.addMenu("&miniDLNA") self._add_action( film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G") - self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) jellyfin_menu = bar.addMenu("&Jellyfin") self._add_action(jellyfin_menu, "Skenovat knihovnu", self.jellyfin_scan) @@ -869,6 +1308,10 @@ class QtApp(QMainWindow): self._add_action(settings_menu, "Statistiky…", self.show_statistics) + tools_menu = bar.addMenu("&Nástroje") + self._add_action( + tools_menu, "Spárovat pool s TMDb (migrace)…", self.migrate_pool) + tests_menu = bar.addMenu("&Testy") self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency) self._add_action(tests_menu, "Kontrola integrity videa…", self.check_video_integrity) @@ -922,7 +1365,7 @@ class QtApp(QMainWindow): self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_table_menu) - self.table.doubleClicked.connect(lambda _: self.open_movies()) + self.table.doubleClicked.connect(lambda _: self.edit_movie()) self.table.itemSelectionChanged.connect(self._update_selection_status) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) # Název @@ -1036,7 +1479,7 @@ class QtApp(QMainWindow): self.library_filter.blockSignals(True) self.library_filter.clear() self.library_filter.addItem("Vše") - for name in self.filehandler.movie_libraries: + for name in self.filehandler.libraries_accepting_movies: self.library_filter.addItem(name) idx = self.library_filter.findText(current) self.library_filter.setCurrentIndex(idx if idx >= 0 else 0) @@ -1094,6 +1537,7 @@ class QtApp(QMainWindow): def _show_table_menu(self, pos) -> None: menu = QMenu(self) + menu.addAction("Upravit film…", self.edit_movie) menu.addAction("Otevřít", self.open_movies) menu.addAction("Přejmenovat…", self.rename_movie) menu.addAction("Přesunout do knihovny…", self.move_to_library) @@ -1163,7 +1607,7 @@ class QtApp(QMainWindow): sources = [Path(p) for p in paths] existing_names = {Path(f.filename).stem for f in self.filehandler.filelist} dialog = ImportMoviesDialog( - self, sources, existing_names, self.filehandler.movie_libraries) + self, sources, existing_names, self.filehandler.libraries_accepting_movies) if dialog.exec() != QDialog.Accepted: return entries = dialog.entries() @@ -1175,7 +1619,7 @@ class QtApp(QMainWindow): # Rows whose canonical "Title (year) [tmdbid]" name already exists colliding = [ canonical_pool_stem(t, y, tid or None) - for s, t, y, tid in entries + for s, t, y, tid, _c in entries if is_valid_year(y) and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y, tid or None)) ] @@ -1188,11 +1632,12 @@ class QtApp(QMainWindow): imported: list[File] = [] skipped = 0 errors: list[str] = [] - for source, title, year, tmdb_id in entries: + for source, title, year, tmdb_id, csfd_url in entries: try: movie = self.filehandler.import_movie( source, title, move=move, on_conflict=on_conflict, - year=year or None, tmdb_id=tmdb_id or None, library=library) + year=year or None, tmdb_id=tmdb_id or None, library=library, + csfd_url=csfd_url or None) if movie is None: skipped += 1 else: @@ -1325,6 +1770,29 @@ class QtApp(QMainWindow): self.refresh_table() self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000) + def edit_movie(self) -> None: + """Open the consolidated edit dialog for the selected movie.""" + files = self._selected_movies() + if len(files) != 1: + QMessageBox.information(self, "Upravit film", "Vyber právě jeden film.") + return + f = files[0] + dialog = MovieEditDialog(self, f, self.filehandler) + if dialog.exec() != QDialog.Accepted: + return + try: + self.filehandler.apply_movie_edits( + f, title=dialog.title, year=dialog.year or None, + tmdb_id=dialog.tmdb_id or None, library=dialog.library, + date=dialog.date, attributes=dialog.attributes, + csfd_url=dialog.csfd_url) + except (ValueError, FileExistsError, OSError) as exc: + QMessageBox.warning(self, "Úprava selhala", str(exc)) + return + self._refresh_library_filter() + self.refresh_table() + self.status.showMessage(f"Upraveno: {f.filename}", 5000) + def rename_to_canonical_naming(self) -> None: """Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata.""" files = self._selected_movies() or self.filehandler.filelist @@ -1418,6 +1886,40 @@ class QtApp(QMainWindow): return SeriesOverviewDialog(self, self.filehandler).exec() + def migrate_pool(self) -> None: + """Match the existing pool against TMDb and assign ids (one-off migration).""" + from src.core import tmdb + if not tmdb.api_available(): + QMessageBox.warning( + self, "Migrace", "Nastav TMDB_API_KEY v .env (potřeba pro párování).") + return + files = self.filehandler.filelist + if not files: + QMessageBox.information(self, "Migrace", "Pool je prázdný.") + return + + progress = QProgressDialog("Hledám na TMDb…", "Zrušit", 0, len(files), self) + progress.setWindowTitle("Migrace — párování s TMDb") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + + def on_progress(done: int, total: int, f: File) -> None: + progress.setValue(done) + progress.setLabelText(f"[{done}/{total}] {f.filename}") + QApplication.processEvents() + + proposals = self.filehandler.match_pool_to_tmdb( + files, on_progress=on_progress, should_cancel=progress.wasCanceled) + progress.setValue(len(files)) + + if not proposals: + QMessageBox.information( + self, "Migrace", "Všechny filmy už mají TMDb ID — není co migrovat.") + return + MigrateDialog(self, self.filehandler, proposals).exec() + self._refresh_library_filter() + self.refresh_table() + def refresh_from_nfo(self) -> None: """(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk.""" files = self._selected_movies() or self.filehandler.filelist @@ -1515,39 +2017,50 @@ class QtApp(QMainWindow): f"tipy +{tips}, nově +{recent}", 5000 ) - def edit_copyasis_folders(self) -> None: - current = ", ".join(self.filehandler.copyasis_folders) - text, ok = QInputDialog.getText( - self, "Copy-as-is složky", - "Názvy pool podsložek zrcadlených 1:1 (oddělené čárkou):", text=current - ) - if not ok: - return - self.filehandler.set_copyasis_folders(text.split(",")) - self.status.showMessage( - "Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000 - ) - def edit_libraries(self) -> None: - """Edit the movie-library list (physical partitions) and create folders.""" + """Open the library organizer (manage the pool's top-level libraries).""" if not self.filehandler.pool_dir: QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.") return - current = ", ".join(self.filehandler.movie_libraries) - text, ok = QInputDialog.getText( - self, "Knihovny (rozdělení)", - "Názvy filmových knihoven — složky v poolu (oddělené čárkou):", - text=current) - if not ok: + dialog = LibraryOrganizerDialog(self, self.filehandler.libraries) + if dialog.exec() != QDialog.Accepted: return - self.filehandler.set_movie_libraries(text.split(",")) + self.filehandler.set_libraries(dialog.libraries()) pool = self.filehandler.pool_dir - for name in self.filehandler.movie_libraries: - (pool / name).mkdir(parents=True, exist_ok=True) + for lib in self.filehandler.libraries: + (pool / lib["name"]).mkdir(parents=True, exist_ok=True) self._refresh_library_filter() self.refresh_table() - self.status.showMessage( - "Knihovny: " + ", ".join(self.filehandler.movie_libraries), 5000) + self._cleanup_orphan_folders() + names = ", ".join(f"{lib['name']} ({lib['kind']})" + for lib in self.filehandler.libraries) + self.status.showMessage("Knihovny: " + names, 6000) + + def _cleanup_orphan_folders(self) -> None: + """Offer to delete empty pool folders that are no longer libraries.""" + orphans = self.filehandler.orphan_pool_folders() + empty = [n for n, is_empty in orphans if is_empty] + nonempty = [n for n, is_empty in orphans if not is_empty] + if empty: + listing = "\n".join(f" • {n}" for n in empty[:15]) + more = f"\n … a další ({len(empty) - 15})" if len(empty) > 15 else "" + ans = QMessageBox.question( + self, "Úklid složek", + f"V poolu jsou prázdné složky, které nejsou knihovnou " + f"({len(empty)}):\n{listing}{more}\n\nSmazat je?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if ans == QMessageBox.Yes: + removed = sum(1 for n in empty + if self.filehandler.remove_empty_pool_folder(n)) + self.status.showMessage(f"Smazáno {removed} prázdných složek", 5000) + if nonempty: + listing = "\n".join(f" • {n}" for n in nonempty[:15]) + more = f"\n … a další ({len(nonempty) - 15})" if len(nonempty) > 15 else "" + QMessageBox.information( + self, "Neznámé složky s obsahem", + f"Tyto složky nejsou knihovnou, ale obsahují soubory — nesmazány " + f"({len(nonempty)}):\n{listing}{more}\n\nStaré knihovny přesuň " + "do některé knihovny, nebo smaž ručně.") def move_to_library(self) -> None: """Move the selected movies into another library.""" @@ -1555,7 +2068,7 @@ class QtApp(QMainWindow): if not files: QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.") return - libraries = self.filehandler.movie_libraries + libraries = self.filehandler.libraries_accepting_movies target, ok = QInputDialog.getItem( self, "Přesunout do knihovny", "Cílová knihovna:", libraries, 0, False) if not ok or not target: diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 9ce5f6f..c87442f 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -427,6 +427,92 @@ class TestPoolManagement: assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv" assert movie.title == "Matrix" # clean title, no year/id + def test_libraries_default_and_derivation(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + assert file_manager.libraries == [ + {"name": "Filmy", "kind": "movie"}, + {"name": "Seriály", "kind": "series"}, + ] + assert file_manager.movie_libraries == ["Filmy"] + assert file_manager.copyasis_folders == ["Seriály"] + + def test_set_libraries_with_all_kinds(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + file_manager.set_libraries([ + {"name": "Hlavní", "kind": "mixed"}, + {"name": "Dokumenty", "kind": "mixed"}, + {"name": "Anime", "kind": "series"}, + {"name": "Chajda", "kind": "homevideo"}, + ]) + assert file_manager.copyasis_folders == ["Anime"] + assert file_manager.movie_libraries == ["Filmy"] # none of kind movie → fallback + assert file_manager.libraries_of_kind("mixed") == ["Hlavní", "Dokumenty"] + assert file_manager.libraries_of_kind("homevideo") == ["Chajda"] + + def test_set_pool_dir_creates_all_library_folders(self, file_manager, tmp_path): + pool = tmp_path / "pool" + file_manager.set_pool_dir(pool) + file_manager.set_libraries([ + {"name": "Hlavní", "kind": "mixed"}, + {"name": "Chajda", "kind": "homevideo"}, + ]) + file_manager.set_pool_dir(pool) # re-run creates the newly defined folders + assert (pool / "Hlavní").is_dir() + assert (pool / "Chajda").is_dir() + + def test_mixed_library_loads_movies_not_episodes(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) + hlavni = tmp_path / "pool" / "Hlavní" + hlavni.mkdir(parents=True, exist_ok=True) + (hlavni / "Matrix (1999) [tmdbid-603].mkv").write_bytes(b"x") # movie (top level) + ep = hlavni / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01" + ep.mkdir(parents=True) + (ep / "S01E01 - pilot.mkv").write_bytes(b"y") # series episode (subfolder) + + file_manager.load_pool_movies() + names = [f.filename for f in file_manager.filelist] + assert names == ["Matrix (1999) [tmdbid-603].mkv"] # episode not loaded as movie + assert file_manager.library_of(file_manager.filelist[0]) == "Hlavní" + + def test_libraries_accepting_movies(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + file_manager.set_libraries([ + {"name": "Filmy", "kind": "movie"}, + {"name": "Hlavní", "kind": "mixed"}, + {"name": "Anime", "kind": "series"}, + ]) + assert file_manager.libraries_accepting_movies == ["Filmy", "Hlavní"] + + def test_orphan_folder_cleanup(self, file_manager, tmp_path): + pool = tmp_path / "pool" + file_manager.set_pool_dir(pool) + file_manager.set_libraries([{"name": "Filmy", "kind": "movie"}]) + (pool / "OldKids").mkdir() # empty orphan + (pool / "OldStuff").mkdir() + (pool / "OldStuff" / "film.mkv").write_bytes(b"x") # non-empty orphan + + orphans = dict(file_manager.orphan_pool_folders()) + assert orphans.get("OldKids") is True + assert orphans.get("OldStuff") is False + assert "Filmy" not in orphans # a configured library is not an orphan + + assert file_manager.remove_empty_pool_folder("OldKids") is True + assert not (pool / "OldKids").exists() + assert file_manager.remove_empty_pool_folder("OldStuff") is False # non-empty + assert file_manager.remove_empty_pool_folder("Filmy") is False # a library + + def test_move_movie_into_mixed_library(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + file_manager.set_libraries([ + {"name": "Filmy", "kind": "movie"}, + {"name": "Hlavní", "kind": "mixed"}, + ]) + (tmp_path / "a.mkv").write_bytes(b"x") + movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999) + file_manager.move_to_library(movie, "Hlavní") + assert file_manager.library_of(movie) == "Hlavní" + def test_import_into_named_library(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_movie_libraries(["Filmy", "Kids"]) @@ -490,6 +576,52 @@ class TestPoolManagement: assert len(reloaded.filelist) == 1 assert reloaded.filelist[0].file_path.suffix == ".mkv" + def test_apply_movie_edits_all_at_once(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + file_manager.set_movie_libraries(["Filmy", "Kids"]) + (tmp_path / "a.mkv").write_bytes(b"x") + movie = file_manager.import_movie(tmp_path / "a.mkv", "Puvodni", year=2000) + + file_manager.apply_movie_edits( + movie, title="Auta", year=2006, tmdb_id=920, library="Kids", + date="2026-01-02", attributes={"collection_sort": "01"}, + tags=["Žánr/Animovaný", "Sbírka/Oblíbené"]) + + assert movie.file_path == tmp_path / "pool" / "Kids" / "Auta (2006) [tmdbid-920].mkv" + assert movie.title == "Auta" + assert file_manager.library_of(movie) == "Kids" + assert movie.date == "2026-01-02" + assert movie.attributes == {"collection_sort": "01"} + assert {t.full_path for t in movie.tags} == {"Žánr/Animovaný", "Sbírka/Oblíbené"} + + def test_import_and_edit_store_csfd_url(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", "Matrix", year=1999, tmdb_id=603, + csfd_url="https://www.csfd.cz/film/9499/") + assert movie.csfd_url == "https://www.csfd.cz/film/9499/" + + # persists across reload + reloaded = FileManager(TagManager()) + reloaded.set_pool_dir(tmp_path / "pool") + reloaded.load_pool_movies() + assert reloaded.filelist[0].csfd_url == "https://www.csfd.cz/film/9499/" + + # editable via apply_movie_edits + file_manager.apply_movie_edits(movie, title="Matrix", year=1999, + 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_rejects_name_clash(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"x") + (tmp_path / "b.mkv").write_bytes(b"y") + file_manager.import_movie(tmp_path / "a.mkv", "Solaris", year=1972) + other = file_manager.import_movie(tmp_path / "b.mkv", "Jiny", year=2002) + with pytest.raises(FileExistsError): + file_manager.apply_movie_edits(other, title="Solaris", year=1972) + def test_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") (tmp_path / "raw.mkv").write_bytes(b"x") @@ -581,7 +713,11 @@ class TestPoolManagement: file_manager.set_copyasis_folders(["Seriály", "Dokumenty"]) assert "copyasis_folders" not in file_manager.global_config - assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "Dokumenty"] + # series libraries now persist in the unified "libraries" setting (in the index) + stored = file_manager.index.get_setting("libraries") + assert stored is not None + assert [lib["name"] for lib in stored if lib["kind"] == "series"] == \ + ["Seriály", "Dokumenty"] # a fresh manager over the same pool reads the stored settings back reloaded = FileManager(TagManager()) diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..82d7767 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,95 @@ +"""Tests for pool → TMDb migration (src.core.migrate + FileManager).""" +import pytest + +from src.core.migrate import classify, normalize +from src.core.tmdb import TmdbResult + + +def test_normalize_folds_diacritics_and_punct(): + assert normalize("Šíleně smutná princezna!") == "silene smutna princezna" + assert normalize("Amélie z Montmartru") == "amelie z montmartru" + + +def test_classify_auto_on_exact_unique(): + cands = [TmdbResult(603, "Matrix", "1999", "movie")] + status, chosen = classify("Matrix", "1999", cands) + assert status == "auto" and chosen.tmdb_id == 603 + + +def test_classify_auto_disambiguates_by_year(): + cands = [ + TmdbResult(1, "Solaris", "1972", "movie"), + TmdbResult(2, "Solaris", "2002", "movie"), + ] + # local year 1972 → unique exact-year match → auto picks the 1972 one + assert classify("Solaris", "1972", cands) == ("auto", cands[0]) + + +def test_classify_ambiguous_when_year_matches_none(): + cands = [ + TmdbResult(1, "Solaris", "1972", "movie"), + TmdbResult(2, "Solaris", "2002", "movie"), + ] + status, chosen = classify("Solaris", "1990", cands) + # year close to neither → no unique exact-year → ambiguous (top hit preselected) + assert status == "ambiguous" and chosen.tmdb_id == 1 + + +def test_classify_ambiguous_without_year(): + cands = [TmdbResult(603, "Matrix", "1999", "movie")] + status, _ = classify("Matrix", None, cands) + assert status == "ambiguous" # no local year → user confirms + + +def test_classify_no_match(): + assert classify("Nic", "2000", []) == ("no_match", None) + + +class TestFileManagerMigrate: + @pytest.fixture + def file_manager(self, tmp_path, monkeypatch): + import src.core.config as config_module + monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag") + from src.core.tag_manager import TagManager + from src.core.file_manager import FileManager + fm = FileManager(TagManager()) + fm.set_pool_dir(tmp_path / "pool") + return fm + + def test_match_and_assign(self, file_manager, tmp_path, monkeypatch): + (tmp_path / "a.mkv").write_bytes(b"x") + movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999) + assert "[tmdbid" not in movie.file_path.name # not migrated yet + + import src.core.tmdb as tmdb + monkeypatch.setattr( + tmdb, "search_movie", + lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")]) + + proposals = file_manager.match_pool_to_tmdb() + assert len(proposals) == 1 + p = proposals[0] + assert p.status == "auto" and p.chosen.tmdb_id == 603 + + file_manager.assign_tmdb_id(p.file, p.chosen.tmdb_id, p.chosen.year) + assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv" + + def test_match_skips_already_migrated(self, file_manager, tmp_path, monkeypatch): + (tmp_path / "a.mkv").write_bytes(b"x") + file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603) + + import src.core.tmdb as tmdb + called = [] + monkeypatch.setattr( + tmdb, "search_movie", + lambda q, year=None, limit=5: called.append(q) or []) + proposals = file_manager.match_pool_to_tmdb() + assert proposals == [] and called == [] # already has id → not searched + + def test_assign_fills_missing_year_from_tmdb(self, file_manager, tmp_path): + (tmp_path / "a.mkv").write_bytes(b"x") + movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") # no year + assert movie.file_path.name == "Matrix.mkv" + + file_manager.assign_tmdb_id(movie, 603, "1999") # TMDb supplies the year + assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv" diff --git a/tests/test_series.py b/tests/test_series.py index c22756b..794132d 100644 --- a/tests/test_series.py +++ b/tests/test_series.py @@ -29,8 +29,13 @@ def test_season_folder_name(): assert season_folder_name(12) == "Season 12" -def test_episode_filename(): - assert episode_filename("Breaking Bad", 1, 2, ".mkv") == "Breaking Bad S01E02.mkv" +def test_episode_filename_keeps_original_name(): + assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv" + # already-canonical stem → no doubled prefix + assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \ + "S01E02 - bb.pilot.hdtv.mkv" + # stem that was only the code → collapses to just the code + assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv" def test_series_folder_stem(): @@ -42,12 +47,12 @@ def test_series_folder_stem(): def test_is_canonical_episode(tmp_path): folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01" folder.mkdir(parents=True) - good = folder / "Breaking Bad S01E02.mkv" + good = folder / "S01E02 - bb.pilot.mkv" good.write_bytes(b"x") bad = folder / "bb.s01e02.mkv" bad.write_bytes(b"x") - assert is_canonical_episode(good, "Breaking Bad") - assert not is_canonical_episode(bad, "Breaking Bad") + assert is_canonical_episode(good) + assert not is_canonical_episode(bad) def test_scan_series(tmp_path): @@ -89,8 +94,22 @@ class TestFileManagerSeries: assert result["imported"] == 2 base = (tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01") - assert (base / "Breaking Bad S01E01.mkv").exists() - assert (base / "Breaking Bad S01E02.mkv").exists() + assert (base / "S01E01 - bb.mkv").exists() + assert (base / "S01E02 - bb.mkv").exists() + + def test_import_episodes_into_mixed_library(self, file_manager, tmp_path): + file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}]) + (tmp_path / "bb.S01E01.mkv").write_bytes(b"x") + result = file_manager.import_episodes( + [tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396, + library="Hlavní") + + assert result["imported"] == 1 + assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]" + / "Season 01" / "S01E01 - bb.mkv").exists() + # a mixed library is scanned for series too + found = file_manager.load_series() + assert [s.title for s in found] == ["Breaking Bad"] def test_import_episodes_skips_uncoded(self, file_manager, tmp_path): (tmp_path / "random.mkv").write_bytes(b"x") @@ -110,5 +129,20 @@ class TestFileManagerSeries: assert len(series_list) == 1 result = file_manager.rename_episodes_to_canonical(series_list[0]) - assert result["renamed"] == [("bb.1x03.HDTV.mkv", "Breaking Bad S01E03.mkv")] - assert (folder / "Season 01" / "Breaking Bad S01E03.mkv").exists() + assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")] + assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists() + + def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path): + folder = tmp_path / "pool" / "Seriály" / "Wrong Name" + (folder / "Season 01").mkdir(parents=True) + (folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x") + + ser = file_manager.load_series()[0] + result = file_manager.apply_series_edits( + ser, title="Breaking Bad", year=2008, tmdb_id=1396) + + new_folder = (tmp_path / "pool" / "Seriály" + / "Breaking Bad (2008) [tmdbid-1396]") + assert result["folder"] == new_folder + assert (new_folder / "Season 01" / "S01E01 - old.mkv").exists() + assert not folder.exists()