Add edit dialogs, library organizer, and TMDb pool migration

This commit is contained in:
2026-09-08 07:37:40 +02:00
parent 39712d7283
commit 79c9e34a5d
10 changed files with 1371 additions and 110 deletions
+565 -52
View File
@@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog,
QFormLayout, QSpinBox,
)
from src.core.file_manager import FileManager
@@ -29,7 +30,8 @@ from src.core.file import File
from src.core.tag import Tag
from src.constants import APP_TITLE
from src.core.hardlink_manager import HardlinkManager
from src.core.naming import canonical_pool_stem, is_valid_year, year_from_filename
from src.core.naming import (
canonical_pool_stem, is_valid_year, parse_tmdb_id, year_from_filename)
from src.core import series
# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
@@ -73,16 +75,17 @@ class ImportMoviesDialog(QDialog):
info.setWordWrap(True)
layout.addWidget(info)
self.table = QTableWidget(0, 5)
self.table = QTableWidget(0, 6)
self.table.setHorizontalHeaderLabels(
["Soubor", "Název", "Rok", "TMDb ID", ""])
["Soubor", "Název", "Rok", "TMDb ID", "ČSFD odkaz", ""])
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
header.setSectionResizeMode(4, QHeaderView.Stretch)
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
layout.addWidget(self.table)
add_row = QHBoxLayout()
@@ -143,6 +146,9 @@ class ImportMoviesDialog(QDialog):
tmdb_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e))
tmdb_edit.textEdited.connect(lambda _t, e=tmdb_edit: self._set_suggested(e, False))
csfd_edit = QLineEdit()
csfd_edit.setPlaceholderText("ČSFD odkaz (volitelné, jen uloží se)")
remove_btn = QPushButton("✕")
remove_btn.setFixedWidth(28)
remove_btn.setToolTip("Odebrat tento soubor z importu")
@@ -150,11 +156,12 @@ class ImportMoviesDialog(QDialog):
self.table.setCellWidget(row, 1, title_edit)
self.table.setCellWidget(row, 2, year_edit)
self.table.setCellWidget(row, 3, tmdb_edit)
self.table.setCellWidget(row, 4, remove_btn)
self._rows.append((source, title_edit, year_edit, tmdb_edit))
self.table.setCellWidget(row, 4, csfd_edit)
self.table.setCellWidget(row, 5, remove_btn)
self._rows.append((source, title_edit, year_edit, tmdb_edit, csfd_edit))
self._mark_collision(title_edit)
def _row_of(self, title_edit: QLineEdit) -> tuple[Path, QLineEdit, QLineEdit, QLineEdit] | None:
def _row_of(self, title_edit: QLineEdit):
for entry in self._rows:
if entry[1] is title_edit:
return entry
@@ -174,7 +181,7 @@ class ImportMoviesDialog(QDialog):
entry = self._row_of(title_edit)
if entry is None:
return
_src, _title, year_edit, tmdb_edit = entry
_src, _title, year_edit, tmdb_edit, _csfd = entry
stem = self._candidate_stem(
title_edit.text(), year_edit.text(), tmdb_edit.text())
clashes = stem is not None and stem.lower() in self.existing_names
@@ -221,7 +228,7 @@ class ImportMoviesDialog(QDialog):
found = 0
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
for _src, title_edit, year_edit, tmdb_edit in self._rows:
for _src, title_edit, year_edit, tmdb_edit, _csfd in self._rows:
try:
if tmdb_edit.text().strip().isdigit():
hit = tmdb.lookup_movie(int(tmdb_edit.text().strip()))
@@ -251,7 +258,7 @@ class ImportMoviesDialog(QDialog):
def accept(self) -> None: # noqa: D401 — Qt override
"""Block the import until every row has a title and a four-digit year."""
bad_year: list[str] = []
for source, title_edit, year_edit, _tmdb in self._rows:
for source, title_edit, year_edit, _tmdb, _csfd in self._rows:
title = title_edit.text().strip() or source.stem
if not is_valid_year(year_edit.text()):
bad_year.append(title)
@@ -273,13 +280,13 @@ class ImportMoviesDialog(QDialog):
def selected_library(self) -> str:
return self.library_combo.currentText()
def entries(self) -> list[tuple[Path, str, str, str]]:
"""Return (source, title, year, tmdb_id) per row; title falls back to stem."""
result: list[tuple[Path, str, str, str]] = []
for source, title_edit, year_edit, tmdb_edit in self._rows:
def entries(self) -> list[tuple[Path, str, str, str, str]]:
"""Return (source, title, year, tmdb_id, csfd_url) per row."""
result: list[tuple[Path, str, str, str, str]] = []
for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows:
title = title_edit.text().strip() or source.stem
result.append(
(source, title, year_edit.text().strip(), tmdb_edit.text().strip()))
result.append((source, title, year_edit.text().strip(),
tmdb_edit.text().strip(), csfd_edit.text().strip()))
return result
@@ -462,10 +469,14 @@ class SeriesOverviewDialog(QDialog):
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"])
self.tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
self.tree.itemDoubleClicked.connect(lambda _i, _c: self._edit_selected())
layout.addWidget(self.tree)
btns = QHBoxLayout()
rename_btn = QPushButton("Přejmenovat epizody vybraného seriálu")
edit_btn = QPushButton("Upravit seriál…")
edit_btn.clicked.connect(self._edit_selected)
btns.addWidget(edit_btn)
rename_btn = QPushButton("Přejmenovat epizody")
rename_btn.clicked.connect(self._rename_selected)
btns.addWidget(rename_btn)
btns.addStretch(1)
@@ -493,7 +504,7 @@ class SeriesOverviewDialog(QDialog):
snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"])
top.addChild(snode)
for ep in eps:
ok = series.is_canonical_episode(ep.path, ser.title)
ok = series.is_canonical_episode(ep.path)
enode = QTreeWidgetItem(
[f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"])
if not ok:
@@ -519,6 +530,434 @@ class SeriesOverviewDialog(QDialog):
f"Přejmenováno: {len(result['renamed'])}, "
f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.")
def _edit_selected(self) -> None:
idx = self._selected_series_index()
if idx is None:
QMessageBox.information(self, "Seriály", "Vyber seriál.")
return
ser = self._series[idx]
dialog = SeriesEditDialog(self, ser, self.filehandler)
if dialog.exec() != QDialog.Accepted:
return
# 1) apply per-episode season/episode overrides, 2) normalize episode files,
# 3) rename/move the series folder + retitle episodes to the new name.
for ep, season, number in dialog.episode_numbers():
ep.season, ep.episode = season, number
self.filehandler.rename_episodes_to_canonical(ser)
try:
self.filehandler.apply_series_edits(
ser, title=dialog.title, year=dialog.year or None,
tmdb_id=dialog.tmdb_id or None, library=dialog.library)
except (ValueError, FileExistsError, OSError) as exc:
QMessageBox.warning(self, "Úprava seriálu selhala", str(exc))
self._reload()
class MovieEditDialog(QDialog):
"""One dialog for every editable parameter of a movie."""
def __init__(self, parent: QWidget, file_obj: File, filehandler: FileManager) -> None:
super().__init__(parent)
self.setWindowTitle("Upravit film")
self.setMinimumWidth(540)
self.file_obj = file_obj
self.filehandler = filehandler
ctx = file_obj.name_context()
layout = QVBoxLayout(self)
form = QFormLayout()
self.title_edit = QLineEdit(file_obj.title or file_obj.file_path.stem)
form.addRow("Název:", self.title_edit)
self.year_edit = QLineEdit(str(ctx.get("year") or ""))
self.year_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit))
form.addRow("Rok:", self.year_edit)
tmdb_row = QHBoxLayout()
self.tmdb_edit = QLineEdit(str(parse_tmdb_id(file_obj.file_path.stem) or ""))
self.tmdb_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit))
tmdb_row.addWidget(self.tmdb_edit)
find_btn = QPushButton("Dohledat z TMDb")
find_btn.clicked.connect(self._lookup)
tmdb_row.addWidget(find_btn)
tmdb_wrap = QWidget()
tmdb_wrap.setLayout(tmdb_row)
form.addRow("TMDb ID:", tmdb_wrap)
self.library_combo = QComboBox()
self.library_combo.addItems(filehandler.libraries_accepting_movies)
cur_lib = filehandler.library_of(file_obj)
if cur_lib:
i = self.library_combo.findText(cur_lib)
if i >= 0:
self.library_combo.setCurrentIndex(i)
form.addRow("Knihovna:", self.library_combo)
self.date_edit = QLineEdit(file_obj.date or "")
self.date_edit.setPlaceholderText("YYYY-MM-DD")
form.addRow("Datum přidání:", self.date_edit)
self.csfd_edit = QLineEdit(file_obj.csfd_url or "")
self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/… (jen uloží se)")
form.addRow("ČSFD odkaz:", self.csfd_edit)
layout.addLayout(form)
layout.addWidget(QLabel("Atributy (pro šablony názvů, např. collection_sort):"))
self.attr_table = QTableWidget(0, 2)
self.attr_table.setHorizontalHeaderLabels(["Klíč", "Hodnota"])
self.attr_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.attr_table.setMaximumHeight(140)
for key, value in file_obj.attributes.items():
self._add_attr(key, value)
layout.addWidget(self.attr_table)
attr_btns = QHBoxLayout()
add_a = QPushButton("➕ Atribut")
add_a.clicked.connect(lambda: self._add_attr("", ""))
del_a = QPushButton("➖ Odebrat")
del_a.clicked.connect(self._remove_attr)
attr_btns.addWidget(add_a)
attr_btns.addWidget(del_a)
attr_btns.addStretch(1)
layout.addLayout(attr_btns)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _add_attr(self, key: str, value: str) -> None:
row = self.attr_table.rowCount()
self.attr_table.insertRow(row)
self.attr_table.setItem(row, 0, QTableWidgetItem(key))
self.attr_table.setItem(row, 1, QTableWidgetItem(value))
def _remove_attr(self) -> None:
row = self.attr_table.currentRow()
if row >= 0:
self.attr_table.removeRow(row)
def _lookup(self) -> None:
from src.core import tmdb
if not tmdb.api_available():
QMessageBox.warning(self, "TMDb", "Není nastaven TMDB_API_KEY (.env).")
return
try:
if self.tmdb_edit.text().strip().isdigit():
hit = tmdb.lookup_movie(int(self.tmdb_edit.text().strip()))
else:
hits = tmdb.search_movie(
self.title_edit.text(),
year=self.year_edit.text().strip() or None, limit=1)
hit = hits[0] if hits else None
except tmdb.TmdbError as exc:
QMessageBox.warning(self, "TMDb", str(exc))
return
if hit is None:
QMessageBox.information(self, "TMDb", "Nic nenalezeno.")
return
self.tmdb_edit.setText(str(hit.tmdb_id))
self.title_edit.setText(hit.title)
if hit.year:
self.year_edit.setText(hit.year)
# --- getters consumed by the handler ---
@property
def title(self) -> str:
return self.title_edit.text().strip()
@property
def year(self) -> str:
return self.year_edit.text().strip()
@property
def tmdb_id(self) -> str:
return self.tmdb_edit.text().strip()
@property
def library(self) -> str:
return self.library_combo.currentText()
@property
def date(self) -> str:
return self.date_edit.text().strip()
@property
def attributes(self) -> dict:
out: dict[str, str] = {}
for row in range(self.attr_table.rowCount()):
key_item = self.attr_table.item(row, 0)
val_item = self.attr_table.item(row, 1)
key = key_item.text().strip() if key_item else ""
if key:
out[key] = val_item.text().strip() if val_item else ""
return out
@property
def csfd_url(self) -> str:
return self.csfd_edit.text().strip()
class SeriesEditDialog(QDialog):
"""Edit a series (title/year/id/library) and each episode's SxxEyy."""
def __init__(self, parent: QWidget, ser, filehandler: FileManager) -> None:
super().__init__(parent)
self.setWindowTitle(f"Upravit seriál — {ser.title}")
self.setMinimumSize(640, 520)
self.series = ser
self._spins: list[tuple] = [] # (episode, season_spin, episode_spin)
layout = QVBoxLayout(self)
form = QFormLayout()
self.title_edit = QLineEdit(ser.title)
form.addRow("Název:", self.title_edit)
self.year_edit = QLineEdit(ser.year or "")
self.year_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit))
form.addRow("Rok:", self.year_edit)
self.tmdb_edit = QLineEdit(str(ser.tmdb_id or ""))
self.tmdb_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit))
form.addRow("TMDb ID:", self.tmdb_edit)
self.library_combo = QComboBox()
self.library_combo.addItems(filehandler.series_libraries)
i = self.library_combo.findText(ser.library)
if i >= 0:
self.library_combo.setCurrentIndex(i)
form.addRow("Knihovna:", self.library_combo)
layout.addLayout(form)
layout.addWidget(QLabel("Epizody — uprav sezónu/díl (uloží se jako SxxEyy):"))
table = QTableWidget(0, 3)
table.setHorizontalHeaderLabels(["Soubor", "Sezóna", "Díl"])
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
for ep in ser.episodes:
row = table.rowCount()
table.insertRow(row)
name_item = QTableWidgetItem(ep.path.name)
name_item.setFlags(Qt.ItemIsEnabled)
table.setItem(row, 0, name_item)
season_spin = QSpinBox()
season_spin.setRange(0, 99)
season_spin.setValue(ep.season if ep.season is not None else 1)
table.setCellWidget(row, 1, season_spin)
ep_spin = QSpinBox()
ep_spin.setRange(0, 999)
ep_spin.setValue(ep.episode if ep.episode is not None else 1)
table.setCellWidget(row, 2, ep_spin)
self._spins.append((ep, season_spin, ep_spin))
layout.addWidget(table)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def accept(self) -> None: # noqa: D401 — Qt override
if not self.title_edit.text().strip():
QMessageBox.warning(self, "Seriál", "Zadej název seriálu.")
return
super().accept()
def episode_numbers(self) -> list[tuple]:
return [(ep, s.value(), e.value()) for ep, s, e in self._spins]
@property
def title(self) -> str:
return self.title_edit.text().strip()
@property
def year(self) -> str:
return self.year_edit.text().strip()
@property
def tmdb_id(self) -> str:
return self.tmdb_edit.text().strip()
@property
def library(self) -> str:
return self.library_combo.currentText()
class LibraryOrganizerDialog(QDialog):
"""Manage the pool's libraries (the top-level folders): name + kind."""
KINDS = [
("Filmy (jednotlivé filmy)", "movie"),
("Seriály", "series"),
("Mix (filmy + seriály)", "mixed"),
("Domácí videa (bez TMDb)", "homevideo"),
]
def __init__(self, parent: QWidget, libraries: list[dict]) -> None:
super().__init__(parent)
self.setWindowTitle("Knihovny poolu")
self.setMinimumSize(560, 420)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(
"Knihovny = složky v kořeni poolu, každá je jedna knihovna v Jellyfinu.\n"
"Typ určuje, jak Curator složku zpracuje."))
self.table = QTableWidget(0, 2)
self.table.setHorizontalHeaderLabels(["Název složky", "Typ"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
layout.addWidget(self.table)
for lib in libraries:
self._add_row(lib.get("name", ""), lib.get("kind", "movie"))
row = QHBoxLayout()
add_btn = QPushButton("➕ Přidat knihovnu")
add_btn.clicked.connect(lambda: self._add_row("", "movie"))
del_btn = QPushButton("➖ Odebrat")
del_btn.clicked.connect(self._remove_row)
row.addWidget(add_btn)
row.addWidget(del_btn)
row.addStretch(1)
layout.addLayout(row)
note = QLabel(
"Pozn.: „Mix“ zpracuje filmy (v kořeni) i seriály (v podsložkách); "
"„Domácí videa“ přibudou v další fázi. Po uložení nabídnu smazání "
"prázdných složek, které už nejsou knihovnou (s obsahem nechám být).")
note.setWordWrap(True)
note.setStyleSheet("color: #888;")
layout.addWidget(note)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _add_row(self, name: str, kind: str) -> None:
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(name))
combo = QComboBox()
for label, value in self.KINDS:
combo.addItem(label, value)
idx = next((i for i, (_l, v) in enumerate(self.KINDS) if v == kind), 0)
combo.setCurrentIndex(idx)
self.table.setCellWidget(row, 1, combo)
def _remove_row(self) -> None:
row = self.table.currentRow()
if row >= 0:
self.table.removeRow(row)
def libraries(self) -> list[dict]:
result: list[dict] = []
for row in range(self.table.rowCount()):
name_item = self.table.item(row, 0)
name = name_item.text().strip() if name_item else ""
if not name:
continue
combo = self.table.cellWidget(row, 1)
result.append({"name": name, "kind": combo.currentData()})
return result
class MigrateDialog(QDialog):
"""Review TMDb match proposals for an existing pool and apply the ids."""
_STATUS = {"auto": "jisté", "ambiguous": "nejednoznačné",
"no_match": "bez shody", "error": "chyba"}
def __init__(self, parent: QWidget, filehandler: FileManager,
proposals: list) -> None:
super().__init__(parent)
self.setWindowTitle("Spárovat pool s TMDb (migrace)")
self.setMinimumSize(820, 560)
self.filehandler = filehandler
self._rows: list[tuple] = [] # (proposal, combo | None)
counts: dict[str, int] = {}
for p in proposals:
counts[p.status] = counts.get(p.status, 0) + 1
layout = QVBoxLayout(self)
layout.addWidget(QLabel(
"Návrhy párování (zelené jisté jsou předvybrané). Zkontroluj, uprav "
"u nejednoznačných a přejmenuj. "
+ " · ".join(f"{self._STATUS.get(k, k)}: {v}"
for k, v in sorted(counts.items()))))
table = QTableWidget(len(proposals), 3)
table.setHorizontalHeaderLabels(["Soubor", "Stav", "Kandidát z TMDb"])
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
for row, p in enumerate(proposals):
name_item = QTableWidgetItem(p.current_name)
name_item.setFlags(Qt.ItemIsEnabled)
table.setItem(row, 0, name_item)
status_item = QTableWidgetItem(self._STATUS.get(p.status, p.status))
status_item.setFlags(Qt.ItemIsEnabled)
if p.status == "auto":
status_item.setForeground(Qt.darkGreen)
elif p.status in ("no_match", "error"):
status_item.setForeground(Qt.red)
table.setItem(row, 1, status_item)
if p.candidates:
combo = QComboBox()
for c in p.candidates:
combo.addItem(
f"{c.title} ({c.year or '?'}) · tmdb {c.tmdb_id}", c)
combo.addItem("(přeskočit)", None)
if p.chosen is not None:
idx = next((i for i, c in enumerate(p.candidates)
if c.tmdb_id == p.chosen.tmdb_id), 0)
combo.setCurrentIndex(idx if p.status == "auto" else idx)
if p.status != "auto":
combo.setCurrentIndex(combo.count() - 1) # default: skip
table.setCellWidget(row, 2, combo)
self._rows.append((p, combo))
else:
cell = QTableWidgetItem(p.error or "nic nenalezeno")
cell.setFlags(Qt.ItemIsEnabled)
table.setItem(row, 2, cell)
self._rows.append((p, None))
layout.addWidget(table)
btns = QHBoxLayout()
auto_btn = QPushButton("Přejmenovat jen jisté")
auto_btn.clicked.connect(lambda: self._apply(only_auto=True))
btns.addWidget(auto_btn)
sel_btn = QPushButton("Přejmenovat vybrané")
sel_btn.clicked.connect(lambda: self._apply(only_auto=False))
btns.addWidget(sel_btn)
btns.addStretch(1)
close_btn = QPushButton("Zavřít")
close_btn.clicked.connect(self.accept)
btns.addWidget(close_btn)
layout.addLayout(btns)
def _apply(self, only_auto: bool) -> None:
done = 0
errors: list[str] = []
for proposal, combo in self._rows:
if only_auto and proposal.status != "auto":
continue
candidate = combo.currentData() if combo is not None else None
if candidate is None:
continue
try:
self.filehandler.assign_tmdb_id(
proposal.file, candidate.tmdb_id, candidate.year)
done += 1
except (ValueError, FileExistsError, OSError) as exc:
errors.append(f"{proposal.current_name}: {exc}")
msg = f"Přejmenováno {done} filmů."
if errors:
msg += "\n\nChyby:\n" + "\n".join(errors[:8])
QMessageBox.warning(self, "Migrace s chybami", msg)
else:
QMessageBox.information(self, "Migrace", msg)
self.accept()
class AssignTagsDialog(QDialog):
"""Tri-state bulk tag assignment over the selected movies.
@@ -830,7 +1269,7 @@ class QtApp(QMainWindow):
pool_menu = bar.addMenu("&Pool")
self._add_action(pool_menu, "Nastavit pool…", self.set_pool, "Ctrl+P")
self._add_action(pool_menu, "Nastavit Filmotéku (výstup)…", self.set_filmoteka)
self._add_action(pool_menu, "Knihovny (rozdělení)…", self.edit_libraries)
self._add_action(pool_menu, "Knihovny…", self.edit_libraries)
pool_menu.addSeparator()
self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5")
pool_menu.addSeparator()
@@ -838,6 +1277,7 @@ class QtApp(QMainWindow):
movie_menu = bar.addMenu("&Filmy")
self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I")
self._add_action(movie_menu, "Upravit film…", self.edit_movie, "Return")
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
self._add_action(movie_menu, "Přesunout do knihovny…", self.move_to_library)
self._add_action(
@@ -856,7 +1296,6 @@ class QtApp(QMainWindow):
film_menu = bar.addMenu("&miniDLNA")
self._add_action(
film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G")
self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders)
jellyfin_menu = bar.addMenu("&Jellyfin")
self._add_action(jellyfin_menu, "Skenovat knihovnu", self.jellyfin_scan)
@@ -869,6 +1308,10 @@ class QtApp(QMainWindow):
self._add_action(settings_menu, "Statistiky…", self.show_statistics)
tools_menu = bar.addMenu("&Nástroje")
self._add_action(
tools_menu, "Spárovat pool s TMDb (migrace)…", self.migrate_pool)
tests_menu = bar.addMenu("&Testy")
self._add_action(tests_menu, "Kontrola konzistence dat…", self.check_consistency)
self._add_action(tests_menu, "Kontrola integrity videa…", self.check_video_integrity)
@@ -922,7 +1365,7 @@ class QtApp(QMainWindow):
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._show_table_menu)
self.table.doubleClicked.connect(lambda _: self.open_movies())
self.table.doubleClicked.connect(lambda _: self.edit_movie())
self.table.itemSelectionChanged.connect(self._update_selection_status)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch) # Název
@@ -1036,7 +1479,7 @@ class QtApp(QMainWindow):
self.library_filter.blockSignals(True)
self.library_filter.clear()
self.library_filter.addItem("Vše")
for name in self.filehandler.movie_libraries:
for name in self.filehandler.libraries_accepting_movies:
self.library_filter.addItem(name)
idx = self.library_filter.findText(current)
self.library_filter.setCurrentIndex(idx if idx >= 0 else 0)
@@ -1094,6 +1537,7 @@ class QtApp(QMainWindow):
def _show_table_menu(self, pos) -> None:
menu = QMenu(self)
menu.addAction("Upravit film…", self.edit_movie)
menu.addAction("Otevřít", self.open_movies)
menu.addAction("Přejmenovat…", self.rename_movie)
menu.addAction("Přesunout do knihovny…", self.move_to_library)
@@ -1163,7 +1607,7 @@ class QtApp(QMainWindow):
sources = [Path(p) for p in paths]
existing_names = {Path(f.filename).stem for f in self.filehandler.filelist}
dialog = ImportMoviesDialog(
self, sources, existing_names, self.filehandler.movie_libraries)
self, sources, existing_names, self.filehandler.libraries_accepting_movies)
if dialog.exec() != QDialog.Accepted:
return
entries = dialog.entries()
@@ -1175,7 +1619,7 @@ class QtApp(QMainWindow):
# Rows whose canonical "Title (year) [tmdbid]" name already exists
colliding = [
canonical_pool_stem(t, y, tid or None)
for s, t, y, tid in entries
for s, t, y, tid, _c in entries
if is_valid_year(y)
and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y, tid or None))
]
@@ -1188,11 +1632,12 @@ class QtApp(QMainWindow):
imported: list[File] = []
skipped = 0
errors: list[str] = []
for source, title, year, tmdb_id in entries:
for source, title, year, tmdb_id, csfd_url in entries:
try:
movie = self.filehandler.import_movie(
source, title, move=move, on_conflict=on_conflict,
year=year or None, tmdb_id=tmdb_id or None, library=library)
year=year or None, tmdb_id=tmdb_id or None, library=library,
csfd_url=csfd_url or None)
if movie is None:
skipped += 1
else:
@@ -1325,6 +1770,29 @@ class QtApp(QMainWindow):
self.refresh_table()
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
def edit_movie(self) -> None:
"""Open the consolidated edit dialog for the selected movie."""
files = self._selected_movies()
if len(files) != 1:
QMessageBox.information(self, "Upravit film", "Vyber právě jeden film.")
return
f = files[0]
dialog = MovieEditDialog(self, f, self.filehandler)
if dialog.exec() != QDialog.Accepted:
return
try:
self.filehandler.apply_movie_edits(
f, title=dialog.title, year=dialog.year or None,
tmdb_id=dialog.tmdb_id or None, library=dialog.library,
date=dialog.date, attributes=dialog.attributes,
csfd_url=dialog.csfd_url)
except (ValueError, FileExistsError, OSError) as exc:
QMessageBox.warning(self, "Úprava selhala", str(exc))
return
self._refresh_library_filter()
self.refresh_table()
self.status.showMessage(f"Upraveno: {f.filename}", 5000)
def rename_to_canonical_naming(self) -> None:
"""Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata."""
files = self._selected_movies() or self.filehandler.filelist
@@ -1418,6 +1886,40 @@ class QtApp(QMainWindow):
return
SeriesOverviewDialog(self, self.filehandler).exec()
def migrate_pool(self) -> None:
"""Match the existing pool against TMDb and assign ids (one-off migration)."""
from src.core import tmdb
if not tmdb.api_available():
QMessageBox.warning(
self, "Migrace", "Nastav TMDB_API_KEY v .env (potřeba pro párování).")
return
files = self.filehandler.filelist
if not files:
QMessageBox.information(self, "Migrace", "Pool je prázdný.")
return
progress = QProgressDialog("Hledám na TMDb…", "Zrušit", 0, len(files), self)
progress.setWindowTitle("Migrace — párování s TMDb")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
def on_progress(done: int, total: int, f: File) -> None:
progress.setValue(done)
progress.setLabelText(f"[{done}/{total}] {f.filename}")
QApplication.processEvents()
proposals = self.filehandler.match_pool_to_tmdb(
files, on_progress=on_progress, should_cancel=progress.wasCanceled)
progress.setValue(len(files))
if not proposals:
QMessageBox.information(
self, "Migrace", "Všechny filmy už mají TMDb ID — není co migrovat.")
return
MigrateDialog(self, self.filehandler, proposals).exec()
self._refresh_library_filter()
self.refresh_table()
def refresh_from_nfo(self) -> None:
"""(Re)load genre/year/rating tags from the NFO Jellyfin wrote on disk."""
files = self._selected_movies() or self.filehandler.filelist
@@ -1515,39 +2017,50 @@ class QtApp(QMainWindow):
f"tipy +{tips}, nově +{recent}", 5000
)
def edit_copyasis_folders(self) -> None:
current = ", ".join(self.filehandler.copyasis_folders)
text, ok = QInputDialog.getText(
self, "Copy-as-is složky",
"Názvy pool podsložek zrcadlených 1:1 (oddělené čárkou):", text=current
)
if not ok:
return
self.filehandler.set_copyasis_folders(text.split(","))
self.status.showMessage(
"Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000
)
def edit_libraries(self) -> None:
"""Edit the movie-library list (physical partitions) and create folders."""
"""Open the library organizer (manage the pool's top-level libraries)."""
if not self.filehandler.pool_dir:
QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.")
return
current = ", ".join(self.filehandler.movie_libraries)
text, ok = QInputDialog.getText(
self, "Knihovny (rozdělení)",
"Názvy filmových knihoven — složky v poolu (oddělené čárkou):",
text=current)
if not ok:
dialog = LibraryOrganizerDialog(self, self.filehandler.libraries)
if dialog.exec() != QDialog.Accepted:
return
self.filehandler.set_movie_libraries(text.split(","))
self.filehandler.set_libraries(dialog.libraries())
pool = self.filehandler.pool_dir
for name in self.filehandler.movie_libraries:
(pool / name).mkdir(parents=True, exist_ok=True)
for lib in self.filehandler.libraries:
(pool / lib["name"]).mkdir(parents=True, exist_ok=True)
self._refresh_library_filter()
self.refresh_table()
self.status.showMessage(
"Knihovny: " + ", ".join(self.filehandler.movie_libraries), 5000)
self._cleanup_orphan_folders()
names = ", ".join(f"{lib['name']} ({lib['kind']})"
for lib in self.filehandler.libraries)
self.status.showMessage("Knihovny: " + names, 6000)
def _cleanup_orphan_folders(self) -> None:
"""Offer to delete empty pool folders that are no longer libraries."""
orphans = self.filehandler.orphan_pool_folders()
empty = [n for n, is_empty in orphans if is_empty]
nonempty = [n for n, is_empty in orphans if not is_empty]
if empty:
listing = "\n".join(f" • {n}" for n in empty[:15])
more = f"\n … a další ({len(empty) - 15})" if len(empty) > 15 else ""
ans = QMessageBox.question(
self, "Úklid složek",
f"V poolu jsou prázdné složky, které nejsou knihovnou "
f"({len(empty)}):\n{listing}{more}\n\nSmazat je?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if ans == QMessageBox.Yes:
removed = sum(1 for n in empty
if self.filehandler.remove_empty_pool_folder(n))
self.status.showMessage(f"Smazáno {removed} prázdných složek", 5000)
if nonempty:
listing = "\n".join(f" • {n}" for n in nonempty[:15])
more = f"\n … a další ({len(nonempty) - 15})" if len(nonempty) > 15 else ""
QMessageBox.information(
self, "Neznámé složky s obsahem",
f"Tyto složky nejsou knihovnou, ale obsahují soubory — nesmazány "
f"({len(nonempty)}):\n{listing}{more}\n\nStaré knihovny přesuň "
"do některé knihovny, nebo smaž ručně.")
def move_to_library(self) -> None:
"""Move the selected movies into another library."""
@@ -1555,7 +2068,7 @@ class QtApp(QMainWindow):
if not files:
QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.")
return
libraries = self.filehandler.movie_libraries
libraries = self.filehandler.libraries_accepting_movies
target, ok = QInputDialog.getItem(
self, "Přesunout do knihovny", "Cílová knihovna:", libraries, 0, False)
if not ok or not target: