diff --git a/CHANGELOG.md b/CHANGELOG.md index 863b97c..0344b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,66 +21,74 @@ Each version entry uses these sections (include only those that apply): ## Unreleased -### Fixed -- **Movies can live in mixed libraries.** The import/edit/move dialogs and the - library filter offered only `movie`-kind libraries (falling back to a default - "Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put - a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**), - and `load_pool_movies` scans a mixed library's **top-level** files as movies - (its subfolders are series). Movies can be imported into / moved between mixed - libraries. -- **Cleanup of leftover library folders.** After editing libraries, Curator now - offers to delete **empty** pool folders that are no longer a library, and lists - non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` / - `remove_empty_pool_folder`. - ### Added -- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at - import (new column in the movie import dialog) and editable in *Upravit film*. - Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD. +- **Movies and series in one list.** The main table lists both as rows with a + **Typ** column (Film / Seriál); size is the file size for a movie and the sum of + the episodes for a series. Double-click (or the context menu) opens the right + editor by type — a movie → the movie parameters dialog, a series → the series + editor with the per-episode SxxEyy tree. The right detail panel handles both. + Library filter + search cover both; a tag filter narrows movies (series carry + no tags). +- **Detail panel on the right** of the table — the selected item's filename (or + series folder), Czech title, library and size (`_update_details`). +- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click) opens + one dialog for every Curator-owned movie parameter — title, year, TMDb ID (with + "Dohledat z TMDb"), library, date, attributes and a ČSFD link — applied at once + (rename `Title (rok) [tmdbid].ext`, move between libraries, set metadata) via + `FileManager.apply_movie_edits`. A series opens an editor (in *Přehled seriálů* + or from the list): edit its title/year/TMDb ID/library **and each episode's + season/episode number**, renaming the folder and laying episodes out canonically + (`FileManager.apply_series_edits`). +- **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`; legacy `movie_libraries` / + `copyasis_folders` are 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; homevideo scanning lands in a later phase. - **Series import into `mixed` libraries.** `series_libraries` (import target + - `load_series` scan) now covers kind `series` **and** `mixed`, so episodes can be + `load_series` scan) covers kind `series` **and** `mixed`, so episodes can be imported into a mixed library and show up in the series overview/editor. +- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at + import (a column in the movie import dialog) and editable in *Upravit film*. + Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD. +- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool + s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by + title + year and proposes an id, filling a missing year from TMDb. Conservative + classification — **jisté** (exact diacritics-insensitive title + matching year, + unique), **nejednoznačné** (pick from candidates), **bez shody**. A review + dialog applies the confident ones or the ones you pick → renames to + `Title (rok) [tmdbid].ext`. Already-migrated files are skipped (re-runnable). + `FileManager.match_pool_to_tmdb` / `assign_tmdb_id`. ### 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.) +- **Tags come from TMDb, not NFO.** *Filmy → "Načíst žánry z TMDb"* + (`FileManager.refresh_tags_from_tmdb`, `tmdb.movie_details`) fetches + genres/year/country/rating by the movie's tmdbid and regenerates its tags; + generating the miniDLNA tree refreshes from TMDb first. Genres come localized + (cs-CZ); production countries stay TMDb's English names. Removes the wait for + Jellyfin to write NFO before the genre tree is populated. +- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz** + (genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow). - **Episode filenames now keep the original name:** `SxxEyy - {original}.ext` (was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The `SxxEyy` code is stripped from the original before re-applying, so repeated renames don't double it (`series.episode_body`); `episode_filename` / `is_canonical_episode` and the series import/rename/edit paths follow. -### 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. +### 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 + (subfolders are series). Movies can be imported into / moved between mixed + libraries. +- **Cleanup of leftover library folders.** After editing libraries, Curator + 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`. ## 1.12.0 — 2026-08-31 diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 6f38ff3..f608d99 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -826,6 +826,47 @@ class FileManager: self.on_files_changed(self.filelist) return file_obj + def refresh_tags_from_tmdb( + self, files: list[File] | None = None, + on_progress: Callable[[int, int, File], None] | None = None, + should_cancel: Callable[[], bool] | None = None, + ) -> dict: + """(Re)derive each movie's tags from **TMDb** (by the tmdbid in its name). + + Fetches genres / year / country / rating from TMDb per the tag schema and + regenerates the auto tags (`File.apply_nfo`, which is source-agnostic); + user-added tags are kept. Movies with no tmdbid in the filename are skipped. + + Returns ``{ok, updated, without_id, errors}``. + """ + from . import tmdb + if not tmdb.api_available(): + return {"ok": False, "error": "TMDB_API_KEY není nastaven (.env).", + "updated": 0, "without_id": 0, "errors": []} + targets = files if files is not None else self.filelist + schema = self.tag_schema + updated, without_id = 0, 0 + errors: list[str] = [] + total = len(targets) + for i, f in enumerate(targets, 1): + if should_cancel is not None and should_cancel(): + break + tmdb_id = parse_tmdb_id(f.file_path.stem) + if tmdb_id is None: + without_id += 1 + else: + try: + f.apply_nfo(tmdb.movie_details(tmdb_id), schema) + updated += 1 + except tmdb.TmdbError as exc: + errors.append(f"{f.filename}: {exc}") + if on_progress is not None: + on_progress(i, total, f) + if updated and self.on_files_changed: + self.on_files_changed(self.filelist) + return {"ok": True, "updated": updated, "without_id": without_id, + "errors": errors} + def match_pool_to_tmdb( self, files: list[File] | None = None, on_progress: Callable[[int, int, File], None] | None = None, diff --git a/src/core/tmdb.py b/src/core/tmdb.py index 51bb809..ed29d66 100644 --- a/src/core/tmdb.py +++ b/src/core/tmdb.py @@ -35,6 +35,33 @@ class TmdbResult: kind: str # "movie" | "tv" +@dataclass +class TmdbDetails: + """Full TMDb data used to (re)generate a movie's tags (genres etc.). + + Exposes :meth:`field_values` so it plugs into the tag schema the same way the + NFO reader does (fields: genres / countries / year / rating). + """ + + tmdb_id: int + title: str + year: Optional[str] + genres: list[str] + countries: list[str] + rating: Optional[int] # 0–100 (TMDb vote_average 0–10 scaled up) + + def field_values(self, name: str) -> list[str]: + if name == "genres": + return list(self.genres) + if name == "countries": + return list(self.countries) + if name == "year": + return [self.year] if self.year else [] + if name == "rating": + return [str(self.rating)] if self.rating is not None else [] + return [] + + class TmdbError(RuntimeError): """A TMDb request could not be completed (no key, network, bad status).""" @@ -83,6 +110,20 @@ def lookup_movie(tmdb_id: int) -> Optional[TmdbResult]: return TmdbResult(int(data["id"]), title, _year_of(data.get("release_date")), "movie") +def movie_details(tmdb_id: int) -> TmdbDetails: + """Full movie data from TMDb (genres/countries/rating/year), localized to cs.""" + data = _get(f"/movie/{int(tmdb_id)}", {}) + title = data.get("title") or data.get("original_title") or "" + genres = [g["name"] for g in (data.get("genres") or []) if g.get("name")] + countries = [c["name"] for c in (data.get("production_countries") or []) + if c.get("name")] + vote = data.get("vote_average") + rating = round(float(vote) * 10) if vote else None + return TmdbDetails( + tmdb_id=int(data["id"]), title=title, year=_year_of(data.get("release_date")), + genres=genres, countries=countries, rating=rating) + + def lookup_tv(tmdb_id: int) -> Optional[TmdbResult]: """Resolve a series by its TMDb id → name + first-air year, or None.""" data = _get(f"/tv/{int(tmdb_id)}", {}) diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 2cf403c..134171c 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -1235,7 +1235,8 @@ class QtApp(QMainWindow): super().__init__() self.filehandler = filehandler self.tagmanager = tagmanager - self.file_rows: dict[int, File] = {} # table row -> File + self.row_items: dict[int, object] = {} # table row -> File or series.Series + self.series_list: list = [] # cached series (movies live in filehandler.filelist) # Active AND-filter as the source of truth (survives sidebar rebuilds); # holds tag full_paths ("Category/Name"). self._active_filter: set[str] = set() @@ -1256,9 +1257,14 @@ class QtApp(QMainWindow): # Load the pool if one is configured if self.filehandler.movies_dir: self.filehandler.load_pool_movies() + self.reload_series() self.refresh_sidebar() self.refresh_table() + def reload_series(self) -> None: + """Refresh the cached series list (scans the series/mixed libraries).""" + self.series_list = self.filehandler.load_series() if self.filehandler.pool_dir else [] + # ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------ @@ -1285,7 +1291,7 @@ class QtApp(QMainWindow): self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T") self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute) - self._add_action(movie_menu, "Načíst žánry z NFO", self.refresh_from_nfo) + self._add_action(movie_menu, "Načíst žánry z TMDb", self.refresh_from_tmdb) movie_menu.addSeparator() self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") @@ -1358,28 +1364,72 @@ class QtApp(QMainWindow): search_row.addWidget(import_btn) main_layout.addLayout(search_row) - self.table = QTableWidget(0, 4) - self.table.setHorizontalHeaderLabels(["Název", "Knihovna", "Štítky", "Velikost"]) + self.table = QTableWidget(0, 5) + self.table.setHorizontalHeaderLabels( + ["Název", "Typ", "Knihovna", "Štítky", "Velikost"]) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_table_menu) - self.table.doubleClicked.connect(lambda _: self.edit_movie()) + self.table.doubleClicked.connect(lambda _: self.edit_item()) self.table.itemSelectionChanged.connect(self._update_selection_status) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) # Název - header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Knihovna - header.setSectionResizeMode(2, QHeaderView.Stretch) # Štítky + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Typ + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Knihovna + header.setSectionResizeMode(3, QHeaderView.Stretch) # Štítky main_layout.addWidget(self.table) self._refresh_library_filter() - splitter.addWidget(main) + + # Right — details of the selected movie + inspector = QWidget() + ins_layout = QVBoxLayout(inspector) + ins_layout.setContentsMargins(8, 8, 8, 8) + ins_layout.addWidget(QLabel("Detail filmu")) + form = QFormLayout() + self.d_filename = QLabel("—") + self.d_filename.setWordWrap(True) + self.d_filename.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.d_title = QLabel("—") + self.d_title.setWordWrap(True) + self.d_library = QLabel("—") + self.d_size = QLabel("—") + form.addRow("Soubor:", self.d_filename) + form.addRow("Český název:", self.d_title) + form.addRow("Knihovna:", self.d_library) + form.addRow("Velikost:", self.d_size) + ins_layout.addLayout(form) + ins_layout.addStretch(1) + splitter.addWidget(inspector) + splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) - splitter.setSizes([260, 940]) + splitter.setStretchFactor(2, 0) + splitter.setSizes([230, 730, 260]) self.setCentralWidget(splitter) + def _update_details(self) -> None: + """Fill the right panel from the single selected item (movie or series).""" + if not hasattr(self, "d_filename"): + return + items = self._selected_items() + if len(items) != 1: + for label in (self.d_filename, self.d_title, self.d_library, self.d_size): + label.setText("—") + return + item = items[0] + if isinstance(item, File): + self.d_filename.setText(item.filename) + self.d_title.setText(item.title or "—") + self.d_library.setText(self.filehandler.library_of(item) or "—") + else: # series.Series + self.d_filename.setText(f"{item.folder.name} ({len(item.episodes)} epizod)") + self.d_title.setText(item.title or "—") + self.d_library.setText(item.library) + self.d_size.setText(self._item_size(item)) + def _build_statusbar(self) -> None: self.status = self.statusBar() # Pool / Filmotéka paths + Jellyfin state as permanent widgets on the @@ -1493,35 +1543,61 @@ class QtApp(QMainWindow): return None if text in ("", "Vše") else text def refresh_table(self, *_args) -> None: - # Tag filter (AND) drives both the table and the sidebar counts; the - # search box further narrows only the table. + # Movies: tag (AND) + library + search filter. Series: library + search + # (they carry no tags, so a tag filter hides them). tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags()) - shown = tag_filtered library = self._selected_library_filter() - if library: - shown = [f for f in shown if self.filehandler.library_of(f) == library] search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" - if search: - shown = [f for f in shown if search in (f.title or f.filename).lower()] - shown = sorted(shown, key=lambda f: (f.title or f.filename).lower()) - self.table.setRowCount(len(shown)) - self.file_rows.clear() - for row, f in enumerate(shown): - self.file_rows[row] = f - name = f.title or f.filename - lib = self.filehandler.library_of(f) or "—" - tags = ", ".join(t.name for t in f.tags) - try: - size = self._format_size(f.file_path.stat().st_size) - except OSError: - size = "?" - for col, value in enumerate([name, lib, tags, size]): - self.table.setItem(row, col, QTableWidgetItem(value)) + movies = tag_filtered + if library: + movies = [f for f in movies if self.filehandler.library_of(f) == library] + if search: + movies = [f for f in movies if search in (f.title or f.filename).lower()] + + series = [] if self._active_filter else list(self.series_list) + if library: + series = [s for s in series if s.library == library] + if search: + series = [s for s in series if search in s.title.lower()] + + def sort_key(item): + return (item.title or getattr(item, "filename", "")).lower() + + rows = sorted(movies + series, key=sort_key) + self.table.setRowCount(len(rows)) + self.row_items.clear() + for row, item in enumerate(rows): + self.row_items[row] = item + self._fill_row(row, item) self.refresh_sidebar(tag_filtered) self._update_selection_status() - self.status.showMessage(f"Zobrazeno {len(shown)} filmů", 4000) + self.status.showMessage( + f"Zobrazeno {len(movies)} filmů, {len(series)} seriálů", 4000) + + def _fill_row(self, row: int, item) -> None: + """Fill one table row from a movie (File) or a series.Series.""" + if isinstance(item, File): + name = item.title or item.filename + values = [name, "Film", self.filehandler.library_of(item) or "—", + ", ".join(t.name for t in item.tags), self._item_size(item)] + else: # series.Series + values = [item.title, "Seriál", item.library, + f"{len(item.episodes)} epizod", self._item_size(item)] + for col, value in enumerate(values): + self.table.setItem(row, col, QTableWidgetItem(value)) + + def _item_size(self, item) -> str: + """Formatted size of a movie file, or the sum over a series' episodes.""" + try: + if isinstance(item, File): + total = item.file_path.stat().st_size + else: + total = sum(ep.path.stat().st_size for ep in item.episodes) + return self._format_size(total) + except OSError: + return "?" @staticmethod def _format_size(size_bytes: float) -> str: @@ -1531,25 +1607,51 @@ class QtApp(QMainWindow): size_bytes /= 1024.0 return f"{size_bytes:.1f} TB" - def _selected_movies(self) -> List[File]: + def _selected_items(self) -> list: rows = {idx.row() for idx in self.table.selectionModel().selectedRows()} - return [self.file_rows[r] for r in sorted(rows) if r in self.file_rows] + return [self.row_items[r] for r in sorted(rows) if r in self.row_items] + + def _selected_movies(self) -> List[File]: + return [i for i in self._selected_items() if isinstance(i, File)] + + def _selected_series(self) -> list: + return [i for i in self._selected_items() if not isinstance(i, File)] + + def edit_item(self) -> None: + """Open the right editor for the single selected row (movie or series).""" + items = self._selected_items() + if len(items) != 1: + QMessageBox.information(self, "Upravit", "Vyber právě jednu položku.") + return + if isinstance(items[0], File): + self.edit_movie() + else: + self.edit_series(items[0]) def _show_table_menu(self, pos) -> None: + items = self._selected_items() + series = [i for i in items if not isinstance(i, File)] menu = QMenu(self) - menu.addAction("Upravit film…", self.edit_movie) - menu.addAction("Otevřít", self.open_movies) - menu.addAction("Přejmenovat…", self.rename_movie) - menu.addAction("Přesunout do knihovny…", self.move_to_library) - menu.addAction("Přiřadit štítky…", self.assign_tags) - menu.addAction("Nastavit datum…", self.set_date) - menu.addAction("Nastavit atribut…", self.set_attribute) - menu.addAction("Načíst žánry z NFO", self.refresh_from_nfo) - menu.addSeparator() - menu.addAction("Odebrat z poolu…", self.remove_movies) + # Series-only selection → series actions; otherwise movie actions. + if series and not any(isinstance(i, File) for i in items): + if len(series) == 1: + menu.addAction( + "Upravit seriál…", lambda: self.edit_series(series[0])) + else: + menu.addAction("Upravit film…", self.edit_movie) + menu.addAction("Otevřít", self.open_movies) + menu.addAction("Přejmenovat…", self.rename_movie) + menu.addAction("Přesunout do knihovny…", self.move_to_library) + menu.addAction("Přiřadit štítky…", self.assign_tags) + menu.addAction("Nastavit datum…", self.set_date) + menu.addAction("Nastavit atribut…", self.set_attribute) + menu.addAction("Načíst žánry z TMDb", self.refresh_from_tmdb) + menu.addSeparator() + menu.addAction("Odebrat z poolu…", self.remove_movies) menu.exec(self.table.viewport().mapToGlobal(pos)) def _update_selection_status(self) -> None: + self._update_details() files = self._selected_movies() if not files: self._update_path_status() @@ -1579,6 +1681,7 @@ class QtApp(QMainWindow): return self.filehandler.set_pool_dir(Path(folder)) self.filehandler.load_pool_movies() + self.reload_series() self._refresh_library_filter() self.refresh_table() self._update_path_status() @@ -1595,6 +1698,7 @@ class QtApp(QMainWindow): QMessageBox.information(self, "Pool", "Nejprve nastavte pool.") return self.filehandler.load_pool_movies() + self.reload_series() self.refresh_table() def import_movie(self) -> None: @@ -1793,6 +1897,25 @@ class QtApp(QMainWindow): self.refresh_table() self.status.showMessage(f"Upraveno: {f.filename}", 5000) + def edit_series(self, ser) -> None: + """Open the series editor (params + per-episode SxxEyy tree) for ``ser``.""" + dialog = SeriesEditDialog(self, ser, self.filehandler) + if dialog.exec() != QDialog.Accepted: + return + for episode, season, number in dialog.episode_numbers(): + episode.season, episode.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_series() + self._refresh_library_filter() + self.refresh_table() + self.status.showMessage(f"Seriál upraven: {ser.title}", 5000) + def rename_to_canonical_naming(self) -> None: """Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata.""" files = self._selected_movies() or self.filehandler.filelist @@ -1878,6 +2001,8 @@ class QtApp(QMainWindow): QMessageBox.warning(self, "Import epizod s chybami", msg) else: QMessageBox.information(self, "Import epizod", msg) + self.reload_series() + self.refresh_table() self.status.showMessage(msg, 5000) def show_series(self) -> None: @@ -1885,6 +2010,8 @@ class QtApp(QMainWindow): QMessageBox.information(self, "Seriály", "Nejprve nastavte pool.") return SeriesOverviewDialog(self, self.filehandler).exec() + self.reload_series() + self.refresh_table() def migrate_pool(self) -> None: """Match the existing pool against TMDb and assign ids (one-off migration).""" @@ -1920,23 +2047,34 @@ class QtApp(QMainWindow): self._refresh_library_filter() self.refresh_table() - def refresh_from_nfo(self) -> None: - """(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk.""" + def refresh_from_tmdb(self) -> None: + """(Re)load genre/year/rating tags from TMDb (by the tmdbid in the name).""" files = self._selected_movies() or self.filehandler.filelist if not files: - QMessageBox.information(self, "NFO", "Pool je prázdný.") + QMessageBox.information(self, "TMDb", "Pool je prázdný.") + return + progress = QProgressDialog("Načítám žánry z TMDb…", "Zrušit", 0, len(files), self) + progress.setWindowTitle("Načíst žánry z 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() + + result = self.filehandler.refresh_tags_from_tmdb( + files, on_progress=on_progress, should_cancel=progress.wasCanceled) + progress.setValue(len(files)) + if not result["ok"]: + QMessageBox.warning(self, "TMDb", result["error"]) return - QApplication.setOverrideCursor(Qt.WaitCursor) - try: - result = self.filehandler.refresh_tags_from_nfo(files) - finally: - QApplication.restoreOverrideCursor() self.refresh_table() - msg = (f"Načteno z NFO: {result['updated']}/{len(files)} filmů. " - f"Bez NFO: {result['without_nfo']}.") - if result["without_nfo"] and not result["updated"]: - msg += "\n\nNejspíš Jellyfin ještě neuložil NFO — nech ho naskenovat." - QMessageBox.information(self, "Načíst žánry z NFO", msg) + msg = (f"Načteno z TMDb: {result['updated']}/{len(files)} filmů. " + f"Bez TMDb ID: {result['without_id']}.") + if result["without_id"] and not result["updated"]: + msg += "\n\nFilmy nemají [tmdbid] v názvu — nejdřív je spáruj (import/migrace)." + QMessageBox.information(self, "Načíst žánry z TMDb", msg) self.status.showMessage(msg, 5000) def remove_movies(self) -> None: @@ -1970,8 +2108,8 @@ class QtApp(QMainWindow): if not files: QMessageBox.information(self, "miniDLNA", "Pool je prázdný.") return - # Refresh genres/year from the NFO Jellyfin wrote, so the tree is current. - self.filehandler.refresh_tags_from_nfo(files) + # Refresh genres/year from TMDb so the genre tree is current. + self.filehandler.refresh_tags_from_tmdb(files) manager = HardlinkManager(out) reserved = (set(self.filehandler.copyasis_folders) | {TIPS_SUBFOLDER, RECENT_SUBFOLDER}) @@ -2188,7 +2326,7 @@ class QtApp(QMainWindow): self.refresh_table() # categories/sidebar may have changed self.status.showMessage( f"Tag schéma uloženo ({len(dialog.result_schema)} kategorií). " - "Pro nové tagy spusťte „Načíst žánry z NFO\" a přegenerujte miniDLNA.", + "Pro nové tagy spusťte „Načíst žánry z TMDb\" a přegenerujte miniDLNA.", 8000, ) diff --git a/tests/test_tmdb.py b/tests/test_tmdb.py index 2e21717..496e633 100644 --- a/tests/test_tmdb.py +++ b/tests/test_tmdb.py @@ -67,6 +67,46 @@ def test_search_movie_empty_query_skips_network(monkeypatch): assert tmdb.search_movie(" ") == [] +def test_movie_details_maps_fields(monkeypatch): + payload = { + "id": 603, "title": "Matrix", "release_date": "1999-03-31", + "vote_average": 8.3, + "genres": [{"id": 28, "name": "Akční"}, {"id": 878, "name": "Vědeckofantastický"}], + "production_countries": [{"iso_3166_1": "US", "name": "United States of America"}], + } + monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload)) + d = tmdb.movie_details(603) + assert d.genres == ["Akční", "Vědeckofantastický"] + assert d.countries == ["United States of America"] + assert d.rating == 83 and d.year == "1999" + assert d.field_values("genres") == ["Akční", "Vědeckofantastický"] + assert d.field_values("rating") == ["83"] + + +def test_refresh_tags_from_tmdb(tmp_path, monkeypatch): + monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", tmp_path / ".gtag") + from src.core.tag_manager import TagManager + from src.core.file_manager import FileManager + from src.core.tmdb import TmdbDetails + + fm = FileManager(TagManager()) + fm.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"x") + (tmp_path / "b.mkv").write_bytes(b"y") + matched = fm.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603) + fm.import_movie(tmp_path / "b.mkv", "Bez ID", year=2000) # no tmdbid + + import src.core.tmdb as tmdb_mod + monkeypatch.setattr(tmdb_mod, "movie_details", lambda i: TmdbDetails( + int(i), "Matrix", "1999", ["Akční", "Sci-Fi"], ["USA"], 83)) + + result = fm.refresh_tags_from_tmdb() + assert result == {"ok": True, "updated": 1, "without_id": 1, "errors": []} + paths = {t.full_path for t in matched.tags} + assert {"Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"} <= paths + assert matched.meta == {"year": "1999", "rating": 83} + + def test_missing_key_raises(monkeypatch): monkeypatch.delenv("TMDB_API_KEY", raising=False) with pytest.raises(TmdbError):