Unify movies and series list, add TMDb-sourced tags and detail panel

This commit is contained in:
2026-09-09 07:13:36 +02:00
parent 79c9e34a5d
commit c0ad1c1553
5 changed files with 376 additions and 108 deletions
+57 -49
View File
@@ -21,66 +21,74 @@ 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 ### Added
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at - **Movies and series in one list.** The main table lists both as rows with a
import (new column in the movie import dialog) and editable in *Upravit film*. **Typ** column (Film / Seriál); size is the file size for a movie and the sum of
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD. 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 + - **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. 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 ### Changed
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**. - **Tags come from TMDb, not NFO.** *Filmy → "Načíst žánry z TMDb"*
(Genre tags come from the NFO; manual tag editing isn't needed in the Jellyfin (`FileManager.refresh_tags_from_tmdb`, `tmdb.movie_details`) fetches
flow.) 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` - **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The (was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
`SxxEyy` code is stripped from the original before re-applying, so repeated `SxxEyy` code is stripped from the original before re-applying, so repeated
renames don't double it (`series.episode_body`); `episode_filename` / renames don't double it (`series.episode_body`); `episode_filename` /
`is_canonical_episode` and the series import/rename/edit paths follow. `is_canonical_episode` and the series import/rename/edit paths follow.
### Added ### Fixed
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's - **Movies can live in mixed libraries.** The import/edit/move dialogs and the
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed / library filter offered only `movie`-kind libraries (falling back to a default
homevideo** — stored in a unified ``libraries`` setting in the pool index "Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
(`FileManager.libraries` / `set_libraries`; the legacy `movie_libraries` / a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**),
`copyasis_folders` are now derived from it and synthesised for old pools). and `load_pool_movies` scans a mixed library's **top-level** files as movies
Setting the pool creates a folder for every library. Replaces the separate (subfolders are series). Movies can be imported into / moved between mixed
"Knihovny (rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and libraries.
**homevideo** can be defined now; their scanning/generation lands in the next - **Cleanup of leftover library folders.** After editing libraries, Curator
phases (for now only movie + series libraries are processed). offers to delete **empty** pool folders that are no longer a library, and lists
- **Pool migration to TMDb** (phase 6; `src/core/migrate.py`, *Nástroje → non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
"Spárovat pool s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) `remove_empty_pool_folder`.
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
+41
View File
@@ -826,6 +826,47 @@ class FileManager:
self.on_files_changed(self.filelist) self.on_files_changed(self.filelist)
return file_obj 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( def match_pool_to_tmdb(
self, files: list[File] | None = None, self, files: list[File] | None = None,
on_progress: Callable[[int, int, File], None] | None = None, on_progress: Callable[[int, int, File], None] | None = None,
+41
View File
@@ -35,6 +35,33 @@ class TmdbResult:
kind: str # "movie" | "tv" 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): class TmdbError(RuntimeError):
"""A TMDb request could not be completed (no key, network, bad status).""" """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") 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]: def lookup_tv(tmdb_id: int) -> Optional[TmdbResult]:
"""Resolve a series by its TMDb id → name + first-air year, or None.""" """Resolve a series by its TMDb id → name + first-air year, or None."""
data = _get(f"/tv/{int(tmdb_id)}", {}) data = _get(f"/tv/{int(tmdb_id)}", {})
+197 -59
View File
@@ -1235,7 +1235,8 @@ class QtApp(QMainWindow):
super().__init__() super().__init__()
self.filehandler = filehandler self.filehandler = filehandler
self.tagmanager = tagmanager 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); # Active AND-filter as the source of truth (survives sidebar rebuilds);
# holds tag full_paths ("Category/Name"). # holds tag full_paths ("Category/Name").
self._active_filter: set[str] = set() self._active_filter: set[str] = set()
@@ -1256,9 +1257,14 @@ class QtApp(QMainWindow):
# Load the pool if one is configured # Load the pool if one is configured
if self.filehandler.movies_dir: if self.filehandler.movies_dir:
self.filehandler.load_pool_movies() self.filehandler.load_pool_movies()
self.reload_series()
self.refresh_sidebar() self.refresh_sidebar()
self.refresh_table() 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 # 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, "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 datum…", self.set_date, "Ctrl+D")
self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute) 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() movie_menu.addSeparator()
self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del")
@@ -1358,28 +1364,72 @@ class QtApp(QMainWindow):
search_row.addWidget(import_btn) search_row.addWidget(import_btn)
main_layout.addLayout(search_row) main_layout.addLayout(search_row)
self.table = QTableWidget(0, 4) self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(["Název", "Knihovna", "Štítky", "Velikost"]) self.table.setHorizontalHeaderLabels(
["Název", "Typ", "Knihovna", "Štítky", "Velikost"])
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
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.edit_movie()) self.table.doubleClicked.connect(lambda _: self.edit_item())
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
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Knihovna header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Typ
header.setSectionResizeMode(2, QHeaderView.Stretch) # Štítky header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Knihovna
header.setSectionResizeMode(3, QHeaderView.Stretch) # Štítky
main_layout.addWidget(self.table) main_layout.addWidget(self.table)
self._refresh_library_filter() self._refresh_library_filter()
splitter.addWidget(main) 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("<b>Detail filmu</b>"))
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(0, 0)
splitter.setStretchFactor(1, 1) splitter.setStretchFactor(1, 1)
splitter.setSizes([260, 940]) splitter.setStretchFactor(2, 0)
splitter.setSizes([230, 730, 260])
self.setCentralWidget(splitter) 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: def _build_statusbar(self) -> None:
self.status = self.statusBar() self.status = self.statusBar()
# Pool / Filmotéka paths + Jellyfin state as permanent widgets on the # 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 return None if text in ("", "Vše") else text
def refresh_table(self, *_args) -> None: def refresh_table(self, *_args) -> None:
# Tag filter (AND) drives both the table and the sidebar counts; the # Movies: tag (AND) + library + search filter. Series: library + search
# search box further narrows only the table. # (they carry no tags, so a tag filter hides them).
tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags()) tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags())
shown = tag_filtered
library = self._selected_library_filter() 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 "" 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)) movies = tag_filtered
self.file_rows.clear() if library:
for row, f in enumerate(shown): movies = [f for f in movies if self.filehandler.library_of(f) == library]
self.file_rows[row] = f if search:
name = f.title or f.filename movies = [f for f in movies if search in (f.title or f.filename).lower()]
lib = self.filehandler.library_of(f) or "—"
tags = ", ".join(t.name for t in f.tags) series = [] if self._active_filter else list(self.series_list)
try: if library:
size = self._format_size(f.file_path.stat().st_size) series = [s for s in series if s.library == library]
except OSError: if search:
size = "?" series = [s for s in series if search in s.title.lower()]
for col, value in enumerate([name, lib, tags, size]):
self.table.setItem(row, col, QTableWidgetItem(value)) 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.refresh_sidebar(tag_filtered)
self._update_selection_status() 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 @staticmethod
def _format_size(size_bytes: float) -> str: def _format_size(size_bytes: float) -> str:
@@ -1531,25 +1607,51 @@ class QtApp(QMainWindow):
size_bytes /= 1024.0 size_bytes /= 1024.0
return f"{size_bytes:.1f} TB" 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()} 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: 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 = QMenu(self)
menu.addAction("Upravit film…", self.edit_movie) # Series-only selection → series actions; otherwise movie actions.
menu.addAction("Otevřít", self.open_movies) if series and not any(isinstance(i, File) for i in items):
menu.addAction("Přejmenovat…", self.rename_movie) if len(series) == 1:
menu.addAction("Přesunout do knihovny…", self.move_to_library) menu.addAction(
menu.addAction("Přiřadit štítky…", self.assign_tags) "Upravit seriál…", lambda: self.edit_series(series[0]))
menu.addAction("Nastavit datum…", self.set_date) else:
menu.addAction("Nastavit atribut…", self.set_attribute) menu.addAction("Upravit film…", self.edit_movie)
menu.addAction("Načíst žánry z NFO", self.refresh_from_nfo) menu.addAction("Otevřít", self.open_movies)
menu.addSeparator() menu.addAction("Přejmenovat…", self.rename_movie)
menu.addAction("Odebrat z poolu…", self.remove_movies) 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)) menu.exec(self.table.viewport().mapToGlobal(pos))
def _update_selection_status(self) -> None: def _update_selection_status(self) -> None:
self._update_details()
files = self._selected_movies() files = self._selected_movies()
if not files: if not files:
self._update_path_status() self._update_path_status()
@@ -1579,6 +1681,7 @@ class QtApp(QMainWindow):
return return
self.filehandler.set_pool_dir(Path(folder)) self.filehandler.set_pool_dir(Path(folder))
self.filehandler.load_pool_movies() self.filehandler.load_pool_movies()
self.reload_series()
self._refresh_library_filter() self._refresh_library_filter()
self.refresh_table() self.refresh_table()
self._update_path_status() self._update_path_status()
@@ -1595,6 +1698,7 @@ class QtApp(QMainWindow):
QMessageBox.information(self, "Pool", "Nejprve nastavte pool.") QMessageBox.information(self, "Pool", "Nejprve nastavte pool.")
return return
self.filehandler.load_pool_movies() self.filehandler.load_pool_movies()
self.reload_series()
self.refresh_table() self.refresh_table()
def import_movie(self) -> None: def import_movie(self) -> None:
@@ -1793,6 +1897,25 @@ class QtApp(QMainWindow):
self.refresh_table() self.refresh_table()
self.status.showMessage(f"Upraveno: {f.filename}", 5000) 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: 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
@@ -1878,6 +2001,8 @@ class QtApp(QMainWindow):
QMessageBox.warning(self, "Import epizod s chybami", msg) QMessageBox.warning(self, "Import epizod s chybami", msg)
else: else:
QMessageBox.information(self, "Import epizod", msg) QMessageBox.information(self, "Import epizod", msg)
self.reload_series()
self.refresh_table()
self.status.showMessage(msg, 5000) self.status.showMessage(msg, 5000)
def show_series(self) -> None: def show_series(self) -> None:
@@ -1885,6 +2010,8 @@ class QtApp(QMainWindow):
QMessageBox.information(self, "Seriály", "Nejprve nastavte pool.") QMessageBox.information(self, "Seriály", "Nejprve nastavte pool.")
return return
SeriesOverviewDialog(self, self.filehandler).exec() SeriesOverviewDialog(self, self.filehandler).exec()
self.reload_series()
self.refresh_table()
def migrate_pool(self) -> None: def migrate_pool(self) -> None:
"""Match the existing pool against TMDb and assign ids (one-off migration).""" """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_library_filter()
self.refresh_table() self.refresh_table()
def refresh_from_nfo(self) -> None: def refresh_from_tmdb(self) -> None:
"""(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk.""" """(Re)load genre/year/rating tags from TMDb (by the tmdbid in the name)."""
files = self._selected_movies() or self.filehandler.filelist files = self._selected_movies() or self.filehandler.filelist
if not files: 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 return
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
result = self.filehandler.refresh_tags_from_nfo(files)
finally:
QApplication.restoreOverrideCursor()
self.refresh_table() self.refresh_table()
msg = (f"Načteno z NFO: {result['updated']}/{len(files)} filmů. " msg = (f"Načteno z TMDb: {result['updated']}/{len(files)} filmů. "
f"Bez NFO: {result['without_nfo']}.") f"Bez TMDb ID: {result['without_id']}.")
if result["without_nfo"] and not result["updated"]: if result["without_id"] and not result["updated"]:
msg += "\n\nNejspíš Jellyfin ještě neuložil NFO — nech ho naskenovat." msg += "\n\nFilmy nemají [tmdbid] v názvu — nejdřív je spáruj (import/migrace)."
QMessageBox.information(self, "Načíst žánry z NFO", msg) QMessageBox.information(self, "Načíst žánry z TMDb", msg)
self.status.showMessage(msg, 5000) self.status.showMessage(msg, 5000)
def remove_movies(self) -> None: def remove_movies(self) -> None:
@@ -1970,8 +2108,8 @@ class QtApp(QMainWindow):
if not files: if not files:
QMessageBox.information(self, "miniDLNA", "Pool je prázdný.") QMessageBox.information(self, "miniDLNA", "Pool je prázdný.")
return return
# Refresh genres/year from the NFO Jellyfin wrote, so the tree is current. # Refresh genres/year from TMDb so the genre tree is current.
self.filehandler.refresh_tags_from_nfo(files) self.filehandler.refresh_tags_from_tmdb(files)
manager = HardlinkManager(out) manager = HardlinkManager(out)
reserved = (set(self.filehandler.copyasis_folders) reserved = (set(self.filehandler.copyasis_folders)
| {TIPS_SUBFOLDER, RECENT_SUBFOLDER}) | {TIPS_SUBFOLDER, RECENT_SUBFOLDER})
@@ -2188,7 +2326,7 @@ class QtApp(QMainWindow):
self.refresh_table() # categories/sidebar may have changed self.refresh_table() # categories/sidebar may have changed
self.status.showMessage( self.status.showMessage(
f"Tag schéma uloženo ({len(dialog.result_schema)} kategorií). " 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, 8000,
) )
+40
View File
@@ -67,6 +67,46 @@ def test_search_movie_empty_query_skips_network(monkeypatch):
assert tmdb.search_movie(" ") == [] 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): def test_missing_key_raises(monkeypatch):
monkeypatch.delenv("TMDB_API_KEY", raising=False) monkeypatch.delenv("TMDB_API_KEY", raising=False)
with pytest.raises(TmdbError): with pytest.raises(TmdbError):