Add edit dialogs, library organizer, and TMDb pool migration
This commit is contained in:
@@ -21,6 +21,67 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
## 1.12.0 — 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+99
-13
@@ -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
|
Curator is being refocused from a ČSFD-tagging tool into a **Jellyfin-first
|
||||||
library manager**. Agreed shape:
|
library manager**. Agreed shape:
|
||||||
|
|
||||||
- **Pool = the single Jellyfin library** (merged; no separate output library).
|
- **Pool = the Jellyfin library set.** Files are named `Title (rok) [tmdbid-N].ext`
|
||||||
Files are named `Title (rok) [tmdbid-N].ext` — the form Jellyfin matches on.
|
— the form Jellyfin matches on. Libraries are physical top-level pool folders,
|
||||||
Libraries (Filmy/Kids/Dokumenty/Anime/Seriály) are physical partitions.
|
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)
|
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
|
||||||
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
|
||||||
Curator only resolves a **TMDb id → title + year** for naming (`tmdb.py`);
|
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
|
||||||
ČSFD is dropped (it exposes no TMDb/IMDb id anyway — verified).
|
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,
|
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
|
||||||
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
|
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
|
||||||
`naming.py`), not from ČSFD tags.
|
`naming.py`), not from ČSFD tags.
|
||||||
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
|
||||||
key + URL in `.env`. NFO on disk stays authoritative.
|
key + URL in `.env`. NFO on disk stays authoritative.
|
||||||
|
|
||||||
Phasing: (1) TMDb identity + tmdbid naming + import rework [done]; (2) movie
|
### Decisions (locked)
|
||||||
libraries as physical partitions + import-into / move-between [done]; (3) Jellyfin
|
|
||||||
API — scan + verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD
|
- **Kids = Jellyfin parental rating, not a folder** (variant B). Kids content
|
||||||
removed [done]; (5) series model — SxxEyy layout, import, rename [done];
|
stays in the normal libraries; a child Jellyfin account gets a max-rating limit
|
||||||
(6) migrate the existing 589 movies (title+year → TMDb search). Note: libraries hold **flat** files for now
|
with **"block unrated" ON**. Ratings come from TMDb via Jellyfin. Caveat: Czech
|
||||||
(`pool/<Library>/Title (rok) [tmdbid].ext`); per-movie folders are a later
|
certifications are sparse — set the library metadata country to one with fuller
|
||||||
refinement.
|
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/<Library>/Title (rok)
|
||||||
|
[tmdbid].ext`); per-movie folders are a later refinement.
|
||||||
|
|
||||||
## Tasks
|
## 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/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
|
||||||
|
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
|
||||||
|
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
|
||||||
|
path-aware `library_of` (currently single-segment).
|
||||||
|
|
||||||
## Done
|
## 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
|
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
|
||||||
import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus
|
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
|
"Přejmenovat dle ČSFD (rok)" to bring older files up to the convention
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class File:
|
|||||||
self.date: str | None = None
|
self.date: str | None = None
|
||||||
# movie-library fields
|
# movie-library fields
|
||||||
self.title: str | None = None
|
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);
|
# 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.
|
# None for older/unknown → falls back to the file's ctime.
|
||||||
self.added: str | None = None
|
self.added: str | None = None
|
||||||
@@ -46,6 +49,7 @@ class File:
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.date = None
|
self.date = None
|
||||||
self.title = None
|
self.title = None
|
||||||
|
self.csfd_url = None
|
||||||
self.meta = None
|
self.meta = None
|
||||||
self.auto_tag_paths = set()
|
self.auto_tag_paths = set()
|
||||||
self.attributes = {}
|
self.attributes = {}
|
||||||
@@ -62,6 +66,7 @@ class File:
|
|||||||
# date může být None
|
# date může být None
|
||||||
"date": self.date,
|
"date": self.date,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
|
"csfd_url": self.csfd_url,
|
||||||
"attributes": self.attributes,
|
"attributes": self.attributes,
|
||||||
"added": self.added,
|
"added": self.added,
|
||||||
}
|
}
|
||||||
@@ -75,6 +80,7 @@ class File:
|
|||||||
self.tags = []
|
self.tags = []
|
||||||
self.date = data.get("date", None)
|
self.date = data.get("date", None)
|
||||||
self.title = data.get("title", 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
|
# 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.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
|
||||||
self.attributes = dict(data.get("attributes", {}))
|
self.attributes = dict(data.get("attributes", {}))
|
||||||
|
|||||||
+279
-29
@@ -17,12 +17,23 @@ from src.core.config import (
|
|||||||
POOL_MOVIES = "Filmy"
|
POOL_MOVIES = "Filmy"
|
||||||
POOL_SERIES = "Seriály"
|
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).
|
# 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 = {
|
_LIBRARY_SETTINGS_DEFAULTS = {
|
||||||
"tag_schema": DEFAULT_TAG_SCHEMA,
|
"tag_schema": DEFAULT_TAG_SCHEMA,
|
||||||
"copyasis_folders": [POOL_SERIES],
|
"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],
|
"movie_libraries": [POOL_MOVIES],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,26 +128,100 @@ class FileManager:
|
|||||||
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
||||||
idx.set_setting(key, value)
|
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
|
@property
|
||||||
def copyasis_folders(self) -> list[str]:
|
def copyasis_folders(self) -> list[str]:
|
||||||
"""Names of pool subfolders mirrored 1:1 (copy-as-is) into the output."""
|
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
|
||||||
return self._get_setting("copyasis_folders")
|
return self.libraries_of_kind("series")
|
||||||
|
|
||||||
def set_copyasis_folders(self, names: list[str]) -> None:
|
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()]
|
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
|
@property
|
||||||
def movie_libraries(self) -> list[str]:
|
def movie_libraries(self) -> list[str]:
|
||||||
"""Top-level pool folders holding movies (each a Jellyfin library)."""
|
"""Pool folders holding single-file movies (kind ``movie``)."""
|
||||||
libs = self._get_setting("movie_libraries")
|
return self.libraries_of_kind("movie") or [POOL_MOVIES]
|
||||||
return libs if libs else [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:
|
def set_movie_libraries(self, names: list[str]) -> None:
|
||||||
"""Set the movie-library list (never empty — falls back to Filmy)."""
|
"""Set the movie libraries, keeping the other kinds; never empty."""
|
||||||
cleaned = [n.strip() for n in names if n.strip()]
|
cleaned = [n.strip() for n in names if n.strip()] or [POOL_MOVIES]
|
||||||
self._set_setting("movie_libraries", cleaned 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:
|
def library_of(self, file_obj: File) -> str | None:
|
||||||
"""Which library (top-level pool folder) a pooled movie lives in."""
|
"""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)
|
self.global_config["pool_dir"] = str(pool_dir)
|
||||||
save_global_config(self.global_config)
|
save_global_config(self.global_config)
|
||||||
self._open_index(pool_dir) # settings now readable through the index
|
self._open_index(pool_dir) # settings now readable through the index
|
||||||
for name in self.movie_libraries + self.copyasis_folders:
|
for lib in self.libraries:
|
||||||
(pool_dir / name).mkdir(parents=True, exist_ok=True)
|
(pool_dir / lib["name"]).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
||||||
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
||||||
@@ -222,17 +307,24 @@ class FileManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._open_index(pool)
|
self._open_index(pool)
|
||||||
for library in self.movie_libraries:
|
for lib in self.libraries:
|
||||||
lib_dir = pool / library
|
if lib["kind"] not in ("movie", "mixed"):
|
||||||
|
continue
|
||||||
|
lib_dir = pool / lib["name"]
|
||||||
if not lib_dir.is_dir():
|
if not lib_dir.is_dir():
|
||||||
continue
|
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):
|
if each.name.endswith(METADATA_SUFFIXES):
|
||||||
continue
|
continue
|
||||||
if each.suffix.lower() in SIDECAR_SUFFIXES:
|
if each.suffix.lower() in SIDECAR_SUFFIXES:
|
||||||
continue
|
continue
|
||||||
file_obj = File(each, self.tagmanager, index=self.index)
|
self.filelist.append(File(each, self.tagmanager, index=self.index))
|
||||||
self.filelist.append(file_obj)
|
|
||||||
|
|
||||||
def check_data_consistency(self) -> dict:
|
def check_data_consistency(self) -> dict:
|
||||||
"""Compare the pool index against the actual files in the movie libraries.
|
"""Compare the pool index against the actual files in the movie libraries.
|
||||||
@@ -255,11 +347,15 @@ class FileManager:
|
|||||||
index_keys = set(index.records.keys())
|
index_keys = set(index.records.keys())
|
||||||
|
|
||||||
disk_keys = set()
|
disk_keys = set()
|
||||||
for library in self.movie_libraries:
|
for lib in self.libraries:
|
||||||
lib_dir = pool / library
|
if lib["kind"] not in ("movie", "mixed"):
|
||||||
|
continue
|
||||||
|
lib_dir = pool / lib["name"]
|
||||||
if not lib_dir.is_dir():
|
if not lib_dir.is_dir():
|
||||||
continue
|
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):
|
if f.name.endswith(METADATA_SUFFIXES):
|
||||||
continue
|
continue
|
||||||
if f.suffix.lower() in SIDECAR_SUFFIXES:
|
if f.suffix.lower() in SIDECAR_SUFFIXES:
|
||||||
@@ -421,7 +517,7 @@ class FileManager:
|
|||||||
self, source: Path, title: str,
|
self, source: Path, title: str,
|
||||||
move: bool = False, on_conflict: str = "suffix",
|
move: bool = False, on_conflict: str = "suffix",
|
||||||
year: int | str | None = None, tmdb_id: int | str | None = None,
|
year: int | str | None = None, tmdb_id: int | str | None = None,
|
||||||
library: str | None = None,
|
library: str | None = None, csfd_url: str | None = None,
|
||||||
) -> File | None:
|
) -> File | None:
|
||||||
"""Bring a video file into a movie library and index its metadata.
|
"""Bring a video file into a movie library and index its metadata.
|
||||||
|
|
||||||
@@ -487,6 +583,7 @@ class FileManager:
|
|||||||
|
|
||||||
file_obj = File(target, self.tagmanager, index=self.index)
|
file_obj = File(target, self.tagmanager, index=self.index)
|
||||||
file_obj.title = safe_title
|
file_obj.title = safe_title
|
||||||
|
file_obj.csfd_url = csfd_url or None
|
||||||
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
||||||
file_obj.save_metadata()
|
file_obj.save_metadata()
|
||||||
|
|
||||||
@@ -650,7 +747,7 @@ class FileManager:
|
|||||||
if pool is None:
|
if pool is None:
|
||||||
raise RuntimeError("Pool není nastaven.")
|
raise RuntimeError("Pool není nastaven.")
|
||||||
new_library = new_library.strip()
|
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}“.")
|
raise ValueError(f"Neznámá knihovna „{new_library}“.")
|
||||||
|
|
||||||
old_path = file_obj.file_path
|
old_path = file_obj.file_path
|
||||||
@@ -670,6 +767,123 @@ class FileManager:
|
|||||||
self.on_files_changed(self.filelist)
|
self.on_files_changed(self.filelist)
|
||||||
return file_obj
|
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:
|
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).
|
"""(Re)derive each movie's auto tags from its NFO (written by Jellyfin).
|
||||||
|
|
||||||
@@ -700,8 +914,8 @@ class FileManager:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def series_libraries(self) -> list[str]:
|
def series_libraries(self) -> list[str]:
|
||||||
"""Pool folders that hold series (the copy-as-is mirrors, e.g. Seriály)."""
|
"""Pool folders that can hold series — kind ``series`` or ``mixed``."""
|
||||||
return self.copyasis_folders
|
return self.libraries_of_kind("series", "mixed")
|
||||||
|
|
||||||
def load_series(self) -> list:
|
def load_series(self) -> list:
|
||||||
"""Scan every series library into a list of ``series.Series``."""
|
"""Scan every series library into a list of ``series.Series``."""
|
||||||
@@ -748,14 +962,14 @@ class FileManager:
|
|||||||
season, ep = code
|
season, ep = code
|
||||||
dest_dir = pool / lib / stem / season_folder_name(season)
|
dest_dir = pool / lib / stem / season_folder_name(season)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
target = dest_dir / episode_filename(title, season, ep, src.suffix)
|
target = dest_dir / episode_filename(season, ep, src.stem, src.suffix)
|
||||||
if target.exists():
|
if target.exists():
|
||||||
if on_conflict == "skip":
|
if on_conflict == "skip":
|
||||||
continue
|
continue
|
||||||
if on_conflict == "replace":
|
if on_conflict == "replace":
|
||||||
target.unlink()
|
target.unlink()
|
||||||
else: # suffix
|
else: # suffix
|
||||||
base = episode_filename(title, season, ep, "")
|
base = episode_filename(season, ep, src.stem, "")
|
||||||
counter = 1
|
counter = 1
|
||||||
while target.exists():
|
while target.exists():
|
||||||
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
||||||
@@ -789,7 +1003,7 @@ class FileManager:
|
|||||||
continue
|
continue
|
||||||
dest_dir = series.folder / season_folder_name(ep.season)
|
dest_dir = series.folder / season_folder_name(ep.season)
|
||||||
new_path = dest_dir / episode_filename(
|
new_path = dest_dir / episode_filename(
|
||||||
series.title, ep.season, ep.episode, ep.path.suffix)
|
ep.season, ep.episode, ep.path.stem, ep.path.suffix)
|
||||||
if new_path == ep.path:
|
if new_path == ep.path:
|
||||||
continue
|
continue
|
||||||
if new_path.exists():
|
if new_path.exists():
|
||||||
@@ -805,6 +1019,42 @@ class FileManager:
|
|||||||
conflicts.append((ep.path.name, str(exc)))
|
conflicts.append((ep.path.name, str(exc)))
|
||||||
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
||||||
|
|
||||||
|
def 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):
|
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
||||||
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
||||||
for f in files_objs:
|
for f in files_objs:
|
||||||
|
|||||||
@@ -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]
|
||||||
+24
-6
@@ -76,9 +76,27 @@ def season_folder_name(season: int) -> str:
|
|||||||
return "Specials" if season == 0 else f"Season {season:02d}"
|
return "Specials" if season == 0 else f"Season {season:02d}"
|
||||||
|
|
||||||
|
|
||||||
def episode_filename(series_title: str, season: int, episode: int, ext: str) -> str:
|
def episode_body(stem: str) -> str:
|
||||||
"""Canonical episode filename ``Series SxxEyy.ext``."""
|
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
|
||||||
return f"{series_title} S{season:02d}E{episode:02d}{ext}"
|
|
||||||
|
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:
|
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
|
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
|
||||||
|
|
||||||
|
|
||||||
def is_canonical_episode(path: Path, series_title: str) -> bool:
|
def is_canonical_episode(path: Path) -> bool:
|
||||||
"""True when ``path`` already follows ``Series SxxEyy`` in the right season."""
|
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
|
||||||
parsed = parse_episode(path.name)
|
parsed = parse_episode(path.name)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return False
|
return False
|
||||||
season, episode = parsed
|
season, episode = parsed
|
||||||
expected = episode_filename(series_title, season, episode, path.suffix)
|
expected = episode_filename(season, episode, path.stem, path.suffix)
|
||||||
if path.name != expected:
|
if path.name != expected:
|
||||||
return False
|
return False
|
||||||
return path.parent.name == season_folder_name(season)
|
return path.parent.name == season_folder_name(season)
|
||||||
|
|||||||
+565
-52
@@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
|
|||||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
|
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
|
||||||
QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog,
|
QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog,
|
||||||
|
QFormLayout, QSpinBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.core.file_manager import FileManager
|
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.core.tag import Tag
|
||||||
from src.constants import APP_TITLE
|
from src.constants import APP_TITLE
|
||||||
from src.core.hardlink_manager import HardlinkManager
|
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
|
from src.core import series
|
||||||
|
|
||||||
# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
|
# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
|
||||||
@@ -73,16 +75,17 @@ class ImportMoviesDialog(QDialog):
|
|||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
self.table = QTableWidget(0, 5)
|
self.table = QTableWidget(0, 6)
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
["Soubor", "Název", "Rok", "TMDb ID", ""])
|
["Soubor", "Název", "Rok", "TMDb ID", "ČSFD odkaz", ""])
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
header.setSectionResizeMode(3, 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)
|
layout.addWidget(self.table)
|
||||||
|
|
||||||
add_row = QHBoxLayout()
|
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.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))
|
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 = QPushButton("✕")
|
||||||
remove_btn.setFixedWidth(28)
|
remove_btn.setFixedWidth(28)
|
||||||
remove_btn.setToolTip("Odebrat tento soubor z importu")
|
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, 1, title_edit)
|
||||||
self.table.setCellWidget(row, 2, year_edit)
|
self.table.setCellWidget(row, 2, year_edit)
|
||||||
self.table.setCellWidget(row, 3, tmdb_edit)
|
self.table.setCellWidget(row, 3, tmdb_edit)
|
||||||
self.table.setCellWidget(row, 4, remove_btn)
|
self.table.setCellWidget(row, 4, csfd_edit)
|
||||||
self._rows.append((source, title_edit, year_edit, tmdb_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)
|
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:
|
for entry in self._rows:
|
||||||
if entry[1] is title_edit:
|
if entry[1] is title_edit:
|
||||||
return entry
|
return entry
|
||||||
@@ -174,7 +181,7 @@ class ImportMoviesDialog(QDialog):
|
|||||||
entry = self._row_of(title_edit)
|
entry = self._row_of(title_edit)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
return
|
return
|
||||||
_src, _title, year_edit, tmdb_edit = entry
|
_src, _title, year_edit, tmdb_edit, _csfd = entry
|
||||||
stem = self._candidate_stem(
|
stem = self._candidate_stem(
|
||||||
title_edit.text(), year_edit.text(), tmdb_edit.text())
|
title_edit.text(), year_edit.text(), tmdb_edit.text())
|
||||||
clashes = stem is not None and stem.lower() in self.existing_names
|
clashes = stem is not None and stem.lower() in self.existing_names
|
||||||
@@ -221,7 +228,7 @@ class ImportMoviesDialog(QDialog):
|
|||||||
found = 0
|
found = 0
|
||||||
QApplication.setOverrideCursor(Qt.WaitCursor)
|
QApplication.setOverrideCursor(Qt.WaitCursor)
|
||||||
try:
|
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:
|
try:
|
||||||
if tmdb_edit.text().strip().isdigit():
|
if tmdb_edit.text().strip().isdigit():
|
||||||
hit = tmdb.lookup_movie(int(tmdb_edit.text().strip()))
|
hit = tmdb.lookup_movie(int(tmdb_edit.text().strip()))
|
||||||
@@ -251,7 +258,7 @@ class ImportMoviesDialog(QDialog):
|
|||||||
def accept(self) -> None: # noqa: D401 — Qt override
|
def accept(self) -> None: # noqa: D401 — Qt override
|
||||||
"""Block the import until every row has a title and a four-digit year."""
|
"""Block the import until every row has a title and a four-digit year."""
|
||||||
bad_year: list[str] = []
|
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
|
title = title_edit.text().strip() or source.stem
|
||||||
if not is_valid_year(year_edit.text()):
|
if not is_valid_year(year_edit.text()):
|
||||||
bad_year.append(title)
|
bad_year.append(title)
|
||||||
@@ -273,13 +280,13 @@ class ImportMoviesDialog(QDialog):
|
|||||||
def selected_library(self) -> str:
|
def selected_library(self) -> str:
|
||||||
return self.library_combo.currentText()
|
return self.library_combo.currentText()
|
||||||
|
|
||||||
def entries(self) -> list[tuple[Path, str, str, str]]:
|
def entries(self) -> list[tuple[Path, str, str, str, str]]:
|
||||||
"""Return (source, title, year, tmdb_id) per row; title falls back to stem."""
|
"""Return (source, title, year, tmdb_id, csfd_url) per row."""
|
||||||
result: list[tuple[Path, str, str, str]] = []
|
result: list[tuple[Path, str, str, str, str]] = []
|
||||||
for source, title_edit, year_edit, tmdb_edit in self._rows:
|
for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows:
|
||||||
title = title_edit.text().strip() or source.stem
|
title = title_edit.text().strip() or source.stem
|
||||||
result.append(
|
result.append((source, title, year_edit.text().strip(),
|
||||||
(source, title, year_edit.text().strip(), tmdb_edit.text().strip()))
|
tmdb_edit.text().strip(), csfd_edit.text().strip()))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -462,10 +469,14 @@ class SeriesOverviewDialog(QDialog):
|
|||||||
self.tree = QTreeWidget()
|
self.tree = QTreeWidget()
|
||||||
self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"])
|
self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"])
|
||||||
self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
self.tree.itemDoubleClicked.connect(lambda _i, _c: self._edit_selected())
|
||||||
layout.addWidget(self.tree)
|
layout.addWidget(self.tree)
|
||||||
|
|
||||||
btns = QHBoxLayout()
|
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)
|
rename_btn.clicked.connect(self._rename_selected)
|
||||||
btns.addWidget(rename_btn)
|
btns.addWidget(rename_btn)
|
||||||
btns.addStretch(1)
|
btns.addStretch(1)
|
||||||
@@ -493,7 +504,7 @@ class SeriesOverviewDialog(QDialog):
|
|||||||
snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"])
|
snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"])
|
||||||
top.addChild(snode)
|
top.addChild(snode)
|
||||||
for ep in eps:
|
for ep in eps:
|
||||||
ok = series.is_canonical_episode(ep.path, ser.title)
|
ok = series.is_canonical_episode(ep.path)
|
||||||
enode = QTreeWidgetItem(
|
enode = QTreeWidgetItem(
|
||||||
[f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"])
|
[f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"])
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -519,6 +530,434 @@ class SeriesOverviewDialog(QDialog):
|
|||||||
f"Přejmenováno: {len(result['renamed'])}, "
|
f"Přejmenováno: {len(result['renamed'])}, "
|
||||||
f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.")
|
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):
|
class AssignTagsDialog(QDialog):
|
||||||
"""Tri-state bulk tag assignment over the selected movies.
|
"""Tri-state bulk tag assignment over the selected movies.
|
||||||
@@ -830,7 +1269,7 @@ class QtApp(QMainWindow):
|
|||||||
pool_menu = bar.addMenu("&Pool")
|
pool_menu = bar.addMenu("&Pool")
|
||||||
self._add_action(pool_menu, "Nastavit pool…", self.set_pool, "Ctrl+P")
|
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, "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()
|
pool_menu.addSeparator()
|
||||||
self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5")
|
self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5")
|
||||||
pool_menu.addSeparator()
|
pool_menu.addSeparator()
|
||||||
@@ -838,6 +1277,7 @@ class QtApp(QMainWindow):
|
|||||||
|
|
||||||
movie_menu = bar.addMenu("&Filmy")
|
movie_menu = bar.addMenu("&Filmy")
|
||||||
self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I")
|
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řejmenovat…", self.rename_movie, "F2")
|
||||||
self._add_action(movie_menu, "Přesunout do knihovny…", self.move_to_library)
|
self._add_action(movie_menu, "Přesunout do knihovny…", self.move_to_library)
|
||||||
self._add_action(
|
self._add_action(
|
||||||
@@ -856,7 +1296,6 @@ class QtApp(QMainWindow):
|
|||||||
film_menu = bar.addMenu("&miniDLNA")
|
film_menu = bar.addMenu("&miniDLNA")
|
||||||
self._add_action(
|
self._add_action(
|
||||||
film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G")
|
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")
|
jellyfin_menu = bar.addMenu("&Jellyfin")
|
||||||
self._add_action(jellyfin_menu, "Skenovat knihovnu", self.jellyfin_scan)
|
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)
|
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")
|
tests_menu = bar.addMenu("&Testy")
|
||||||
self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency)
|
self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency)
|
||||||
self._add_action(tests_menu, "Kontrola integrity videa…", self.check_video_integrity)
|
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.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
self.table.customContextMenuRequested.connect(self._show_table_menu)
|
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)
|
self.table.itemSelectionChanged.connect(self._update_selection_status)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(0, QHeaderView.Stretch) # Název
|
header.setSectionResizeMode(0, QHeaderView.Stretch) # Název
|
||||||
@@ -1036,7 +1479,7 @@ class QtApp(QMainWindow):
|
|||||||
self.library_filter.blockSignals(True)
|
self.library_filter.blockSignals(True)
|
||||||
self.library_filter.clear()
|
self.library_filter.clear()
|
||||||
self.library_filter.addItem("Vše")
|
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)
|
self.library_filter.addItem(name)
|
||||||
idx = self.library_filter.findText(current)
|
idx = self.library_filter.findText(current)
|
||||||
self.library_filter.setCurrentIndex(idx if idx >= 0 else 0)
|
self.library_filter.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
@@ -1094,6 +1537,7 @@ class QtApp(QMainWindow):
|
|||||||
|
|
||||||
def _show_table_menu(self, pos) -> None:
|
def _show_table_menu(self, pos) -> None:
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
menu.addAction("Upravit film…", self.edit_movie)
|
||||||
menu.addAction("Otevřít", self.open_movies)
|
menu.addAction("Otevřít", self.open_movies)
|
||||||
menu.addAction("Přejmenovat…", self.rename_movie)
|
menu.addAction("Přejmenovat…", self.rename_movie)
|
||||||
menu.addAction("Přesunout do knihovny…", self.move_to_library)
|
menu.addAction("Přesunout do knihovny…", self.move_to_library)
|
||||||
@@ -1163,7 +1607,7 @@ class QtApp(QMainWindow):
|
|||||||
sources = [Path(p) for p in paths]
|
sources = [Path(p) for p in paths]
|
||||||
existing_names = {Path(f.filename).stem for f in self.filehandler.filelist}
|
existing_names = {Path(f.filename).stem for f in self.filehandler.filelist}
|
||||||
dialog = ImportMoviesDialog(
|
dialog = ImportMoviesDialog(
|
||||||
self, sources, existing_names, self.filehandler.movie_libraries)
|
self, sources, existing_names, self.filehandler.libraries_accepting_movies)
|
||||||
if dialog.exec() != QDialog.Accepted:
|
if dialog.exec() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
entries = dialog.entries()
|
entries = dialog.entries()
|
||||||
@@ -1175,7 +1619,7 @@ class QtApp(QMainWindow):
|
|||||||
# Rows whose canonical "Title (year) [tmdbid]" name already exists
|
# Rows whose canonical "Title (year) [tmdbid]" name already exists
|
||||||
colliding = [
|
colliding = [
|
||||||
canonical_pool_stem(t, y, tid or None)
|
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)
|
if is_valid_year(y)
|
||||||
and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y, tid or None))
|
and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y, tid or None))
|
||||||
]
|
]
|
||||||
@@ -1188,11 +1632,12 @@ class QtApp(QMainWindow):
|
|||||||
imported: list[File] = []
|
imported: list[File] = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for source, title, year, tmdb_id in entries:
|
for source, title, year, tmdb_id, csfd_url in entries:
|
||||||
try:
|
try:
|
||||||
movie = self.filehandler.import_movie(
|
movie = self.filehandler.import_movie(
|
||||||
source, title, move=move, on_conflict=on_conflict,
|
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:
|
if movie is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
else:
|
else:
|
||||||
@@ -1325,6 +1770,29 @@ class QtApp(QMainWindow):
|
|||||||
self.refresh_table()
|
self.refresh_table()
|
||||||
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
|
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:
|
def rename_to_canonical_naming(self) -> None:
|
||||||
"""Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata."""
|
"""Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata."""
|
||||||
files = self._selected_movies() or self.filehandler.filelist
|
files = self._selected_movies() or self.filehandler.filelist
|
||||||
@@ -1418,6 +1886,40 @@ class QtApp(QMainWindow):
|
|||||||
return
|
return
|
||||||
SeriesOverviewDialog(self, self.filehandler).exec()
|
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:
|
def refresh_from_nfo(self) -> None:
|
||||||
"""(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk."""
|
"""(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk."""
|
||||||
files = self._selected_movies() or self.filehandler.filelist
|
files = self._selected_movies() or self.filehandler.filelist
|
||||||
@@ -1515,39 +2017,50 @@ class QtApp(QMainWindow):
|
|||||||
f"tipy +{tips}, nově +{recent}", 5000
|
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:
|
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:
|
if not self.filehandler.pool_dir:
|
||||||
QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.")
|
QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.")
|
||||||
return
|
return
|
||||||
current = ", ".join(self.filehandler.movie_libraries)
|
dialog = LibraryOrganizerDialog(self, self.filehandler.libraries)
|
||||||
text, ok = QInputDialog.getText(
|
if dialog.exec() != QDialog.Accepted:
|
||||||
self, "Knihovny (rozdělení)",
|
|
||||||
"Názvy filmových knihoven — složky v poolu (oddělené čárkou):",
|
|
||||||
text=current)
|
|
||||||
if not ok:
|
|
||||||
return
|
return
|
||||||
self.filehandler.set_movie_libraries(text.split(","))
|
self.filehandler.set_libraries(dialog.libraries())
|
||||||
pool = self.filehandler.pool_dir
|
pool = self.filehandler.pool_dir
|
||||||
for name in self.filehandler.movie_libraries:
|
for lib in self.filehandler.libraries:
|
||||||
(pool / name).mkdir(parents=True, exist_ok=True)
|
(pool / lib["name"]).mkdir(parents=True, exist_ok=True)
|
||||||
self._refresh_library_filter()
|
self._refresh_library_filter()
|
||||||
self.refresh_table()
|
self.refresh_table()
|
||||||
self.status.showMessage(
|
self._cleanup_orphan_folders()
|
||||||
"Knihovny: " + ", ".join(self.filehandler.movie_libraries), 5000)
|
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:
|
def move_to_library(self) -> None:
|
||||||
"""Move the selected movies into another library."""
|
"""Move the selected movies into another library."""
|
||||||
@@ -1555,7 +2068,7 @@ class QtApp(QMainWindow):
|
|||||||
if not files:
|
if not files:
|
||||||
QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.")
|
QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.")
|
||||||
return
|
return
|
||||||
libraries = self.filehandler.movie_libraries
|
libraries = self.filehandler.libraries_accepting_movies
|
||||||
target, ok = QInputDialog.getItem(
|
target, ok = QInputDialog.getItem(
|
||||||
self, "Přesunout do knihovny", "Cílová knihovna:", libraries, 0, False)
|
self, "Přesunout do knihovny", "Cílová knihovna:", libraries, 0, False)
|
||||||
if not ok or not target:
|
if not ok or not target:
|
||||||
|
|||||||
+137
-1
@@ -427,6 +427,92 @@ class TestPoolManagement:
|
|||||||
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||||
assert movie.title == "Matrix" # clean title, no year/id
|
assert movie.title == "Matrix" # clean title, no year/id
|
||||||
|
|
||||||
|
def test_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):
|
def test_import_into_named_library(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
file_manager.set_movie_libraries(["Filmy", "Kids"])
|
||||||
@@ -490,6 +576,52 @@ class TestPoolManagement:
|
|||||||
assert len(reloaded.filelist) == 1
|
assert len(reloaded.filelist) == 1
|
||||||
assert reloaded.filelist[0].file_path.suffix == ".mkv"
|
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):
|
def test_rename_to_canonical_preserves_tmdb_id(self, file_manager, tmp_path):
|
||||||
file_manager.set_pool_dir(tmp_path / "pool")
|
file_manager.set_pool_dir(tmp_path / "pool")
|
||||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||||
@@ -581,7 +713,11 @@ class TestPoolManagement:
|
|||||||
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
|
file_manager.set_copyasis_folders(["Seriály", "Dokumenty"])
|
||||||
|
|
||||||
assert "copyasis_folders" not in file_manager.global_config
|
assert "copyasis_folders" not in file_manager.global_config
|
||||||
assert file_manager.index.get_setting("copyasis_folders") == ["Seriály", "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
|
# a fresh manager over the same pool reads the stored settings back
|
||||||
reloaded = FileManager(TagManager())
|
reloaded = FileManager(TagManager())
|
||||||
|
|||||||
@@ -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"
|
||||||
+43
-9
@@ -29,8 +29,13 @@ def test_season_folder_name():
|
|||||||
assert season_folder_name(12) == "Season 12"
|
assert season_folder_name(12) == "Season 12"
|
||||||
|
|
||||||
|
|
||||||
def test_episode_filename():
|
def test_episode_filename_keeps_original_name():
|
||||||
assert episode_filename("Breaking Bad", 1, 2, ".mkv") == "Breaking Bad S01E02.mkv"
|
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():
|
def test_series_folder_stem():
|
||||||
@@ -42,12 +47,12 @@ def test_series_folder_stem():
|
|||||||
def test_is_canonical_episode(tmp_path):
|
def test_is_canonical_episode(tmp_path):
|
||||||
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||||
folder.mkdir(parents=True)
|
folder.mkdir(parents=True)
|
||||||
good = folder / "Breaking Bad S01E02.mkv"
|
good = folder / "S01E02 - bb.pilot.mkv"
|
||||||
good.write_bytes(b"x")
|
good.write_bytes(b"x")
|
||||||
bad = folder / "bb.s01e02.mkv"
|
bad = folder / "bb.s01e02.mkv"
|
||||||
bad.write_bytes(b"x")
|
bad.write_bytes(b"x")
|
||||||
assert is_canonical_episode(good, "Breaking Bad")
|
assert is_canonical_episode(good)
|
||||||
assert not is_canonical_episode(bad, "Breaking Bad")
|
assert not is_canonical_episode(bad)
|
||||||
|
|
||||||
|
|
||||||
def test_scan_series(tmp_path):
|
def test_scan_series(tmp_path):
|
||||||
@@ -89,8 +94,22 @@ class TestFileManagerSeries:
|
|||||||
assert result["imported"] == 2
|
assert result["imported"] == 2
|
||||||
base = (tmp_path / "pool" / "Seriály"
|
base = (tmp_path / "pool" / "Seriály"
|
||||||
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
|
||||||
assert (base / "Breaking Bad S01E01.mkv").exists()
|
assert (base / "S01E01 - bb.mkv").exists()
|
||||||
assert (base / "Breaking Bad S01E02.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):
|
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
|
||||||
(tmp_path / "random.mkv").write_bytes(b"x")
|
(tmp_path / "random.mkv").write_bytes(b"x")
|
||||||
@@ -110,5 +129,20 @@ class TestFileManagerSeries:
|
|||||||
assert len(series_list) == 1
|
assert len(series_list) == 1
|
||||||
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
||||||
|
|
||||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "Breaking Bad S01E03.mkv")]
|
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")]
|
||||||
assert (folder / "Season 01" / "Breaking Bad S01E03.mkv").exists()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user