Unify movies and series list, add TMDb-sourced tags and detail panel
This commit is contained in:
+197
-59
@@ -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("<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(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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user