""" PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. Layout: - Toolbar / menu: configure the Pool and the Filmotéka output, import a movie, generate the Filmotéka hardlink tree. - Left sidebar: tag tree used as an AND-filter over the movie list. - Center: the movie table (the contents of pool/Filmy). - Status bar: counts and the current pool/output paths. """ import os import subprocess import sys from pathlib import Path from typing import ClassVar, cast from PySide6.QtCore import QRegularExpression, Qt, QTimer from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QProgressDialog, QPushButton, QRadioButton, QSpinBox, QSplitter, QTableWidget, QTableWidgetItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from src.constants import APP_TITLE from src.core import series from src.core.file import File from src.core.file_manager import FileManager from src.core.hardlink_manager import HardlinkManager from src.core.movie import ( DEFAULT_VERSION_LABEL, normalize_3d_label, normalize_part_label, ) from src.core.naming import ( THREE_D_FORMATS, canonical_pool_stem, is_valid_year, parse_3d_format, parse_pool_stem, parse_tmdb_id, three_d_format_from_filename, year_from_filename, ) from src.core.tag import Tag from src.core.tag_manager import TagManager # Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV # browsers sort them ahead of the genre folders at the output root. TIPS_SUBFOLDER = "- Tipy dne" # random selection, refreshed each run RECENT_SUBFOLDER = "- Nově přidané" # most recently added movies TIPS_COUNT = 15 # random tips RECENT_COUNT = 10 # newest by date added # Series found inside a "mixed" library (movies + series in one folder) have no # genre tags to run through the tag tree — they're hardcopied here instead, # same as a dedicated series library's copy-as-is mirror. MIXED_SERIES_SUBFOLDER = "Seriály" class ImportMoviesDialog(QDialog): """Collect a Title + Year + TMDb id per file for a batch import into the pool. One row per source file (filename shown; Title, Year and TMDb id editable). "Dohledat z TMDb" fills them from TMDb — by the given id, or by searching the filename. A single toggle decides whether the files are copied (default) or moved into the pool. """ def __init__( self, parent: QWidget, sources: list[Path], existing_names: set[str] | None = None, libraries: list[str] | None = None, ) -> None: super().__init__(parent) self.setWindowTitle("Importovat filmy do poolu") self.setMinimumSize(760, 360) self._libraries = libraries or ["Filmy"] # Lower-cased canonical stems already present in the pool (collision check) self.existing_names = {n.lower() for n in (existing_names or set())} # (source path, title field, year field, tmdb-id field, ČSFD-link field) per row self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit, QLineEdit]] = [] # A row set here isn't imported on its own — it becomes another # playable version of the row it points to (e.g. a 3D + a 2D file of # the same film), added via FileManager.add_movie_version right after # its target is imported. {version source: (target source, popisek)}. self._version_links: dict[Path, tuple[Path, str]] = {} self._version_buttons: dict[Path, QPushButton] = {} # 3D formát for a row that stays a flat, single-file movie (the only # file for that film, itself entirely 3D) — disabled whenever the row # is a version of another one or another row is a version of it, since # a grouped movie's 3D-ness lives on the specific version's popisek. self._format3d_combos: dict[Path, QComboBox] = {} layout = QVBoxLayout(self) info = QLabel( "Filmy se uloží jako „Název (rok) [tmdbid-N].ext“ — přesně jak je " "páruje Jellyfin. Rok je povinný; TMDb ID zajistí správné spárování " "(nepovinné). Modře = navrženo z TMDb (ověř); červeně = název už v poolu je. " "Je jen jeden soubor a je celý 3D → nastav mu rovnou „3D formát“. Je-li " "soubor jen další verzí filmu z jiného řádku (2D+3D, jiná kvalita, …), " "použij „Přiřadit jako verzi…“ místo vyplňování vlastního názvu/roku." ) info.setWordWrap(True) layout.addWidget(info) self.table = QTableWidget(0, 8) self.table.setHorizontalHeaderLabels( [ "Soubor", "Název", "Rok", "TMDb ID", "ČSFD odkaz", "3D formát", "Verze", "", ] ) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() add_btn = QPushButton("➕ Přidat soubory…") add_btn.clicked.connect(self._add_files) add_row.addWidget(add_btn) find_btn = QPushButton("🔎 Dohledat z TMDb") find_btn.setToolTip( "Podle TMDb ID doplní název + rok; bez ID vyhledá podle názvu souboru." ) find_btn.clicked.connect(self._autofill_tmdb) add_row.addWidget(find_btn) csfd_btn = QPushButton("🎬 Dohledat přes ČSFD") csfd_btn.setToolTip( "Najde film na ČSFD (podle vyplněného odkazu, jinak podle názvu " "souboru), vezme z něj původní název + rok a podle nich dohledá " "TMDb ID. Vhodné, když přímé hledání na TMDb vrací špatný film." ) csfd_btn.clicked.connect(self._autofill_via_csfd) add_row.addWidget(csfd_btn) add_row.addStretch(1) layout.addLayout(add_row) opts_row = QHBoxLayout() opts_row.addWidget(QLabel("Knihovna:")) self.library_combo = QComboBox() self.library_combo.addItems(self._libraries) opts_row.addWidget(self.library_combo) self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)") opts_row.addWidget(self.move_check) self.skip_no_tmdb_check = QCheckBox("Vynechat řádky bez TMDb ID") self.skip_no_tmdb_check.setToolTip( "Při importu přeskočí soubory bez vyplněného TMDb ID " "(pak ani nevadí chybějící rok). Přeskočené řádky jsou přeškrtnuté." ) self.skip_no_tmdb_check.toggled.connect(self._refresh_skip_marks) opts_row.addWidget(self.skip_no_tmdb_check) opts_row.addStretch(1) layout.addLayout(opts_row) # sources dropped by "Vynechat řádky bez TMDb ID" on accept self._skipped_no_tmdb: list[Path] = [] buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) for source in sources: self._append_row(source) def _append_row(self, source: Path) -> None: row = self.table.rowCount() self.table.insertRow(row) name_item = QTableWidgetItem(source.name) name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.table.setItem(row, 0, name_item) title_edit = QLineEdit(source.stem) title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) year_edit = QLineEdit(year_from_filename(source.name) or "") year_edit.setPlaceholderText("rok") year_edit.setMaximumWidth(64) year_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), year_edit) ) year_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) # Manual edits clear the "suggested" (blue) marker; TMDb autofill re-adds it. year_edit.textEdited.connect( lambda _t, e=year_edit: self._set_suggested(e, False) ) tmdb_edit = QLineEdit() tmdb_edit.setPlaceholderText("TMDb ID") tmdb_edit.setMaximumWidth(96) tmdb_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), tmdb_edit) ) tmdb_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) tmdb_edit.textChanged.connect(lambda _t: self._refresh_skip_marks()) tmdb_edit.textEdited.connect( lambda _t, e=tmdb_edit: self._set_suggested(e, False) ) csfd_edit = QLineEdit() csfd_edit.setPlaceholderText("ČSFD odkaz (volitelné; uloží se + pro dohledání)") csfd_edit.textEdited.connect( lambda _t, e=csfd_edit: self._set_suggested(e, False) ) # Pre-fill the row from metadata already sitting next to the source file: # Curator's own pre-index sidecar (`.{name}.!tag`) / an old `.Curator.!index` # first (authoritative for the ČSFD link), then a Kodi/Jellyfin `.nfo`. # Everything filled this way is blue (suggested — verify). self._prefill_row(source, title_edit, year_edit, tmdb_edit, csfd_edit) format3d_combo = QComboBox() format3d_combo.addItem("— žádný —", None) for fmt in THREE_D_FORMATS: format3d_combo.addItem(f"3D {fmt}", fmt) detected = three_d_format_from_filename(source.name) if detected: format3d_combo.setCurrentIndex(format3d_combo.findData(detected)) format3d_combo.setToolTip( "Je-li tenhle soubor jediná (a celá 3D) verze filmu, nastav mu tu " "3D formát — uloží se rovnou jako „Title (rok) [tmdbid].3D." ".ext“. Nedává smysl spolu s „Přiřadit jako verzi…“ — " "3D verze v seskupeném filmu má formát ve svém popisku." ) self._format3d_combos[source] = format3d_combo version_btn = QPushButton() version_btn.clicked.connect(lambda _c, s=source: self._link_as_version(s)) self._version_buttons[source] = version_btn self._refresh_version_button(source) remove_btn = QPushButton("✕") remove_btn.setFixedWidth(28) remove_btn.setToolTip("Odebrat tento soubor z importu") remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e)) 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, csfd_edit) self.table.setCellWidget(row, 5, format3d_combo) self.table.setCellWidget(row, 6, version_btn) self.table.setCellWidget(row, 7, remove_btn) self._rows.append((source, title_edit, year_edit, tmdb_edit, csfd_edit)) self._mark_collision(title_edit) def _prefill_row( self, source: Path, title_edit: QLineEdit, year_edit: QLineEdit, tmdb_edit: QLineEdit, csfd_edit: QLineEdit, ) -> None: """Seed a new import row from metadata already next to the source file. Curator's pre-index layouts win for the ČSFD link (a per-file ``.{name}.!tag`` sidecar, or an old ``.Curator.!index`` up the tree); a Kodi/Jellyfin ``.nfo`` fills what they don't carry (notably the TMDb id). """ from src.core import legacy_meta, nfo def fill(edit: QLineEdit, value: object) -> None: if value and not edit.text().strip(): edit.setText(str(value)) self._set_suggested(edit, True) legacy = legacy_meta.read_legacy_meta(source) if legacy is not None: fill(csfd_edit, legacy.csfd_url) fill(year_edit, legacy.year) if legacy.title: title_edit.setText(legacy.title) data = nfo.read_nfo(source) if data is not None: fill(csfd_edit, data.csfd_url) fill(tmdb_edit, data.tmdb_id) fill(year_edit, data.year) if data.title and title_edit.text().strip() == source.stem: title_edit.setText(data.title) def _row_of(self, title_edit: QLineEdit): for entry in self._rows: if entry[1] is title_edit: return entry return None def _row_by_source(self, source: Path): for entry in self._rows: if entry[0] == source: return entry return None def _refresh_skip_marks(self) -> None: """Strike through rows that "Vynechat řádky bez TMDb ID" would drop. A version row never needs its own TMDb ID (it inherits its target's), so it's struck through only when its *target* would be dropped too. """ active = self.skip_no_tmdb_check.isChecked() for row, (source, _t, _y, tmdb_edit, _c) in enumerate(self._rows): item = self.table.item(row, 0) if item is None: continue link = self._version_links.get(source) if link is not None: target_tmdb = self._row_by_source(link[0]) skipped = ( active and target_tmdb is not None and not target_tmdb[3].text().strip() ) else: skipped = active and not tmdb_edit.text().strip() font = item.font() font.setStrikeOut(skipped) font.setItalic(skipped) item.setFont(font) def _candidate_stem(self, title: str, year: str, tmdb_id: str) -> str | None: """The canonical 'Title (year) [tmdbid]' stem, or None if incomplete.""" if not (title.strip() and is_valid_year(year)): return None try: return canonical_pool_stem(title, year, tmdb_id or None) except ValueError: return None def _mark_collision(self, title_edit: QLineEdit) -> None: """Red title when the resulting pooled name already exists.""" entry = self._row_of(title_edit) if entry is None: return _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 title_edit.setStyleSheet( "QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "" ) title_edit.setToolTip( f"V poolu už existuje film „{stem}“ — při importu se tě zeptám, co s ním." if clashes else "" ) def _set_suggested(self, edit: QLineEdit, suggested: bool) -> None: """Blue tint marks an auto-filled value (TMDb / ČSFD), not yet confirmed.""" edit.setStyleSheet("QLineEdit { color: #2980b9; }" if suggested else "") edit.setToolTip("Navrženo automaticky — ověř." if suggested else "") def _remove_row(self, title_edit: QLineEdit) -> None: """Drop a single file from the import list without restarting the dialog.""" for index, entry in enumerate(self._rows): if entry[1] is title_edit: source = entry[0] self.table.removeRow(index) self._rows.pop(index) self._version_buttons.pop(source, None) self._format3d_combos.pop(source, None) old_link = self._version_links.pop(source, None) if old_link is not None: # This row was a version of another — that one may have # just lost its last follower (regains its 3D combo). self._sync_version_row_fields(old_link[0]) # Any row versioned *to* the one just removed goes back to # being its own film — its target no longer exists to import. orphaned = [ s for s, (target, _l) in self._version_links.items() if target == source ] for s in orphaned: del self._version_links[s] self._refresh_version_button(s) self._sync_version_row_fields(s) return def _has_followers(self, source: Path) -> bool: """True when another row is linked as a version *of* ``source``.""" return any(target == source for target, _l in self._version_links.values()) def _sync_version_row_fields(self, source: Path) -> None: """Grey out a row's own identity fields while it's linked as a version — its title/year/tmdbid/ČSFD come from the film it's a version *of*. The 3D formát combo is separate: it's for a row that stays a flat, single-file movie, so it's disabled both when the row *is* a version (its 3D-ness belongs on its own popisek instead — see :meth:`_link_as_version`) and when it *has* one (once grouped, 3D lives on whichever version is actually 3D, not on the group itself). """ entry = self._row_by_source(source) if entry is None: return _s, title_edit, year_edit, tmdb_edit, csfd_edit = entry is_version = source in self._version_links note = ( "Verze jiného filmu z tohoto importu — název/rok/ID se přebírají z něj." if is_version else "" ) for w in (title_edit, year_edit, tmdb_edit, csfd_edit): w.setEnabled(not is_version) w.setToolTip(note) if is_version: title_edit.setStyleSheet("") # a version's own name/collision is moot else: self._mark_collision(title_edit) combo = self._format3d_combos.get(source) if combo is not None: grouped = is_version or self._has_followers(source) combo.setEnabled(not grouped) if grouped: combo.setCurrentIndex(0) def _refresh_version_button(self, source: Path) -> None: btn = self._version_buttons.get(source) if btn is None: return link = self._version_links.get(source) if link is None: btn.setText("🔗 Přiřadit jako verzi…") btn.setToolTip( "Označ, že tenhle soubor je jen další verze filmu (2D+3D, jiná " "kvalita, …) z jiného řádku, ne samostatný film." ) else: target_source, label = link target = self._row_by_source(target_source) target_name = target[1].text() if target else target_source.name btn.setText(f"🔗 verze „{target_name}“") btn.setToolTip( f"Verze filmu „{target_name}“ s popiskem „{label or DEFAULT_VERSION_LABEL}“. " "Klikni pro zrušení provázání." ) def _link_as_version(self, source: Path) -> None: if source in self._version_links: target_source, label = self._version_links[source] target = self._row_by_source(target_source) target_name = target[1].text() if target else target_source.name answer = QMessageBox.question( self, "Verze", f"Zrušit provázání — „{source.name}“ nebude verze filmu " f"„{target_name}“ (popisek „{label or DEFAULT_VERSION_LABEL}“), " "ale samostatný film?", ) if answer != QMessageBox.StandardButton.Yes: return del self._version_links[source] self._refresh_version_button(source) self._sync_version_row_fields(source) self._sync_version_row_fields(target_source) # may regain followers=False self._refresh_skip_marks() return if any(target == source for target, _l in self._version_links.values()): QMessageBox.information( self, "Verze", "Tenhle soubor už je sám hlavní verzí pro jiný řádek v tomhle " "importu — nemůže se zároveň stát verzí něčeho jiného.", ) return candidates = [ entry for entry in self._rows if entry[0] != source and entry[0] not in self._version_links ] if not candidates: QMessageBox.information( self, "Verze", "Není ke komu tenhle soubor přiřadit — přidej nejdřív do " "importu i ten druhý (hlavní) soubor filmu.", ) return names = [c[0].name for c in candidates] choice, ok = QInputDialog.getItem( self, "Verze", "Ke kterému filmu je tenhle soubor další verzí?", names, 0, False, ) if not ok: return target_source = candidates[names.index(choice)][0] fmt = three_d_format_from_filename(source.name) dialog = VersionLabelDialog( self, initial=f"3D {fmt}" if fmt else "", intro=f"„{source.name}“ → verze filmu „{choice}“.", ) if dialog.exec() != QDialog.DialogCode.Accepted: return self._version_links[source] = (target_source, dialog.label()) self._refresh_version_button(source) self._sync_version_row_fields(source) self._sync_version_row_fields(target_source) # now has a follower self._refresh_skip_marks() def version_links(self) -> dict[Path, tuple[Path, str]]: """``{version source: (target source, popisek)}`` set via "Přiřadit jako verzi…" — read by the caller to import these as versions (:meth:`FileManager.add_movie_version`) instead of standalone films.""" return dict(self._version_links) def _add_files(self) -> None: paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory") for path in paths: self._append_row(Path(path)) def _autofill_tmdb(self) -> None: """Fill Title/Year/TMDb-id from TMDb per row (blue = suggested, verify). A row that already has a TMDb id is resolved by id; otherwise the filename is searched (narrowed by the year if present) and the top hit's id/title/year are filled in. """ import re from src.core import tmdb if not tmdb.api_available(): QMessageBox.warning( self, "TMDb", "Není nastaven TMDB_API_KEY (.env). Doplň ID ručně, nebo přidej klíč.", ) return found = 0 QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: 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())) else: query = re.sub(r"[._]+", " ", title_edit.text()).strip() year = year_edit.text().strip() or None hits = tmdb.search_movie(query, year=year, limit=1) hit = hits[0] if hits else None except tmdb.TmdbError: hit = None if hit is None: continue found += 1 if not tmdb_edit.text().strip(): tmdb_edit.setText(str(hit.tmdb_id)) self._set_suggested(tmdb_edit, True) title_edit.setText(hit.title) if hit.year: year_edit.setText(hit.year) self._set_suggested(year_edit, True) finally: QApplication.restoreOverrideCursor() QMessageBox.information( self, "TMDb", f"Doplněno {found} z {len(self._rows)} řádků." ) def _autofill_via_csfd(self) -> None: """Fill ČSFD-odkaz / TMDb-id / Title / Year per row via the ČSFD bridge. Per row: use the ČSFD link if given, otherwise search ČSFD by the cleaned filename. From the ČSFD film take the original title + year and search TMDb by those (Czech title as a fallback). Filled values are blue (suggested — verify). Rows that already have a value keep it. """ from src.core import csfd, tmdb if not csfd.dependencies_available(): QMessageBox.warning( self, "ČSFD", "Chybí balíčky requests / beautifulsoup4 (spusť uv sync)." ) return session = csfd.requests.Session() found = 0 errors: list[str] = [] QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows: try: url = csfd_edit.text().strip() or csfd.find_csfd_url( csfd.clean_filename_to_query(source.name), session=session ) if not url: continue film = csfd.fetch_film(url, session=session) if not csfd_edit.text().strip(): csfd_edit.setText(url) self._set_suggested(csfd_edit, True) q_title, q_year = film.tmdb_query hits = ( tmdb.search_movie(q_title, year=q_year, limit=1) if tmdb.api_available() else [] ) if ( not hits and film.czech_title and film.czech_title != q_title and tmdb.api_available() ): hits = tmdb.search_movie(film.czech_title, year=q_year, limit=1) hit = hits[0] if hits else None if hit is None: # No TMDb match — still surface the ČSFD title/year. if q_title: title_edit.setText(q_title) if q_year and not year_edit.text().strip(): year_edit.setText(q_year) self._set_suggested(year_edit, True) continue found += 1 if not tmdb_edit.text().strip(): tmdb_edit.setText(str(hit.tmdb_id)) self._set_suggested(tmdb_edit, True) title_edit.setText(hit.title) if hit.year: year_edit.setText(hit.year) self._set_suggested(year_edit, True) except (csfd.CsfdError, tmdb.TmdbError, OSError) as exc: errors.append(f"{source.name}: {exc}") finally: QApplication.restoreOverrideCursor() session.close() msg = f"Přes ČSFD doplněno {found} z {len(self._rows)} řádků." if errors: QMessageBox.warning( self, "ČSFD", msg + "\n\nChyby:\n" + "\n".join(errors[:5]) ) else: QMessageBox.information(self, "ČSFD", msg) def accept(self) -> None: """Block the import until every standalone row has a title and a four-digit year — a row linked as a version needs neither (its identity comes from the film it's a version of). With "Vynechat řádky bez TMDb ID" ticked, standalone rows without a TMDb ID are dropped first (and no longer need a year); a version row is exempt on its own but goes with its target if that one is dropped. """ self._skipped_no_tmdb = [] if self.skip_no_tmdb_check.isChecked(): def has_tmdb(entry) -> bool: source, _t, _y, tmdb_edit, _c = entry link = self._version_links.get(source) if link is None: return bool(tmdb_edit.text().strip()) target = self._row_by_source(link[0]) return target is not None and bool(target[3].text().strip()) kept = [r for r in self._rows if has_tmdb(r)] self._skipped_no_tmdb = [r[0] for r in self._rows if not has_tmdb(r)] if not kept: QMessageBox.warning( self, "Import", "Žádný řádek nemá TMDb ID — není co importovat." ) return dropped = set(self._skipped_no_tmdb) self._version_links = { s: link for s, link in self._version_links.items() if s not in dropped and link[0] not in dropped } self._rows = kept bad_year: list[str] = [] for source, title_edit, year_edit, _tmdb, _csfd in self._rows: if source in self._version_links: continue # identity comes from its target, not this row title = title_edit.text().strip() or source.stem if not is_valid_year(year_edit.text()): bad_year.append(title) if bad_year: listing = "\n".join(f" • {t}" for t in bad_year[:10]) more = f"\n … a další ({len(bad_year) - 10})" if len(bad_year) > 10 else "" QMessageBox.warning( self, "Chybí rok", "Každý film musí mít čtyřmístný rok (uloží se jako „Název " f"(rok).ext“):\n{listing}{more}", ) return super().accept() @property def move_files(self) -> bool: return self.move_check.isChecked() @property def skipped_no_tmdb(self) -> list[Path]: """Sources dropped by "Vynechat řádky bez TMDb ID" (valid after accept).""" return list(self._skipped_no_tmdb) @property def selected_library(self) -> str: return self.library_combo.currentText() def entries(self) -> list[tuple[Path, str, str, str, str, str | None]]: """Return (source, title, year, tmdb_id, csfd_url, format_3d) per **standalone** row — a row linked as a version (see :meth:`version_links`) is excluded, it isn't imported as a film of its own. ``format_3d`` is always ``None`` for a row that has a follower (see :meth:`_has_followers`) even if its combo still shows a value — once grouped, 3D belongs to a specific version's popisek, not the flat identity this row would otherwise import as. """ result: list[tuple[Path, str, str, str, str, str | None]] = [] for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows: if source in self._version_links: continue title = title_edit.text().strip() or source.stem combo = self._format3d_combos.get(source) format_3d = ( combo.currentData() if combo is not None and not self._has_followers(source) else None ) result.append( ( source, title, year_edit.text().strip(), tmdb_edit.text().strip(), csfd_edit.text().strip(), format_3d, ) ) return result class ImportEpisodesDialog(QDialog): """Collect a series identity + episode files, name them Series SxxEyy.ext.""" def __init__( self, parent: QWidget, sources: list[Path], libraries: list[str] | None = None ) -> None: super().__init__(parent) self.setWindowTitle("Importovat epizody seriálu") self.setMinimumSize(720, 460) self._libraries = libraries or ["Seriály"] self._rows: list[tuple[Path, QLineEdit]] = [] layout = QVBoxLayout(self) form = QHBoxLayout() form.addWidget(QLabel("Seriál:")) self.title_edit = QLineEdit() self.title_edit.setPlaceholderText("Název seriálu") form.addWidget(self.title_edit) form.addWidget(QLabel("Rok:")) self.year_edit = QLineEdit() self.year_edit.setMaximumWidth(64) self.year_edit.setPlaceholderText("rok") self.year_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit) ) form.addWidget(self.year_edit) form.addWidget(QLabel("TMDb ID:")) self.tmdb_edit = QLineEdit() self.tmdb_edit.setMaximumWidth(96) self.tmdb_edit.setPlaceholderText("TV ID") self.tmdb_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit) ) form.addWidget(self.tmdb_edit) find_btn = QPushButton("🔎 Dohledat") find_btn.clicked.connect(self._autofill_tmdb) form.addWidget(find_btn) layout.addLayout(form) lib_row = QHBoxLayout() lib_row.addWidget(QLabel("Knihovna:")) self.library_combo = QComboBox() self.library_combo.addItems(self._libraries) lib_row.addWidget(self.library_combo) lib_row.addStretch(1) layout.addLayout(lib_row) info = QLabel( "Epizody se uloží jako „Název SxxEyy.ext“ do Season složek. " "Číslo sezóny/epizody se předvyplní z názvu souboru — nerozpoznané " "(nebo špatně určené) dopiš ve sloupci SxxEyy (např. S01E05 / 1x05). " "Je-li epizoda rozdělená na víc souborů (starý záznam na dvě kazety " "apod.), přidej part: S01E05P2. " "„Přidat složku…“ projde i všechny podsložky (Season 01, Season 02, …)." ) info.setWordWrap(True) layout.addWidget(info) self.table = QTableWidget(0, 3) self.table.setHorizontalHeaderLabels(["Soubor", "SxxEyy", ""]) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() add_btn = QPushButton("➕ Přidat epizody…") add_btn.clicked.connect(self._add_files) add_row.addWidget(add_btn) folder_btn = QPushButton("📁 Přidat složku…") folder_btn.setToolTip( "Vyber složku seriálu — Curator projde i všechny podsložky " "a přidá každý video soubor v nich." ) folder_btn.clicked.connect(self._add_folder) add_row.addWidget(folder_btn) add_row.addStretch(1) layout.addLayout(add_row) self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)") layout.addWidget(self.move_check) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) for source in sources: self._append_row(source) def _append_row(self, source: Path) -> None: row = self.table.rowCount() self.table.insertRow(row) name_item = QTableWidgetItem(source.name) name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.table.setItem(row, 0, name_item) code = series.parse_episode(source.name) part = series.parse_episode_part(source.name) if code else None prefill = f"S{code[0]:02d}E{code[1]:02d}" if code else "" if prefill and part: prefill += f"P{part}" code_edit = QLineEdit(prefill) code_edit.setPlaceholderText("SxxEyy") code_edit.setMaximumWidth(120) code_edit.textChanged.connect(lambda _t, e=code_edit: self._mark_code(e)) self.table.setCellWidget(row, 1, code_edit) remove_btn = QPushButton("✕") remove_btn.setFixedWidth(28) remove_btn.clicked.connect(lambda _c, p=source: self._remove_row(p)) self.table.setCellWidget(row, 2, remove_btn) self._rows.append((source, code_edit)) self._mark_code(code_edit) def _mark_code(self, edit: QLineEdit) -> None: """Colour the SxxEyy field: green = valid, red = unparsable, plain = empty.""" text = edit.text().strip() if not text: edit.setStyleSheet("") edit.setToolTip("Prázdné — epizoda se přeskočí. Zadej např. S01E05.") elif series.parse_episode_code(text) is None: edit.setStyleSheet("QLineEdit { color: #c0392b; }") edit.setToolTip( "Nerozpoznaný formát — použij S01E05 nebo 1x05 " "(část epizody: S01E05P2)." ) else: edit.setStyleSheet("QLineEdit { color: #27ae60; }") edit.setToolTip("") def _remove_row(self, source: Path) -> None: for index, (path, _edit) in enumerate(self._rows): if path is source: self.table.removeRow(index) self._rows.pop(index) return def _add_files(self) -> None: paths, _ = QFileDialog.getOpenFileNames(self, "Vyber epizody") for path in paths: self._append_row(Path(path)) def _add_folder(self) -> None: """Add every video file under a picked folder (recursively, subfolders too).""" folder = QFileDialog.getExistingDirectory(self, "Vyber složku se seriálem") if not folder: return folder_path = Path(folder) have = {path for path, _ in self._rows} added = 0 for path in series.collect_episode_files(folder_path): if path not in have: self._append_row(path) added += 1 if not added: QMessageBox.information( self, "Import epizod", "Ve složce (ani podsložkách) nejsou žádné nové video soubory.", ) return # Seed the series identity from the folder name if still blank. if not self.title_edit.text().strip(): parsed = parse_pool_stem(folder_path.name) self.title_edit.setText(parsed[0] if parsed else folder_path.name) if parsed and not self.year_edit.text().strip(): self.year_edit.setText(str(parsed[1])) tid = parse_tmdb_id(folder_path.name) if tid and not self.tmdb_edit.text().strip(): self.tmdb_edit.setText(str(tid)) def _autofill_tmdb(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_tv(int(self.tmdb_edit.text().strip())) else: hits = tmdb.search_tv( 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 if not self.tmdb_edit.text().strip(): self.tmdb_edit.setText(str(hit.tmdb_id)) self.title_edit.setText(hit.title) if hit.year: self.year_edit.setText(hit.year) def accept(self) -> None: if not self._rows: QMessageBox.warning( self, "Seriál", "Přidej aspoň jednu epizodu (soubor nebo složku)." ) return if not self.title_edit.text().strip(): QMessageBox.warning(self, "Seriál", "Zadej název seriálu.") return missing = [ p.name for p, edit in self._rows if series.parse_episode_code(edit.text().strip()) is None ] if missing: listing = "\n".join(f" • {n}" for n in missing[:10]) more = f"\n … a další ({len(missing) - 10})" if len(missing) > 10 else "" answer = QMessageBox.question( self, "Chybí číslování", f"{len(missing)} epizod nemá platné SxxEyy a při importu se " f"přeskočí:\n{listing}{more}\n\nPokračovat?", ) if answer != QMessageBox.StandardButton.Yes: return super().accept() @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 move_files(self) -> bool: return self.move_check.isChecked() def sources(self) -> list[Path]: return [path for path, _ in self._rows] def episode_codes(self) -> dict[Path, tuple[int, int, int | None]]: """Per-file ``(season, episode, part)`` taken from the SxxEyy column. Covers what the user typed / corrected in the dialog; rows whose field is empty or unparsable are omitted (they fall back to filename parsing in ``import_episodes`` and are skipped if that fails too). """ codes: dict[Path, tuple[int, int, int | None]] = {} for path, edit in self._rows: parsed = series.parse_episode_code(edit.text().strip()) if parsed is not None: codes[path] = parsed return codes class SeriesOverviewDialog(QDialog): """Read-only Series → Season → Episode tree with a per-series rename action.""" def __init__(self, parent: QWidget, filehandler: FileManager) -> None: super().__init__(parent) self.filehandler = filehandler self.setWindowTitle("Přehled seriálů") self.setMinimumSize(720, 520) self._series: list = [] layout = QVBoxLayout(self) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"]) self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.tree.itemDoubleClicked.connect(lambda _i, _c: self._edit_selected()) layout.addWidget(self.tree) btns = QHBoxLayout() 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) remove_btn = QPushButton("Odebrat z poolu…") remove_btn.setToolTip( "Smaže to, co je ve stromu vybrané — celý seriál, jen jednu sezónu, " "nebo jen jednu epizodu." ) remove_btn.clicked.connect(self._remove_selected) btns.addWidget(remove_btn) btns.addStretch(1) close_btn = QPushButton("Zavřít") close_btn.clicked.connect(self.accept) btns.addWidget(close_btn) layout.addLayout(btns) self._reload() def _reload(self) -> None: self.tree.clear() self._series = self.filehandler.load_series() for idx, ser in enumerate(self._series): label = ser.title if ser.year: label += f" ({ser.year})" if ser.tmdb_id: label += f" [tmdb-{ser.tmdb_id}]" top = QTreeWidgetItem([label, f"{len(ser.episodes)} epizod"]) top.setData(0, Qt.ItemDataRole.UserRole, idx) self.tree.addTopLevelItem(top) for season, eps in ser.seasons().items(): sname = ( series.season_folder_name(season) if season >= 0 else "? bez sezóny" ) snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"]) snode.setData(0, Qt.ItemDataRole.UserRole + 1, (season, eps)) top.addChild(snode) for ep in eps: ok = series.is_canonical_episode(ep.path, ser.title) enode = QTreeWidgetItem( [f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"] ) enode.setData(0, Qt.ItemDataRole.UserRole + 1, ep) if not ok: enode.setForeground(1, Qt.GlobalColor.red) snode.addChild(enode) top.setExpanded(True) def _selected_series_index(self): item = self.tree.currentItem() while item is not None and item.parent() is not None: item = item.parent() return item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None def _selected_node(self): """``("series"|"season"|"episode", Series, extra)`` for the current selection, by tree depth — ``extra`` is ``None`` / ``(season, eps)`` / the ``Episode``. ``None`` when nothing is selected.""" item = self.tree.currentItem() if item is None: return None if item.parent() is None: return "series", self._series[item.data(0, Qt.ItemDataRole.UserRole)], None if item.parent().parent() is None: ser = self._series[item.parent().data(0, Qt.ItemDataRole.UserRole)] return "season", ser, item.data(0, Qt.ItemDataRole.UserRole + 1) ser = self._series[item.parent().parent().data(0, Qt.ItemDataRole.UserRole)] return "episode", ser, item.data(0, Qt.ItemDataRole.UserRole + 1) def _rename_selected(self) -> None: idx = self._selected_series_index() if idx is None: QMessageBox.information(self, "Seriály", "Vyber seriál.") return result = self.filehandler.rename_episodes_to_canonical(self._series[idx]) self._reload() QMessageBox.information( self, "Přejmenování epizod", f"Přejmenováno: {len(result['renamed'])}, " f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.", ) def _remove_selected(self) -> None: node = self._selected_node() if node is None: QMessageBox.information( self, "Seriály", "Vyber seriál, sezónu nebo epizodu." ) return kind, ser, extra = node if kind == "series": question = ( f"Smazat seriál „{ser.title}“ z poolu včetně všech sezón a epizod?" ) action = lambda: self.filehandler.remove_series(ser) elif kind == "season": season, eps = extra name = ( series.season_folder_name(season) if season >= 0 else "epizody bez rozpoznané sezóny" ) question = f"Smazat „{name}“ ({len(eps)} dílů) seriálu „{ser.title}“?" action = lambda: self.filehandler.remove_season(ser, season) else: # episode ep = extra question = ( f"Smazat epizodu {ep.code} „{ep.path.name}“ ze seriálu „{ser.title}“?" ) action = lambda: self.filehandler.remove_episodes([ep]) if ( QMessageBox.question(self, "Odebrat z poolu", question) != QMessageBox.StandardButton.Yes ): return try: action() except (ValueError, OSError) as exc: QMessageBox.warning(self, "Chyba", str(exc)) return self._reload() 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.DialogCode.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 VersionLabelDialog(QDialog): """Pick a label for a new movie version — a 3D formát preset or free text. Jellyfin tells versions apart purely by this label text, so a 3D preset just *is* the label (``3D HSBS``) — same tokens it already scans for in a flat ``.3D.HSBS`` filename, just placed after " - " instead. """ def __init__(self, parent: QWidget, initial: str = "", intro: str = "") -> None: super().__init__(parent) self.setWindowTitle("Popisek verze") self.setMinimumWidth(360) layout = QVBoxLayout(self) if intro: intro_label = QLabel(intro) intro_label.setWordWrap(True) layout.addWidget(intro_label) layout.addWidget( QLabel( "Jak tuhle verzi Jellyfin rozliší od ostatních (uvidí ji v přehrávači " "jako volbu)." ) ) form = QFormLayout() self.format3d_combo = QComboBox() self.format3d_combo.addItem("— vlastní popisek —", None) for fmt in THREE_D_FORMATS: self.format3d_combo.addItem(f"3D {fmt}", fmt) self.format3d_combo.currentIndexChanged.connect(self._sync_label) form.addRow("3D formát:", self.format3d_combo) self.label_edit = QLineEdit() self.label_edit.setPlaceholderText( "např. 4K, Director's Cut (prázdné = obyčejná 2D verze, Výchozí)" ) form.addRow("Popisek:", self.label_edit) layout.addLayout(form) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) preset = normalize_3d_label(initial) if preset: self.format3d_combo.setCurrentIndex(self.format3d_combo.findData(preset)) elif initial: self.label_edit.setText(initial) def _sync_label(self) -> None: fmt = self.format3d_combo.currentData() self.label_edit.setEnabled(fmt is None) self.label_edit.setText(f"3D {fmt}" if fmt else "") def label(self) -> str: return self.label_edit.text().strip() class DuplicatesDialog(QDialog): """Nástroje → Kontrola duplicit: movies present in the pool more than once. Each group lists the colliding movies with why they were flagged; a movie can be folded into another one as a version (the usual fix for a 2D + 3D pair lying flat side by side) or deleted when it's a plain copy. """ def __init__(self, parent: QWidget, filehandler: FileManager) -> None: super().__init__(parent) self.filehandler = filehandler self.changed = False self.setWindowTitle("Kontrola duplicit") self.setMinimumSize(820, 480) layout = QVBoxLayout(self) self.summary = QLabel() self.summary.setWordWrap(True) layout.addWidget(self.summary) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Film / soubor", "Knihovna", "Verzí", "Velikost"]) self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.tree.currentItemChanged.connect(lambda *_: self._sync_buttons()) layout.addWidget(self.tree) btns = QHBoxLayout() self.merge_btn = QPushButton("Sloučit jako verzi do…") self.merge_btn.setToolTip( "Přesune vybraný soubor do složky druhého filmu jako další verzi; " "metadata druhého filmu zůstanou, štítky a ČSFD odkaz se doplní." ) self.merge_btn.clicked.connect(self._merge_selected) btns.addWidget(self.merge_btn) self.remove_btn = QPushButton("Smazat vybraný z poolu…") self.remove_btn.clicked.connect(self._remove_selected) btns.addWidget(self.remove_btn) btns.addStretch(1) close_btn = QPushButton("Zavřít") close_btn.clicked.connect(self.accept) btns.addWidget(close_btn) layout.addLayout(btns) self._reload() def _reload(self) -> None: self.groups = self.filehandler.find_duplicates() self.tree.clear() for gi, group in enumerate(self.groups): first = group["movies"][0] top = QTreeWidgetItem( [f"{first.title or first.filename} — {', '.join(group['reasons'])}"] ) font = top.font(0) font.setBold(True) top.setFont(0, font) top.setData(0, Qt.ItemDataRole.UserRole, (gi, None)) for mi, movie in enumerate(group["movies"]): versions = self.filehandler.list_movie_versions(movie) grouped = self.filehandler.is_movie_group_member(movie) name = f"{movie.file_path.parent.name}/" if grouped else movie.filename size = QtApp._format_size( sum( v.file_path.stat().st_size for v in versions if v.file_path.exists() ) ) child = QTreeWidgetItem( [ name, self.filehandler.library_of(movie) or "—", str(len(versions)), size, ] ) child.setToolTip(0, "\n".join(str(v.file_path) for v in versions)) child.setData(0, Qt.ItemDataRole.UserRole, (gi, mi)) top.addChild(child) self.tree.addTopLevelItem(top) top.setExpanded(True) if self.groups: self.summary.setText( f"Nalezeno {len(self.groups)} skupin možných duplicit. Vyber soubor " "a buď ho slouč jako verzi do druhého filmu (např. 2D + 3D), nebo " "smaž, pokud je to jen kopie." ) else: self.summary.setText( "✅ Žádné duplicity — každý film je v poolu jen jednou." ) self._sync_buttons() def _selected(self) -> tuple[dict, File] | None: item = self.tree.currentItem() if item is None: return None gi, mi = item.data(0, Qt.ItemDataRole.UserRole) if mi is None: return None group = self.groups[gi] return group, group["movies"][mi] def _sync_buttons(self) -> None: picked = self._selected() self.merge_btn.setEnabled(picked is not None) self.remove_btn.setEnabled(picked is not None) def _merge_selected(self) -> None: picked = self._selected() if picked is None: return group, other = picked candidates = [m for m in group["movies"] if m is not other] if len(candidates) == 1: keep = candidates[0] else: names = [m.file_path.name for m in candidates] choice, ok = QInputDialog.getItem( self, "Sloučit", "Do kterého filmu přidat jako verzi?", names, 0, False ) if not ok: return keep = candidates[names.index(choice)] fmt = parse_3d_format(other.file_path.stem) dialog = VersionLabelDialog( self, initial=f"3D {fmt}" if fmt else "", intro=f"„{other.filename}“ → verze filmu „{keep.title or keep.filename}“.", ) if dialog.exec() != QDialog.DialogCode.Accepted: return try: self.filehandler.merge_as_version(keep, other, dialog.label()) except (ValueError, RuntimeError, OSError) as exc: QMessageBox.warning(self, "Sloučení se nezdařilo", str(exc)) return self.changed = True self._reload() def _remove_selected(self) -> None: picked = self._selected() if picked is None: return _group, movie = picked versions = self.filehandler.list_movie_versions(movie) extra = f" (všech {len(versions)} verzí)" if len(versions) > 1 else "" answer = QMessageBox.question( self, "Smazat z poolu", f"Smazat „{movie.file_path.name}“{extra} z poolu včetně souboru, " "titulků a metadat?", ) if answer != QMessageBox.StandardButton.Yes: return try: self.filehandler.remove_movie(movie) except OSError as exc: QMessageBox.warning(self, "Chyba", str(exc)) return self.changed = True 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() self.tmdb_edit.setValidator( QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit) ) tmdb_row.addWidget(self.tmdb_edit) self.find_btn = QPushButton("Dohledat z TMDb") self.find_btn.clicked.connect(self._lookup) tmdb_row.addWidget(self.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) self.format3d_combo = QComboBox() self.format3d_combo.addItem("— žádný —", None) for fmt in THREE_D_FORMATS: self.format3d_combo.addItem(fmt, fmt) form.addRow("3D formát:", self.format3d_combo) layout.addLayout(form) self._refresh_identity_fields() layout.addWidget( QLabel( "Titulky (externí soubor vedle videa — Jellyfin je přehraje bez muxování; " "sledují film i při přejmenování):" ) ) self.subs_table = QTableWidget(0, 2) self.subs_table.setHorizontalHeaderLabels(["Soubor", "Jazyk / příznak"]) self.subs_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.subs_table.horizontalHeader().setSectionResizeMode( 0, QHeaderView.ResizeMode.Stretch ) self.subs_table.setMaximumHeight(110) self._subtitles: list = [] layout.addWidget(self.subs_table) subs_btns = QHBoxLayout() add_sub_btn = QPushButton("➕ Přidat titulky…") add_sub_btn.clicked.connect(self._add_subtitle) subs_btns.addWidget(add_sub_btn) del_sub_btn = QPushButton("➖ Odebrat") del_sub_btn.clicked.connect(self._remove_subtitle) subs_btns.addWidget(del_sub_btn) subs_btns.addStretch(1) layout.addLayout(subs_btns) self._reload_subtitles() # --- Média: flat (one file) or a folder of versions / parts --------- media_box = QGroupBox("Média filmu") media_layout = QVBoxLayout(media_box) self.layout_label = QLabel() self.layout_label.setWordWrap(True) media_layout.addWidget(self.layout_label) mode_row = QHBoxLayout() mode_row.addWidget(QLabel("Ve složce:")) self.mode_versions = QRadioButton("Verze — alternativy k výběru") self.mode_versions.setToolTip( "Každý soubor je celý film, Jellyfin nabídne výběr mezi nimi — " "kinoverze, režisérská verze, 3D, 4K…" ) self.mode_parts = QRadioButton("Díly — přehrají se za sebou") self.mode_parts.setToolTip( "Film rozdělený do víc souborů (díl 1, díl 2…) — Jellyfin je " "přehraje za sebou jako jeden film." ) self.mode_group = QButtonGroup(self) self.mode_group.addButton(self.mode_versions) self.mode_group.addButton(self.mode_parts) self.mode_versions.setChecked(True) self.mode_group.buttonClicked.connect(self._on_mode_clicked) mode_row.addWidget(self.mode_versions) mode_row.addWidget(self.mode_parts) mode_row.addStretch(1) media_layout.addLayout(mode_row) self.versions_table = QTableWidget(0, 3) self.versions_table.setHorizontalHeaderLabels(["Soubor", "Typ", "Popisek"]) self.versions_table.setEditTriggers( QAbstractItemView.EditTrigger.NoEditTriggers ) self.versions_table.setSelectionBehavior( QAbstractItemView.SelectionBehavior.SelectRows ) self.versions_table.horizontalHeader().setSectionResizeMode( 0, QHeaderView.ResizeMode.Stretch ) self.versions_table.setMaximumHeight(130) self._versions: list = [] media_layout.addWidget(self.versions_table) media_btns = QHBoxLayout() add_media_btn = QPushButton("➕ Přidat médium…") add_media_btn.setToolTip( "Přidá další soubor jako verzi, nebo jako díl — podle volby „Ve " "složce“. Samostatný film se tím přesune do vlastní složky." ) add_media_btn.clicked.connect(self._add_media) media_btns.addWidget(add_media_btn) self.label_btn = QPushButton("✏️ Popisek / 3D…") self.label_btn.clicked.connect(self._rename_version) media_btns.addWidget(self.label_btn) self.up_btn = QPushButton("↑") self.up_btn.setToolTip("Posunout díl dopředu") self.up_btn.setFixedWidth(36) self.up_btn.clicked.connect(lambda: self._move_part(-1)) media_btns.addWidget(self.up_btn) self.down_btn = QPushButton("↓") self.down_btn.setToolTip("Posunout díl dozadu") self.down_btn.setFixedWidth(36) self.down_btn.clicked.connect(lambda: self._move_part(+1)) media_btns.addWidget(self.down_btn) del_media_btn = QPushButton("➖ Odebrat") del_media_btn.clicked.connect(self._remove_version) media_btns.addWidget(del_media_btn) media_btns.addStretch(1) media_layout.addLayout(media_btns) layout.addWidget(media_box) self._reload_versions() buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _reload_subtitles(self) -> None: self._subtitles = self.filehandler.list_subtitles(self.file_obj) self.subs_table.setRowCount(0) for sub in self._subtitles: row = self.subs_table.rowCount() self.subs_table.insertRow(row) self.subs_table.setItem(row, 0, QTableWidgetItem(sub.path.name)) self.subs_table.setItem(row, 1, QTableWidgetItem(sub.label)) def _add_subtitle(self) -> None: import re path, _ = QFileDialog.getOpenFileName( self, "Vyber soubor s titulky", filter="Titulky (*.srt *.ass *.ssa *.sub *.vtt)", ) if not path: return language_text, ok = QInputDialog.getText( self, "Jazyk titulků", "Kód jazyka (např. cs, en; prázdné = bez tagu):" ) if not ok: return language: str | None = language_text.strip().lower() or None if language and not re.fullmatch(r"[a-z]{2,3}", language): QMessageBox.warning( self, "Titulky", "Kód jazyka zadej jako 2–3 písmena (např. cs)." ) return forced = ( QMessageBox.question( self, "Titulky", "Jsou to vynucené (forced) titulky?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) == QMessageBox.StandardButton.Yes ) try: self.filehandler.attach_subtitle( self.file_obj, Path(path), language=language, flag="forced" if forced else None, ) except (ValueError, OSError) as exc: QMessageBox.warning(self, "Titulky", f"Nelze přidat titulky: {exc}") return self._reload_subtitles() def _remove_subtitle(self) -> None: row = self.subs_table.currentRow() if row < 0 or row >= len(self._subtitles): QMessageBox.information(self, "Titulky", "Vyber titulky k odebrání.") return sub = self._subtitles[row] answer = QMessageBox.question( self, "Odebrat titulky", f"Smazat soubor „{sub.path.name}“?" ) if answer != QMessageBox.StandardButton.Yes: return try: self.filehandler.remove_subtitle(sub.path) except OSError as exc: QMessageBox.warning(self, "Titulky", str(exc)) return self._reload_subtitles() def _refresh_identity_fields(self) -> None: """(Re)fill TMDb ID and enable/disable the identity fields for the current grouping state of ``self.file_obj`` — called at open and again after adding/removing a version, since that can promote or demote it. """ grouped = self.filehandler.is_movie_group_member(self.file_obj) # A grouped movie's own filename carries " - Label", not the plain # "Title (rok) [tmdbid]" the identity parsers expect — read those from # the shared container folder's name instead. identity_stem = ( self.file_obj.file_path.parent.name if grouped else self.file_obj.file_path.stem ) self.tmdb_edit.setText(str(parse_tmdb_id(identity_stem) or "")) note = ( "Film má víc verzí — název/rok/TMDb ID/knihovna se zatím " "needitují společně (odeber verzi, nebo uprav soubory ručně)." if grouped else "" ) for w in ( self.title_edit, self.year_edit, self.tmdb_edit, self.find_btn, self.library_combo, ): w.setEnabled(not grouped) w.setToolTip(note) current_3d = None if grouped else parse_3d_format(identity_stem) idx = self.format3d_combo.findData(current_3d) self.format3d_combo.setCurrentIndex(max(idx, 0)) self.format3d_combo.setEnabled(not grouped) self.format3d_combo.setToolTip( "U filmu s víc verzemi patří 3D do popisku té konkrétní verze " "(viz „Verze“ níže), ne sem." if grouped else "Jellyfin pozná 3D podle „.3D.“ v názvu souboru — " "HSBS/FSBS = side-by-side (half/full), HTAB/FTAB = top-and-bottom " "(half/full), MVC = nativní 3D blu-ray stream." ) def _reload_versions(self, select: File | None = None) -> None: """Refill the media table and the mode controls from disk.""" mode = self.filehandler.movie_group_mode(self.file_obj) grouped = mode != "flat" self._versions = self.filehandler.ordered_media(self.file_obj) self.versions_table.setRowCount(0) for version in self._versions: row = self.versions_table.rowCount() self.versions_table.insertRow(row) self.versions_table.setItem( row, 0, QTableWidgetItem(version.file_path.name) ) # A flat movie has exactly one file and no on-disk label — # "Výchozí" is just the table's name for it. label = version._identity_from_path()[4] if grouped else "" part = normalize_part_label(label) if part is not None: kind, label = f"díl {part}", "" elif label and label != DEFAULT_VERSION_LABEL: kind = "verze" else: kind, label = DEFAULT_VERSION_LABEL, "" self.versions_table.setItem(row, 1, QTableWidgetItem(kind)) self.versions_table.setItem(row, 2, QTableWidgetItem(label)) if select is not None and version.file_path == select.file_path: self.versions_table.selectRow(row) if grouped: # the radio shows the folder's actual mode (self.mode_parts if mode == "parts" else self.mode_versions).setChecked( True ) self.layout_label.setText( f"Složka filmu — {len(self._versions)} médií" + ( " (díly, přehrají se za sebou)." if mode == "parts" else " (verze, Jellyfin nabídne výběr)." ) ) else: self.layout_label.setText( "Samostatný film — jeden soubor v kořeni knihovny (+ titulky). " "Přidáním dalšího média se film přesune do vlastní složky " "jako verze, nebo jako díl — podle volby níže." ) parts = self._effective_mode() == "parts" self.label_btn.setEnabled(grouped and not parts) self.up_btn.setEnabled(grouped and parts) self.down_btn.setEnabled(grouped and parts) def _effective_mode(self) -> str: """``"parts"`` or ``"versions"`` — the folder's, or for a flat movie what the next added medium will become.""" return "parts" if self.mode_parts.isChecked() else "versions" def _selected_media(self) -> File | None: row = self.versions_table.currentRow() if row < 0 or row >= len(self._versions): return None return self._versions[row] def _after_media_change(self, select: File | None = None) -> None: self._reload_versions(select) self._reload_subtitles() self._refresh_identity_fields() def _on_mode_clicked(self, _button) -> None: """Switch a folder between versions and parts (a flat movie only remembers the choice for the next added medium).""" current = self.filehandler.movie_group_mode(self.file_obj) wanted = self._effective_mode() if current == "flat" or current == wanted: self._reload_versions() return try: if wanted == "parts": order = "\n".join( f" díl {n}: {f.file_path.name}" for n, f in enumerate(self._versions, 1) ) answer = QMessageBox.question( self, "Přepnout na díly", f"Soubory se přejmenují na díly v tomto pořadí (pak jde " f"pořadí změnit šipkami):\n{order}", ) if answer != QMessageBox.StandardButton.Yes: self._reload_versions() return self.filehandler.set_movie_group_mode(self.file_obj, "parts") else: labels: list[str] = [] for f in self._versions[1:]: dialog = VersionLabelDialog( self, intro=f"Popisek verze pro „{f.file_path.name}“ " f"(první soubor bude výchozí verze).", ) if ( dialog.exec() != QDialog.DialogCode.Accepted or not dialog.label() ): self._reload_versions() return labels.append(dialog.label()) self.filehandler.set_movie_group_mode(self.file_obj, "versions", labels) except (ValueError, OSError) as exc: QMessageBox.warning(self, "Režim složky", str(exc)) self._after_media_change() def _add_media(self) -> None: """Add one more file — as a part or a version, by the chosen mode.""" path, _ = QFileDialog.getOpenFileName(self, "Vyber další médium filmu") if not path: return try: if self._effective_mode() == "parts": added = self.filehandler.add_movie_part(self.file_obj, Path(path)) else: dialog = VersionLabelDialog(self) if dialog.exec() != QDialog.DialogCode.Accepted: return added = self.filehandler.add_movie_version( self.file_obj, Path(path), dialog.label() ) except (ValueError, RuntimeError, FileExistsError) as exc: QMessageBox.warning(self, "Média", f"Nelze přidat: {exc}") return self._after_media_change(added) def _move_part(self, delta: int) -> None: part = self._selected_media() if part is None: QMessageBox.information(self, "Díly", "Vyber díl k posunutí.") return try: self.filehandler.move_movie_part(part, delta) except (ValueError, OSError) as exc: QMessageBox.warning(self, "Díly", str(exc)) return self._after_media_change(part) def _rename_version(self) -> None: version = self._selected_media() if version is None: QMessageBox.information(self, "Verze", "Vyber verzi.") return if self.filehandler.movie_group_mode(self.file_obj) != "versions": return label_item = self.versions_table.item(self.versions_table.currentRow(), 2) dialog = VersionLabelDialog( self, initial=label_item.text() if label_item else "" ) if dialog.exec() != QDialog.DialogCode.Accepted: return try: self.filehandler.rename_movie_version(version, dialog.label()) except (ValueError, FileExistsError, OSError) as exc: QMessageBox.warning(self, "Verze", f"Nelze přejmenovat verzi: {exc}") return self._after_media_change(version) def _remove_version(self) -> None: version = self._selected_media() if version is None: QMessageBox.information(self, "Média", "Vyber médium k odebrání.") return if len(self._versions) < 2: QMessageBox.information( self, "Média", "Film má jen tenhle jeden soubor — celý film " "odebereš z hlavní tabulky (Odebrat z poolu).", ) return answer = QMessageBox.question( self, "Odebrat médium", f"Smazat soubor „{version.file_path.name}“?" ) if answer != QMessageBox.StandardButton.Yes: return # The dialog may be open on the very file being removed (it opens on # the movie's primary version) — carry on with a sibling instead. All # versions share one movie record, so nothing else in the dialog changes. sibling = next(v for v in self._versions if v is not version) try: self.filehandler.remove_movie_version(version) except ValueError as exc: QMessageBox.warning(self, "Média", str(exc)) return if version is self.file_obj or version.file_path == self.file_obj.file_path: self.file_obj = sibling self._after_media_change() 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 csfd_url(self) -> str: return self.csfd_edit.text().strip() @property def format_3d(self) -> str: """Selected 3D tag, or ``""`` for "— žádný —" (clears any existing tag).""" return self.format3d_combo.currentData() or "" 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) ) tmdb_row = QHBoxLayout() tmdb_row.addWidget(self.tmdb_edit) lookup_btn = QPushButton("🔎 Dohledat") lookup_btn.setToolTip( "Doplní název a rok z TMDb — podle TMDb ID, bez něj podle názvu." ) lookup_btn.clicked.connect(self._lookup) tmdb_row.addWidget(lookup_btn) form.addRow("TMDb ID:", tmdb_row) # One-off: some "series" exist on TMDb only as a movie (a miniseries cut # together, a documentary in parts, …). The id is then a *movie* id — # good for pulling the title/year once, but useless (and misleading) # in the series folder name, where Jellyfin's TV provider would look # it up as a TV id and match some unrelated show. self.movie_id_check = QCheckBox("Metadata projednou z ID filmu (ne seriálu)") self.movie_id_check.setToolTip( "TMDb vede tohle jako film, ne seriál. „Dohledat“ pak použije filmové " "ID / vyhledávání filmů. Filmové ID se do názvu složky seriálu " "neuloží — Jellyfin by ho hledal jako ID seriálu a spároval něco " "jiného; seriál pak spáruje podle názvu a roku." ) form.addRow("", self.movie_id_check) 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.ResizeMode.Stretch) table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) for ep in ser.episodes: row = table.rowCount() table.insertRow(row) name_item = QTableWidgetItem(ep.path.name) name_item.setFlags(Qt.ItemFlag.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.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _lookup(self) -> None: """Fill title + year from TMDb — by the typed id, else by the title. Uses the movie endpoints instead of the TV ones while "Metadata projednou z ID filmu" is ticked. """ from src.core import tmdb if not tmdb.api_available(): QMessageBox.warning(self, "TMDb", "Není nastaven TMDB_API_KEY (.env).") return as_movie = self.movie_id_check.isChecked() typed_id = self.tmdb_edit.text().strip() try: if typed_id.isdigit(): lookup = tmdb.lookup_movie if as_movie else tmdb.lookup_tv hit = lookup(int(typed_id)) else: search = tmdb.search_movie if as_movie else tmdb.search_tv hits = search( 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", "Film nenalezen." if as_movie else "Seriál nenalezen." ) return self.title_edit.setText(hit.title) if hit.year: self.year_edit.setText(hit.year) if not typed_id: self.tmdb_edit.setText(str(hit.tmdb_id)) def accept(self) -> None: 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: """The TMDb TV id for the series folder name. Empty while "Metadata projednou z ID filmu" is ticked: the typed id is then a *movie* id, and Jellyfin would look it up as a TV id and match an unrelated show — without it, Jellyfin matches by title and year. """ if self.movie_id_check.isChecked(): return "" 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: ClassVar[list[tuple[str, str]]] = [ ("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.ResizeMode.Stretch ) self.table.horizontalHeader().setSectionResizeMode( 1, QHeaderView.ResizeMode.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.StandardButton.Ok | QDialogButtonBox.StandardButton.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 = cast(QComboBox, self.table.cellWidget(row, 1)) result.append({"name": name, "kind": combo.currentData()}) return result class MinidlnaLibrariesDialog(QDialog): """Choose which pool libraries feed miniDLNA generation.""" def __init__( self, parent: QWidget, libraries: list[dict], selected: list[str] | None, ) -> None: super().__init__(parent) self.setWindowTitle("Knihovny pro generování miniDLNA") self.setMinimumSize(420, 360) layout = QVBoxLayout(self) layout.addWidget( QLabel( "Které knihovny se mají promítnout do miniDLNA stromu (žánry, Tipy " "dne, Nově přidané, zrcadlené seriály)?" ) ) warn = QLabel( "⚠️ Odškrtnutá knihovna: její odkazy ve stromu se při dalším " "generování odeberou jako zastaralé, dokud ji znovu nezaškrtneš." ) warn.setWordWrap(True) warn.setStyleSheet("color: #c0392b;") layout.addWidget(warn) self.list = QListWidget() chosen = set(selected) if selected is not None else None # None = all for lib in libraries: item = QListWidgetItem(f"{lib['name']} ({lib['kind']})") item.setData(Qt.ItemDataRole.UserRole, lib["name"]) item.setCheckState( Qt.CheckState.Checked if chosen is None or lib["name"] in chosen else Qt.CheckState.Unchecked ) self.list.addItem(item) layout.addWidget(self.list) quick = QHBoxLayout() all_btn = QPushButton("Vše") all_btn.clicked.connect(lambda: self._set_all(Qt.CheckState.Checked)) none_btn = QPushButton("Nic") none_btn.clicked.connect(lambda: self._set_all(Qt.CheckState.Unchecked)) quick.addWidget(all_btn) quick.addWidget(none_btn) quick.addStretch(1) layout.addLayout(quick) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _set_all(self, state) -> None: for i in range(self.list.count()): self.list.item(i).setCheckState(state) def selected_libraries(self) -> list[str]: return [ self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count()) if self.list.item(i).checkState() == Qt.CheckState.Checked ] class MigrateDialog(QDialog): """Review TMDb match proposals for an existing pool and apply the ids.""" _STATUS: ClassVar[dict[str, str]] = { "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.ResizeMode.Stretch) table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) for row, p in enumerate(proposals): name_item = QTableWidgetItem(p.current_name) name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) if getattr(p, "note", ""): name_item.setToolTip(p.note) name_item.setForeground(Qt.GlobalColor.darkBlue) table.setItem(row, 0, name_item) status_item = QTableWidgetItem(self._STATUS.get(p.status, p.status)) status_item.setFlags(Qt.ItemFlag.ItemIsEnabled) if p.status == "auto": status_item.setForeground(Qt.GlobalColor.darkGreen) elif p.status in ("no_match", "error"): status_item.setForeground(Qt.GlobalColor.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": 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.ItemFlag.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. Result maps full_path -> 1 (assign), 0 (remove), 2 (leave mixed/unchanged). """ def __init__( self, parent: QWidget, tagmanager: TagManager, files: list[File], categories: list[str] | None = None, ) -> None: super().__init__(parent) self.setWindowTitle("Přiřadit štítky") self.setMinimumSize(380, 480) self.result_map: dict[str, int] = {} self.tagmanager = tagmanager self._file_tag_sets = [{t.full_path for t in f.tags} for f in files] self._file_count = len(files) self._items: list[tuple[str, QTreeWidgetItem]] = [] self._cat_items: dict[str, QTreeWidgetItem] = {} layout = QVBoxLayout(self) layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}")) self.tree = QTreeWidget() self.tree.setHeaderHidden(True) layout.addWidget(self.tree) # Show schema categories (incl. empty ones) ∪ categories that have tags all_categories = list( dict.fromkeys((categories or []) + self.tagmanager_categories(tagmanager)) ) for category in all_categories: self._category_item(category) for tag in tagmanager.get_tags_in_category(category): self._add_tag_item(category, tag.name) add_btn = QPushButton("➕ Nový štítek…") add_btn.clicked.connect(self._add_new_tag) layout.addWidget(add_btn) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @staticmethod def tagmanager_categories(tagmanager: TagManager) -> list[str]: return sorted(tagmanager.get_categories()) def _category_item(self, category: str) -> QTreeWidgetItem: """Return (creating if needed) the header item for a category.""" if category not in self._cat_items: cat_item = QTreeWidgetItem([category]) cat_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.tree.addTopLevelItem(cat_item) cat_item.setExpanded(True) self._cat_items[category] = cat_item return self._cat_items[category] def _add_tag_item(self, category: str, name: str, force_checked: bool = False): """Add a checkable tag row under its category (deduplicated).""" full_path = f"{category}/{name}" for fp, item in self._items: if fp == full_path: # already listed → optionally (re)check it if force_checked: item.setCheckState(0, Qt.CheckState.Checked) return item have = sum(1 for s in self._file_tag_sets if full_path in s) if force_checked or have == self._file_count and self._file_count: state = Qt.CheckState.Checked elif have == 0: state = Qt.CheckState.Unchecked else: state = Qt.CheckState.PartiallyChecked item = QTreeWidgetItem([name]) item.setFlags( Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsAutoTristate ) item.setCheckState(0, state) self._category_item(category).addChild(item) self._items.append((full_path, item)) return item def _add_new_tag(self) -> None: """Prompt for a category + value and add it as a checked tag.""" known = list(self._cat_items.keys()) category, ok = QInputDialog.getItem( self, "Nový štítek", "Kategorie:", known, editable=True ) category = category.strip() if not ok or not category: return name, ok = QInputDialog.getText( self, "Nový štítek", f'Název štítku v kategorii „{category}":' ) name = name.strip() if not ok or not name: return item = self._add_tag_item(category, name, force_checked=True) self.tree.scrollToItem(item) def accept(self) -> None: mapping = { Qt.CheckState.Checked: 1, Qt.CheckState.Unchecked: 0, Qt.CheckState.PartiallyChecked: 2, } self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items} super().accept() class TagSchemaDialog(QDialog): """Editor for the tag schema: categories, their NFO source and folder rules. Each row defines a tag category, optionally which NFO field feeds it (with a value transform), and how it maps into the miniDLNA tree (a grouping folder, the output root, or no folders at all). """ # (label, source field) — the NFO fields a category can draw from FIELD_CHOICES: ClassVar[list[tuple[str, str | None]]] = [ ("(žádné — uživatelská)", None), ("Žánry (genres)", "genres"), ("Rok (year)", "year"), ("Země (countries)", "countries"), ("Hodnocení (rating)", "rating"), ] TRANSFORM_CHOICES: ClassVar[list[tuple[str, str | None]]] = [ ("(žádný)", None), ("Pásmo po 10 (decade_band)", "decade_band"), ] def __init__(self, parent: QWidget, schema: list[dict]) -> None: super().__init__(parent) self.setWindowTitle("Tag schéma — kategorie a pravidla") self.setMinimumSize(760, 420) self.result_schema: list[dict] | None = None layout = QVBoxLayout(self) layout.addWidget( QLabel( "Kategorie tagů, jejich zdroj z NFO a jak se z nich tvoří složky " 'Filmotéky.\nSložka: prázdné = v kořeni výstupu, „Dle…" = seskupit, ' 'vypnuté „Tvořit složky" = jen filtr bez složek.\n' "Šablona názvu: jak pojmenovat hardlink jen v této kategorii, např. " '„{year} - {title}{ext}" (pole: title/year/rating/ext/stem/filename).' ) ) self.table = QTableWidget(0, 6) self.table.setHorizontalHeaderLabels( [ "Kategorie", "Zdroj (NFO)", "Transform", "Tvořit složky", "Složka", "Šablona názvu", ] ) hdr = self.table.horizontalHeader() hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch) layout.addWidget(self.table) btn_row = QHBoxLayout() add_btn = QPushButton("➕ Přidat kategorii") add_btn.clicked.connect(lambda: self._append_row({})) del_btn = QPushButton("➖ Odebrat vybranou") del_btn.clicked.connect(self._remove_selected) btn_row.addWidget(add_btn) btn_row.addWidget(del_btn) btn_row.addStretch(1) layout.addLayout(btn_row) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self._save) buttons.rejected.connect(self.reject) layout.addWidget(buttons) for entry in schema: self._append_row(entry) def _append_row(self, entry: dict) -> None: row = self.table.rowCount() self.table.insertRow(row) self.table.setCellWidget(row, 0, QLineEdit(entry.get("category", ""))) field_combo = QComboBox() for label, value in self.FIELD_CHOICES: field_combo.addItem(label, value) field_combo.setCurrentIndex( self._combo_index(self.FIELD_CHOICES, entry.get("csfd_field")) ) self.table.setCellWidget(row, 1, field_combo) transform_combo = QComboBox() for label, value in self.TRANSFORM_CHOICES: transform_combo.addItem(label, value) transform_combo.setCurrentIndex( self._combo_index(self.TRANSFORM_CHOICES, entry.get("transform")) ) self.table.setCellWidget(row, 2, transform_combo) root = entry.get("filmoteka_root", "") make_folders = QCheckBox() make_folders.setChecked(root is not None) self.table.setCellWidget(row, 3, make_folders) folder_edit = QLineEdit("" if root is None else root) folder_edit.setPlaceholderText("prázdné = kořen výstupu") self.table.setCellWidget(row, 4, folder_edit) template_edit = QLineEdit(entry.get("filename_template") or "") template_edit.setPlaceholderText("výchozí název souboru") self.table.setCellWidget(row, 5, template_edit) @staticmethod def _combo_index(choices: list, value) -> int: for i, (_label, v) in enumerate(choices): if v == value: return i return 0 def _remove_selected(self) -> None: row = self.table.currentRow() if row >= 0: self.table.removeRow(row) def _save(self) -> None: schema: list[dict] = [] for row in range(self.table.rowCount()): category = cast(QLineEdit, self.table.cellWidget(row, 0)).text().strip() if not category: continue make_folders = cast(QCheckBox, self.table.cellWidget(row, 3)).isChecked() folder = cast(QLineEdit, self.table.cellWidget(row, 4)).text().strip() template = cast(QLineEdit, self.table.cellWidget(row, 5)).text().strip() schema.append( { "category": category, "csfd_field": cast( QComboBox, self.table.cellWidget(row, 1) ).currentData(), "transform": cast( QComboBox, self.table.cellWidget(row, 2) ).currentData(), "filmoteka_root": folder if make_folders else None, "filename_template": template or None, } ) self.result_schema = schema self.accept() class StatsDialog(QDialog): """Read-only overview of the pool: totals + per-category tag breakdown.""" def __init__(self, parent: QWidget, stats: dict, size_text: str) -> None: super().__init__(parent) self.setWindowTitle("Statistiky knihovny") self.setMinimumSize(460, 560) layout = QVBoxLayout(self) avg = stats["avg_rating"] summary = ( f"Filmů v poolu: {stats['count']}
" f"Celková velikost: {size_text}
" f"Průměrné hodnocení: " f"{str(avg) + ' %' if avg is not None else '—'}
" f"S NFO metadaty: {stats['with_nfo']}  ·  " f"bez: {stats['without_nfo']}
" f"Bez štítků: {stats['untagged']}" ) layout.addWidget(QLabel(summary)) tree = QTreeWidget() tree.setHeaderLabels(["Kategorie / štítek", "Počet"]) tree.setColumnWidth(0, 300) for category in sorted(stats["categories"]): entries = stats["categories"][category] cat_item = QTreeWidgetItem([f"{category} ({len(entries)})", ""]) tree.addTopLevelItem(cat_item) for name, count in entries: cat_item.addChild(QTreeWidgetItem([name, str(count)])) layout.addWidget(tree) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) buttons.rejected.connect(self.reject) buttons.accepted.connect(self.accept) layout.addWidget(buttons) class QtApp(QMainWindow): def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None: super().__init__() self.filehandler = filehandler self.tagmanager = tagmanager 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() self.filehandler.on_files_changed = lambda _=None: self.refresh_table() self.setWindowTitle(f"{APP_TITLE} — Jellyfin knihovna") geometry = self.filehandler.global_config.get("window_geometry", "1200x800") try: w, h = (int(x) for x in geometry.lower().split("x")) self.resize(w, h) except ValueError, AttributeError: self.resize(1200, 800) self._build_menu() self._build_central() self._build_statusbar() # 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 # ------------------------------------------------------------------ def _build_menu(self) -> None: bar = self.menuBar() 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…", self.edit_libraries) pool_menu.addSeparator() self._add_action(pool_menu, "Znovu načíst pool", self.reload_pool, "F5") pool_menu.addSeparator() self._add_action(pool_menu, "Konec", self.close, "Ctrl+Q") 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( movie_menu, "Přejmenovat dle metadat (rok)…", self.rename_to_canonical_naming, ) 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, "Načíst žánry z TMDb", self.refresh_from_tmdb) movie_menu.addSeparator() self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") series_menu = bar.addMenu("&Seriály") self._add_action(series_menu, "Importovat epizody…", self.import_episodes) self._add_action(series_menu, "Přehled seriálů…", self.show_series) self._add_action(series_menu, "Napravit názvy souborů…", self.rename_all_series) self._add_action(series_menu, "Odebrat z poolu…", lambda: self.remove_series()) film_menu = bar.addMenu("&miniDLNA") self._add_action( film_menu, "Knihovny pro generování…", self.select_minidlna_libraries ) self._add_action( film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G" ) jellyfin_menu = bar.addMenu("&Jellyfin") self._add_action(jellyfin_menu, "Skenovat knihovnu", self.jellyfin_scan) self._add_action(jellyfin_menu, "Ověřit proti poolu…", self.jellyfin_verify) self._add_action( jellyfin_menu, "Zkontrolovat připojení", self.jellyfin_check_connection ) settings_menu = bar.addMenu("&Nastavení") self._add_action(settings_menu, "Tag schéma…", self.edit_tag_schema) 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 ) self._add_action(tools_menu, "Kontrola duplicit…", self.check_duplicates) self._add_action( tools_menu, "Sjednotit názvy výchozích verzí…", self.normalize_default_names ) 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 ) def _add_action( self, menu: QMenu, text: str, slot, shortcut: str | None = None ) -> QAction: action = QAction(text, self) if shortcut: action.setShortcut(QKeySequence(shortcut)) action.triggered.connect(slot) menu.addAction(action) return action def _build_central(self) -> None: splitter = QSplitter(Qt.Orientation.Horizontal) # Sidebar — tag filter sidebar = QWidget() side_layout = QVBoxLayout(sidebar) side_layout.setContentsMargins(4, 4, 4, 4) side_layout.addWidget(QLabel("📂 Štítky (filtr)")) self.tag_tree = QTreeWidget() self.tag_tree.setHeaderHidden(True) self.tag_tree.itemChanged.connect(self._on_tag_filter_changed) side_layout.addWidget(self.tag_tree) splitter.addWidget(sidebar) # Main — search + movie table main = QWidget() main_layout = QVBoxLayout(main) main_layout.setContentsMargins(4, 4, 4, 4) search_row = QHBoxLayout() search_row.addWidget(QLabel("Knihovna:")) self.library_filter = QComboBox() self.library_filter.currentIndexChanged.connect(self.refresh_table) search_row.addWidget(self.library_filter) search_row.addWidget(QLabel("🔍")) self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("Hledat film…") self.search_edit.textChanged.connect(self.refresh_table) search_row.addWidget(self.search_edit) import_btn = QPushButton("➕ Importovat filmy") import_btn.clicked.connect(self.import_movie) search_row.addWidget(import_btn) main_layout.addLayout(search_row) self.table = QTableWidget(0, 5) self.table.setHorizontalHeaderLabels( ["Název", "Typ", "Knihovna", "Štítky", "Velikost"] ) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_table_menu) self.table.doubleClicked.connect(lambda _: self.edit_item()) self.table.itemSelectionChanged.connect(self._update_selection_status) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Název header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Typ header.setSectionResizeMode( 2, QHeaderView.ResizeMode.ResizeToContents ) # Knihovna header.setSectionResizeMode(3, QHeaderView.ResizeMode.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.TextInteractionFlag.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.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): if self.filehandler.is_movie_group_member(item): count = len(self.filehandler.list_movie_versions(item)) self.d_filename.setText( f"{item.file_path.parent.name}/ " f"({count} {self._versions_word(count)})" ) else: 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 # right so they stay visible while the transient area shows counts. self.jellyfin_label = QLabel() self.pool_label = QLabel() self.output_label = QLabel() self.status.addPermanentWidget(self.jellyfin_label) self.status.addPermanentWidget(self.pool_label) self.status.addPermanentWidget(self.output_label) self._update_path_status() self._update_jellyfin_status(None) def _update_jellyfin_status(self, version: str | None) -> None: """Show Jellyfin connection state; None = not checked / not configured.""" from src.core import jellyfin if not jellyfin.api_available(): self.jellyfin_label.setText("Jellyfin: ⚙ nenastaveno") elif version is None: self.jellyfin_label.setText("Jellyfin: ? nezkontrolováno") else: self.jellyfin_label.setText(f"Jellyfin: ● {version}") # ------------------------------------------------------------------ # Sidebar (tag filter) # ------------------------------------------------------------------ def refresh_sidebar(self, filtered: list[File] | None = None) -> None: """Rebuild the filter tree, preserving the active filter and updating counts. The count after each tag is how many of ``filtered`` (the movies matching the current filter; all movies when nothing is checked) also carry that tag — i.e. how many would remain if that tag were checked. Check state is restored from ``self._active_filter`` so it survives the rebuild. """ if filtered is None: filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags()) counts: dict[str, int] = {} for f in filtered: for t in f.tags: counts[t.full_path] = counts.get(t.full_path, 0) + 1 # Preserve each category's expanded/collapsed state across the rebuild # (new categories default to expanded). expanded = {} for i in range(self.tag_tree.topLevelItemCount()): cat = self.tag_tree.topLevelItem(i) if cat is not None: expanded[cat.text(0)] = cat.isExpanded() self.tag_tree.blockSignals(True) self.tag_tree.clear() for category in self.tagmanager.get_categories(): cat_item = QTreeWidgetItem([category]) cat_item.setFlags(Qt.ItemFlag.ItemIsEnabled) self.tag_tree.addTopLevelItem(cat_item) cat_item.setExpanded(expanded.get(category, True)) for tag in self.tagmanager.get_tags_in_category(category): count = counts.get(tag.full_path, 0) item = QTreeWidgetItem([f"{tag.name} ({count})"]) item.setFlags( Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled ) checked = tag.full_path in self._active_filter item.setCheckState( 0, Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked ) item.setData(0, Qt.ItemDataRole.UserRole, tag.full_path) cat_item.addChild(item) self.tag_tree.blockSignals(False) def _on_tag_filter_changed(self, item, _col) -> None: full_path = item.data(0, Qt.ItemDataRole.UserRole) if full_path is None: return # category header row, not a tag if item.checkState(0) == Qt.CheckState.Checked: self._active_filter.add(full_path) else: self._active_filter.discard(full_path) # Defer the refresh: rebuilding the tree (clear()) *inside* its own # itemChanged signal deletes the item Qt is still processing → SIGSEGV. # Running it on the next event-loop tick lets Qt finish first. QTimer.singleShot(0, self.refresh_table) def _active_filter_tags(self) -> list[Tag]: tags: list[Tag] = [] for full_path in self._active_filter: category, name = full_path.split("/", 1) tags.append(Tag(category, name)) return tags # ------------------------------------------------------------------ # Movie table # ------------------------------------------------------------------ def _refresh_library_filter(self) -> None: """Repopulate the library filter combo, keeping the current choice.""" if not hasattr(self, "library_filter"): return current = self.library_filter.currentText() self.library_filter.blockSignals(True) self.library_filter.clear() self.library_filter.addItem("Vše") for name in self.filehandler.libraries_accepting_movies: self.library_filter.addItem(name) idx = self.library_filter.findText(current) self.library_filter.setCurrentIndex(max(idx, 0)) self.library_filter.blockSignals(False) def _selected_library_filter(self) -> str | None: """The library the table is filtered to, or None for 'Vše'.""" if not hasattr(self, "library_filter"): return None text = self.library_filter.currentText() return None if text in ("", "Vše") else text def refresh_table(self, *_args) -> None: # 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()) # A multi-version movie contributes only its primary version as a row # — the rest live under "Upravit film → Verze", not as separate rows. visible_paths = {f.file_path for f in self.filehandler.visible_movies()} tag_filtered = [f for f in tag_filtered if f.file_path in visible_paths] library = self._selected_library_filter() search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" 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(movies)} filmů, {len(series)} seriálů", 4000 ) @staticmethod def _versions_word(count: int) -> str: return "verze" if 2 <= count <= 4 else "verzí" def _fill_row(self, row: int, item) -> None: """Fill one table row from a movie (File) or a series.Series. A movie made of several media files (a version folder) is marked so it doesn't pass for a single file: ``▣`` before the name in bold, the version count in the Typ column, the summed size and a tooltip listing every file. """ versions: list[File] = [] if isinstance(item, File): name = item.title or item.filename kind = "Film" if self.filehandler.is_movie_group_member(item): versions = self.filehandler.list_movie_versions(item) name = f"▣ {name}" n = len(versions) if all( normalize_part_label(v._identity_from_path()[4]) is not None for v in versions ): kind = f"Film · {n} {'díly' if 2 <= n <= 4 else 'dílů'}" else: kind = f"Film · {n} {self._versions_word(n)}" values = [ name, kind, 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): cell = QTableWidgetItem(value) if versions: if col == 0: font = cell.font() font.setBold(True) cell.setFont(font) cell.setToolTip( "Verze filmu:\n" + "\n".join(f" • {v.file_path.name}" for v in versions) ) self.table.setItem(row, col, cell) def _item_size(self, item) -> str: """Formatted size of a movie (all its versions), or a series' episodes.""" try: if isinstance(item, File): total = sum( v.file_path.stat().st_size for v in self.filehandler.list_movie_versions(item) ) 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: for unit in ["B", "KB", "MB", "GB"]: if size_bytes < 1024.0: return f"{size_bytes:.1f} {unit}" size_bytes /= 1024.0 return f"{size_bytes:.1f} TB" def _selected_items(self) -> list: rows = {idx.row() for idx in self.table.selectionModel().selectedRows()} 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) # 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])) menu.addSeparator() menu.addAction("Odebrat z poolu…", lambda: self.remove_series(series)) 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("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() return total = 0 for f in files: try: total += f.file_path.stat().st_size except OSError: pass self.status.showMessage(f"{len(files)} vybráno — {self._format_size(total)}") def _update_path_status(self) -> None: pool = self.filehandler.pool_dir out = self.filehandler.filmoteka_dir self.pool_label.setText(f"Pool: {pool or '—'}") self.output_label.setText(f"Output: {out or '—'}") self.status.showMessage(f"{len(self.filehandler.filelist)} filmů") # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def set_pool(self) -> None: folder = QFileDialog.getExistingDirectory(self, "Vyber složku poolu") if not folder: 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() def set_filmoteka(self) -> None: folder = QFileDialog.getExistingDirectory( self, "Vyber výstupní složku Filmotéky" ) if not folder: return self.filehandler.set_filmoteka_dir(Path(folder)) self._update_path_status() def reload_pool(self) -> None: if not self.filehandler.movies_dir: QMessageBox.information(self, "Pool", "Nejprve nastavte pool.") return self.filehandler.load_pool_movies() self.reload_series() self.refresh_table() def import_movie(self) -> None: if not self.filehandler.movies_dir: QMessageBox.warning( self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool)." ) return # NOTE: hit a native SIGSEGV here once (2026-09-13), inside Qt's own # QFileDialog teardown (QPersistentModelIndex::~QPersistentModelIndex, # preceded by "QAbstractItemModel::endRemoveRows: Invalid index" from # QFileSystemModel) — a known long-standing Qt/PySide bug (Qt5 through # 6.11), not a Curator issue; confirmed via `coredumpctl` + gdb # backtrace, no Python frame involved. That run's control group showed # the process launched from inside VS Code's Snap-sandboxed terminal, # which can block D-Bus access to xdg-desktop-portal and force Qt's # own (non-native) file dialog — the buggy implementation the trace # actually shows in use. If this recurs, try launching Curator from a # plain (non-snap-sandboxed) terminal first. paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory") if not paths: return 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.libraries_accepting_movies ) if dialog.exec() != QDialog.DialogCode.Accepted: return entries = dialog.entries() version_links = dialog.version_links() skipped_no_tmdb = dialog.skipped_no_tmdb if not entries: if skipped_no_tmdb: QMessageBox.information( self, "Import", f"Přeskočeno {len(skipped_no_tmdb)} souborů bez TMDb ID, " "nic k importu.", ) return move = dialog.move_files library = dialog.selected_library # Rows whose film (by identity — title, year, TMDb id; a 3D tag or a # grouped folder doesn't change it) is already in the pool def is_colliding(t: str, y: str, tid: str) -> bool: return bool( is_valid_year(y) and self.filehandler.pooled_with_stem( canonical_pool_stem(t, y, tid or None) ) ) colliding = [ canonical_pool_stem(t, y, tid or None) for s, t, y, tid, _c, _fmt in entries if is_colliding(t, y, tid) ] on_conflict = "suffix" version_labels: dict[Path, str] = {} if colliding: choice = self._resolve_import_conflicts(colliding) if choice is None: return # user cancelled the whole import on_conflict = choice if on_conflict == "version": # Each already-pooled film's new file gets its own popisek. for s, t, y, tid, _c, fmt in entries: if not is_colliding(t, y, tid): continue label_dialog = VersionLabelDialog( self, initial=f"3D {fmt}" if fmt else "", intro=f"„{s.name}“ → nová verze filmu „{t}“, který už v poolu je.", ) if label_dialog.exec() != QDialog.DialogCode.Accepted: return # cancelled — nothing imported yet version_labels[s] = label_dialog.label() imported: list[File] = [] imported_by_source: dict[Path, File] = {} skipped = 0 errors: list[str] = [] for source, title, year, tmdb_id, csfd_url, format_3d 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, csfd_url=csfd_url or None, format_3d=format_3d, version_label=version_labels.get(source), ) if movie is None: skipped += 1 else: imported.append(movie) imported_by_source[source] = movie except Exception as exc: # noqa: BLE001 — surface per-file import failures errors.append(f"{source.name}: {exc}") # Rows marked "Přiřadit jako verzi…" become another version of the # film their target row just landed as, instead of a film of their own. total = len(entries) + len(version_links) for source, (target_source, label) in version_links.items(): target = imported_by_source.get(target_source) if target is None: errors.append( f"{source.name}: hlavní verze ({target_source.name}) se " "nenaimportovala, tahle verze se přeskakuje." ) skipped += 1 continue try: imported.append( self.filehandler.add_movie_version(target, source, label, move=move) ) except (ValueError, RuntimeError, FileExistsError) as exc: errors.append(f"{source.name}: {exc}") self.refresh_table() verb = "Přesunuto" if move else "Zkopírováno" summary = f"{verb} {len(imported)}/{total} filmů." if skipped_no_tmdb: summary += f" Přeskočeno {len(skipped_no_tmdb)} bez TMDb ID." if errors: QMessageBox.warning( self, "Import dokončen s chybami", summary + "\n\nChyby:\n" + "\n".join(errors[:5]), ) else: QMessageBox.information(self, "Import", summary) self.status.showMessage(summary, 5000) def _resolve_import_conflicts(self, names: list[str]) -> str | None: """Ask what to do with films already in the pool. Returns the policy for :meth:`FileManager.import_movie` — ``"version"`` (add the new file as another version of that film), ``"replace"`` (the whole old film goes, then the new one is imported) or ``"skip"`` — or ``None`` when the user cancels the import. There's deliberately no "keep both as separate films": these share a TMDb id, so they would share one movie record anyway — the same film twice is a duplicate, and a duplicate is what *versions* are for. """ box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Question) box.setWindowTitle("Film už v poolu je") listing = "\n".join(f" • {n}" for n in names[:10]) more = f"\n … a další ({len(names) - 10})" if len(names) > 10 else "" box.setText(f"Tyhle filmy už v poolu jsou:\n{listing}{more}\n\nCo s nimi?") version_btn = box.addButton( "Přidat jako verzi", QMessageBox.ButtonRole.AcceptRole ) replace_btn = box.addButton( "Nahradit celý film novým", QMessageBox.ButtonRole.DestructiveRole ) skip_btn = box.addButton("Přeskočit", QMessageBox.ButtonRole.ActionRole) box.addButton("Zrušit import", QMessageBox.ButtonRole.RejectRole) box.setDefaultButton(version_btn) box.exec() clicked = box.clickedButton() if clicked is version_btn: return "version" if clicked is replace_btn: return "replace" if clicked is skip_btn: return "skip" return None def open_movies(self) -> None: for f in self._selected_movies(): self._open_path(f.file_path) def _open_path(self, path: Path) -> None: try: if sys.platform.startswith("win"): os.startfile(path) # type: ignore[attr-defined] elif sys.platform.startswith("darwin"): subprocess.call(["open", str(path)]) else: subprocess.call(["xdg-open", str(path)]) except Exception as exc: # noqa: BLE001 QMessageBox.critical(self, "Chyba", f"Nelze otevřít {path}: {exc}") def assign_tags(self) -> None: files = self._selected_movies() if not files: QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.") return schema_categories = [e["category"] for e in self.filehandler.tag_schema] dialog = AssignTagsDialog(self, self.tagmanager, files, schema_categories) if dialog.exec() != QDialog.DialogCode.Accepted: return for full_path, state in dialog.result_map.items(): category, name = full_path.split("/", 1) if state == 1: self.filehandler.assign_tag_to_file_objects( files, self.tagmanager.add_tag(category, name) ) elif state == 0: self.filehandler.remove_tag_from_file_objects( files, Tag(category, name) ) self.refresh_table() def set_date(self) -> None: files = self._selected_movies() if not files: QMessageBox.information(self, "Datum", "Nejprve vyberte filmy.") return text, ok = QInputDialog.getText( self, "Nastavit datum", "Datum (YYYY-MM-DD, prázdné = smazat):" ) if not ok: return for f in files: f.set_date(text.strip() or None) self.refresh_table() def rename_movie(self) -> None: files = self._selected_movies() if len(files) != 1: QMessageBox.information(self, "Přejmenovat", "Vyberte právě jeden film.") return f = files[0] current = f.file_path.stem # name without extension text, ok = QInputDialog.getText( self, "Přejmenovat film", f"Nový název (bez přípony {f.file_path.suffix}):", text=current, ) if not ok: return try: self.filehandler.rename_movie(f, text) except (ValueError, FileExistsError, OSError) as exc: QMessageBox.warning(self, "Přejmenování selhalo", str(exc)) return 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 dialog = MovieEditDialog(self, files[0], self.filehandler) if dialog.exec() != QDialog.DialogCode.Accepted: self.refresh_table() # versions may have been added/removed meanwhile return f = dialog.file_obj # its own file may have been removed — a sibling then 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, csfd_url=dialog.csfd_url, format_3d=dialog.format_3d, ) 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 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.DialogCode.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 if not files: QMessageBox.information(self, "Přejmenovat dle metadat", "Pool je prázdný.") return scope = "vybrané filmy" if self._selected_movies() else "celý pool" answer = QMessageBox.question( self, "Přejmenovat dle metadat (rok)", f"Přejmenovat {scope} ({len(files)}) na „Název (rok) [tmdbid].ext“ " "podle metadat?\n\nFilmy bez známého roku se přeskočí.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if answer != QMessageBox.StandardButton.Yes: return progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self) progress.setWindowTitle("Přejmenovat dle metadat (rok)") progress.setWindowModality(Qt.WindowModality.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.rename_all_to_canonical( files, on_progress=on_progress, should_cancel=progress.wasCanceled ) progress.setValue(len(files)) self.refresh_table() renamed = result["renamed"] no_year = result["skipped_no_year"] conflicts = result["conflicts"] suffix = " (zrušeno)" if result["cancelled"] else "" summary = ( f"Přejmenováno: {len(renamed)}, beze změny: {result['unchanged']}, " f"bez roku: {len(no_year)}, kolize: {len(conflicts)}{suffix}" ) if not (no_year or conflicts): QMessageBox.information(self, "Přejmenování dokončeno", summary) else: detail: list[str] = [] if renamed: detail.append(f"■ Přejmenováno ({len(renamed)}):") detail += [f" • {old} → {new}" for old, new in renamed] detail.append("") if no_year: detail.append(f"■ Bez známého roku — přeskočeno ({len(no_year)}):") detail += [f" • {n}" for n in no_year] detail.append("") if conflicts: detail.append(f"■ Kolize názvu ({len(conflicts)}):") detail += [f" • {old} — {msg}" for old, msg in conflicts] box = QMessageBox(self) box.setIcon( QMessageBox.Icon.Warning if conflicts else QMessageBox.Icon.Information ) box.setWindowTitle("Přejmenování dle metadat") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) box.exec() self.status.showMessage(summary, 5000) def import_episodes(self) -> None: """Import episode files into a series (Series/Season NN/Series SxxEyy). The dialog opens empty — add episodes file-by-file or pick a whole series folder (its subfolders are scanned too). """ if not self.filehandler.pool_dir: QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.") return dialog = ImportEpisodesDialog(self, [], self.filehandler.series_libraries) if dialog.exec() != QDialog.DialogCode.Accepted: return result = self.filehandler.import_episodes( dialog.sources(), dialog.title, year=dialog.year or None, tmdb_id=dialog.tmdb_id or None, library=dialog.library, move=dialog.move_files, codes=dialog.episode_codes(), ) msg = ( f"Naimportováno {result['imported']} epizod. " f"Nerozpoznáno (bez SxxEyy): {len(result['skipped_no_code'])}." ) if result["errors"]: msg += "\n\nChyby:\n" + "\n".join(result["errors"][:5]) 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: if not self.filehandler.pool_dir: QMessageBox.information(self, "Seriály", "Nejprve nastavte pool.") return SeriesOverviewDialog(self, self.filehandler).exec() self.reload_series() self.refresh_table() def rename_all_series(self) -> None: """Bulk-fix every series' episode filenames to ``SxxEyy - {série}.ext``.""" if not self.filehandler.pool_dir: QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.") return count = len(self.filehandler.load_series()) if not count: QMessageBox.information(self, "Seriály", "Žádné seriály v poolu.") return answer = QMessageBox.question( self, "Napravit názvy souborů", f"Přejmenovat epizody u {count} seriálů na „SxxEyy - Název " "seriálu.ext“ a uklidit prázdné Season složky?\n\nEpizody bez " "rozpoznaného SxxEyy se ponechají a vypíšou.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if answer != QMessageBox.StandardButton.Yes: return progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, count, self) progress.setWindowTitle("Napravit názvy souborů seriálů") progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) def on_progress(done: int, total: int, name: str) -> None: progress.setValue(done) progress.setLabelText(f"[{done}/{total}] {name}") QApplication.processEvents() result = self.filehandler.rename_all_series_to_canonical( on_progress=on_progress, should_cancel=progress.wasCanceled ) progress.setValue(count) self.reload_series() self.refresh_table() renamed = result["renamed"] skipped = result["skipped_no_code"] conflicts = result["conflicts"] swept = result["empty_dirs_removed"] tail = " (zrušeno)" if result["cancelled"] else "" summary = ( f"Přejmenováno: {len(renamed)}, bez SxxEyy: {len(skipped)}, " f"kolize: {len(conflicts)}, uklizené složky: {len(swept)}{tail}" ) if not (skipped or conflicts): QMessageBox.information(self, "Hotovo", summary) else: detail: list[str] = [] if renamed: detail.append(f"■ Přejmenováno ({len(renamed)}):") detail += [f" • [{s}] {old} → {new}" for s, old, new in renamed] detail.append("") if skipped: detail.append(f"■ Bez rozpoznaného SxxEyy ({len(skipped)}):") detail += [f" • [{s}] {n}" for s, n in skipped] detail.append("") if conflicts: detail.append(f"■ Kolize ({len(conflicts)}):") detail += [f" • [{s}] {n} — {msg}" for s, n, msg in conflicts] box = QMessageBox(self) box.setIcon( QMessageBox.Icon.Warning if conflicts else QMessageBox.Icon.Information ) box.setWindowTitle("Napravit názvy souborů seriálů") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) box.exec() self.status.showMessage(summary, 5000) def normalize_default_names(self) -> None: """Nástroje: rename " - Výchozí.ext" to the bare ".ext".""" if not self.filehandler.pool_dir: QMessageBox.information(self, "Výchozí verze", "Nejprve nastavte pool.") return self.filehandler.load_pool_movies() answer = QMessageBox.question( self, "Sjednotit názvy výchozích verzí", "Výchozí verze filmů s víc verzemi se přejmenuje z „Název - Výchozí.ext“ " "na holé „Název.ext“ (titulky a metadata se přenesou). Pokračovat?", ) if answer != QMessageBox.StandardButton.Yes: return renamed = self.filehandler.normalize_default_version_names() self.refresh_table() QMessageBox.information( self, "Sjednotit názvy výchozích verzí", f"Přejmenováno: {len(renamed)}." if renamed else "Nic k přejmenování.", ) def check_duplicates(self) -> None: """Nástroje: list movies present more than once and offer merge / delete.""" if not self.filehandler.pool_dir: QMessageBox.information(self, "Kontrola duplicit", "Nejprve nastavte pool.") return dialog = DuplicatesDialog(self, self.filehandler) dialog.exec() if dialog.changed: self.refresh_table() 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.WindowModality.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_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, "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.WindowModality.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 self.refresh_table() 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: files = self._selected_movies() if not files: return grouped = sum(1 for f in files if self.filehandler.is_movie_group_member(f)) note = ( f"\n\n{grouped} z nich má více verzí — smažou se všechny verze." if grouped else "" ) answer = QMessageBox.question( self, "Odebrat z poolu", f"Smazat {len(files)} film(ů) z poolu včetně souborů, titulků a " f"metadat?{note}", ) if answer != QMessageBox.StandardButton.Yes: return for f in files: try: self.filehandler.remove_movie(f) except OSError as exc: QMessageBox.warning(self, "Chyba", f"Nelze smazat {f.filename}: {exc}") self.refresh_table() def remove_series(self, series_list: list | None = None) -> None: """Delete whole series folders from the pool (all seasons/episodes).""" targets = series_list if series_list is not None else self._selected_series() if not targets: return listing = "\n".join(f" • {s.title}" for s in targets[:10]) more = f"\n … a další ({len(targets) - 10})" if len(targets) > 10 else "" answer = QMessageBox.question( self, "Odebrat z poolu", f"Smazat {len(targets)} seriál(ů) z poolu včetně všech sezón a " f"epizod?\n{listing}{more}", ) if answer != QMessageBox.StandardButton.Yes: return for ser in targets: try: self.filehandler.remove_series(ser) except (ValueError, OSError) as exc: QMessageBox.warning(self, "Chyba", f"Nelze smazat „{ser.title}“: {exc}") self.reload_series() self.refresh_table() def select_minidlna_libraries(self) -> None: """Choose which pool libraries feed miniDLNA generation (persisted).""" if not self.filehandler.pool_dir: QMessageBox.information(self, "miniDLNA", "Nejprve nastavte pool.") return dialog = MinidlnaLibrariesDialog( self, self.filehandler.libraries, self.filehandler.minidlna_libraries ) if dialog.exec() != QDialog.DialogCode.Accepted: return chosen = dialog.selected_libraries() all_names = {lib["name"] for lib in self.filehandler.libraries} # Selecting every library is the same as "all" (None) — no scoping. self.filehandler.set_minidlna_libraries( None if set(chosen) == all_names else chosen ) n = len(chosen) self.status.showMessage( f"miniDLNA: {n}/{len(all_names)} knihoven vybráno pro generování.", 6000 ) def generate_filmoteka(self) -> None: out = self.filehandler.filmoteka_dir if not out: QMessageBox.warning( self, "miniDLNA", "Nejprve nastavte výstupní složku (Pool → Nastavit Filmotéku).", ) return files, mirror_libs, mixed_series = self.filehandler.minidlna_scope() scoped = self.filehandler.minidlna_libraries is not None if not files and not mirror_libs and not mixed_series: QMessageBox.information( self, "miniDLNA", "Pool je prázdný." if not scoped else "Vybrané knihovny neobsahují nic k vygenerování — " "zkontroluj miniDLNA → „Knihovny pro generování…“.", ) return # Refresh genres/year from TMDb so the genre tree is current. self.filehandler.refresh_tags_from_tmdb(files) manager = HardlinkManager(out) # Every mirrored library (series/homevideo, + the mixed-library series # bucket) stays reserved — protected from tag-tree cleanup — even when # this run skips mirroring it. reserved = set(self.filehandler.mirror_as_is_libraries) | { TIPS_SUBFOLDER, RECENT_SUBFOLDER, MIXED_SERIES_SUBFOLDER, } created, create_fail, removed, remove_fail = manager.sync_structure( files, category_roots=self.filehandler.filmoteka_category_roots(), category_transforms=self.filehandler.filmoteka_category_transforms(), category_filename_templates=( self.filehandler.filmoteka_category_filename_templates() ), reserved_subfolders=reserved, ) # Mirrored libraries (series copy-as-is + homevideo): hardlink each # whole library folder 1:1 into output/ — neither # kind has genre tags to build a tree from. pool = self.filehandler.pool_dir mirrored = 0 mirror_fail = 0 for name in mirror_libs: src = pool / name if pool else None if src and src.is_dir() and any(src.iterdir()): m_created, m_failed = manager.mirror_as_is(src, name) mirrored += m_created mirror_fail += m_failed # Series found inside a "mixed" library: no genre tags apply, so just # hardcopy each one into output/Seriály/ — same # mechanism as a dedicated series library, scoped to one series' folder. for ser in mixed_series: if ser.folder.is_dir(): m_created, m_failed = manager.mirror_as_is( ser.folder, f"{MIXED_SERIES_SUBFOLDER}/{ser.folder.name}" ) mirrored += m_created mirror_fail += m_failed # Mirrors have no obsolete-link cleanup of their own (unlike the genre # tree) — a library/series deselected this run would otherwise linger # in the output forever. Remove exactly what fell out of scope. stale_libs, valid_bucket = self.filehandler.minidlna_prune_targets( mirror_libs, mixed_series, MIXED_SERIES_SUBFOLDER ) pruned = sum(1 for name in stale_libs if manager.remove_mirror(name)) bucket_dir = out / MIXED_SERIES_SUBFOLDER if bucket_dir.is_dir(): for entry in list(bucket_dir.iterdir()): if entry.name not in valid_bucket and manager.remove_mirror( f"{MIXED_SERIES_SUBFOLDER}/{entry.name}" ): pruned += 1 # Special folders (pool only; copy-as-is excluded): random tips + newest tips, tips_fail = manager.generate_random_tips( files, count=TIPS_COUNT, subfolder=TIPS_SUBFOLDER ) recent, recent_fail = manager.generate_recently_added( files, count=RECENT_COUNT, subfolder=RECENT_SUBFOLDER ) scope_note = ( f"\n(omezeno na {len(self.filehandler.minidlna_libraries or [])} knihoven — " "miniDLNA → „Knihovny pro generování…“)" if scoped else "" ) pruned_note = f", odebráno mimo výběr: {pruned}" if pruned else "" msg = ( f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n" f"Zrcadleno (seriály / homevideo): {mirrored}{pruned_note}\n" f"Tipy dne — náhodně: {tips}, Nově přidané: {recent}{scope_note}" ) failed = create_fail + remove_fail + mirror_fail + tips_fail + recent_fail if failed: msg += f"\nSelhalo: {failed}" QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg) else: QMessageBox.information(self, "Filmotéka vygenerována", msg) self.status.showMessage( f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}, " f"tipy +{tips}, nově +{recent}", 5000, ) def edit_libraries(self) -> None: """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 dialog = LibraryOrganizerDialog(self, self.filehandler.libraries) if dialog.exec() != QDialog.DialogCode.Accepted: return self.filehandler.set_libraries(dialog.libraries()) pool = self.filehandler.pool_dir for lib in self.filehandler.libraries: (pool / lib["name"]).mkdir(parents=True, exist_ok=True) self._refresh_library_filter() self.refresh_table() 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.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if ans == QMessageBox.StandardButton.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.""" files = self._selected_movies() if not files: QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.") return 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: return moved, errors = 0, [] for f in files: try: self.filehandler.move_to_library(f, target) moved += 1 except (ValueError, FileExistsError, OSError) as exc: errors.append(f"{f.filename}: {exc}") self._refresh_library_filter() self.refresh_table() summary = f"Přesunuto {moved}/{len(files)} do „{target}“." if errors: QMessageBox.warning( self, "Přesun s chybami", summary + "\n\n" + "\n".join(errors[:5]) ) self.status.showMessage(summary, 5000) # ------------------------------------------------------------------ # Jellyfin # ------------------------------------------------------------------ def _jellyfin_ready(self) -> bool: from src.core import jellyfin if not jellyfin.api_available(): QMessageBox.warning( self, "Jellyfin", "Nastav JELLYFIN_URL a JELLYFIN_API_KEY v .env " "(klíč vytvoříš v Jellyfin → Dashboard → API Keys).", ) return False return True def _jellyfin_call(self, fn): """Run a Jellyfin call under a wait cursor; return (result, error_str).""" from src.core import jellyfin QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: return fn(), None except jellyfin.JellyfinError as exc: return None, str(exc) finally: QApplication.restoreOverrideCursor() def jellyfin_check_connection(self) -> None: from src.core import jellyfin if not self._jellyfin_ready(): return version, err = self._jellyfin_call(lambda: jellyfin.server_version(timeout=8)) if err: self._update_jellyfin_status(None) QMessageBox.warning(self, "Jellyfin", err) return self._update_jellyfin_status(version) QMessageBox.information(self, "Jellyfin", f"Připojeno — verze {version}.") def jellyfin_scan(self) -> None: from src.core import jellyfin if not self._jellyfin_ready(): return def scan_then_version() -> str: jellyfin.scan_all() return jellyfin.server_version() version, err = self._jellyfin_call(scan_then_version) if err: self._update_jellyfin_status(None) QMessageBox.warning(self, "Jellyfin", err) return self._update_jellyfin_status(version) self.status.showMessage("Jellyfin: sken knihovny spuštěn", 5000) QMessageBox.information(self, "Jellyfin", "Sken knihovny spuštěn.") def jellyfin_verify(self) -> None: from src.core import jellyfin if not self._jellyfin_ready(): return data, err = self._jellyfin_call( lambda: (jellyfin.fetch_movies(), jellyfin.server_version()) ) if err: self._update_jellyfin_status(None) QMessageBox.warning(self, "Jellyfin", err) return movies, version = data self._update_jellyfin_status(version) result = self.filehandler.verify_against_jellyfin(movies) pool_only, jf_only = result["pool_only"], result["jellyfin_only"] summary = ( f"Spárováno: {result['matched']}/{result['pool_total']} | " f"Jellyfin má: {result['jellyfin_total']}" ) if not pool_only and not jf_only: QMessageBox.information( self, "Ověření proti Jellyfin", "✅ Vše sedí.\n\n" + summary ) return detail: list[str] = [] if pool_only: detail.append( f"■ V poolu, Jellyfin nevidí ({len(pool_only)}) — přeskenuj / " "oprav match v Jellyfinu:" ) detail += [f" • {n}" for n in pool_only] detail.append("") if jf_only: detail.append(f"■ V Jellyfinu, ne v poolu ({len(jf_only)}):") detail += [f" • {n}" for n in jf_only] box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle("Ověření proti Jellyfin — nesrovnalosti") box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") box.setDetailedText("\n".join(detail)) box.exec() def edit_tag_schema(self) -> None: dialog = TagSchemaDialog(self, self.filehandler.tag_schema) if dialog.exec() != QDialog.DialogCode.Accepted or dialog.result_schema is None: return self.filehandler.set_tag_schema(dialog.result_schema) 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 TMDb" a přegenerujte miniDLNA.', 8000, ) def show_statistics(self) -> None: if not self.filehandler.filelist: QMessageBox.information(self, "Statistiky", "Pool je prázdný.") return stats = self.filehandler.statistics() StatsDialog(self, stats, self._format_size(stats["total_size"])).exec() def check_consistency(self) -> None: """Test: does the pool index match the actual files in pool/Filmy?""" if not self.filehandler.movies_dir: QMessageBox.information(self, "Testy", "Nejprve nastavte pool.") return result = self.filehandler.check_data_consistency() if not result["ok"]: QMessageBox.warning(self, "Kontrola konzistence", result["error"]) return missing, untracked = result["missing"], result["untracked"] if not missing and not untracked: QMessageBox.information( self, "Kontrola konzistence dat", f"✅ Vše v pořádku — index a pool si odpovídají.\n\n" f"Záznamů v indexu: {result['index_count']}\n" f"Souborů v poolu: {result['disk_count']}", ) return detail: list[str] = [] if missing: detail.append( f"■ Metadata bez souboru ({len(missing)}) — soubor byl smazán " "přímo, záznam v indexu zůstal:" ) detail += [f" • {k}" for k in missing] detail.append("") if untracked: detail.append( f"■ Soubory bez metadat ({len(untracked)}) — přidané do poolu " "mimo import:" ) detail += [f" • {k}" for k in untracked] box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle("Kontrola konzistence — nalezeny nesrovnalosti") box.setText( f"Metadata bez souboru: {len(missing)}\n" f"Soubory bez metadat: {len(untracked)}\n\n" 'Podrobnosti zobrazíš přes „Show Details…".' ) box.setDetailedText("\n".join(detail)) box.exec() def check_video_integrity(self) -> None: """Test: scan movies for corrupted / unreadable video data (FFmpeg).""" if not self.filehandler.movies_dir: QMessageBox.information(self, "Testy", "Nejprve nastavte pool.") return # Scope: selected movies, or the whole pool when nothing is selected. files = self._selected_movies() or self.filehandler.filelist if not files: QMessageBox.information(self, "Kontrola integrity", "Pool je prázdný.") return scope = "vybrané filmy" if self._selected_movies() else "celý pool" # Depth: deep = full decode (thorough, slow), quick = ffprobe (fast). box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Question) box.setWindowTitle("Kontrola integrity videa") box.setText( f"Zkontrolovat {scope} ({len(files)}).\n\n" "Důkladná: plné dekódování přes ffmpeg — odhalí i poškozená data " "uvnitř souboru, ale čte celý soubor (pomalé).\n" "Rychlá: ffprobe — odhalí nečitelné/uříznuté soubory (rychlé)." ) deep_btn = box.addButton("Důkladná", QMessageBox.ButtonRole.AcceptRole) quick_btn = box.addButton("Rychlá", QMessageBox.ButtonRole.AcceptRole) box.addButton("Zrušit", QMessageBox.ButtonRole.RejectRole) box.exec() clicked = box.clickedButton() if clicked not in (deep_btn, quick_btn): return deep = clicked is deep_btn progress = QProgressDialog( "Kontroluji integritu videa…", "Zrušit", 0, len(files), self ) progress.setWindowTitle("Kontrola integrity videa") progress.setWindowModality(Qt.WindowModality.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.scan_video_integrity( files, deep=deep, on_progress=on_progress, should_cancel=progress.wasCanceled, ) progress.setValue(len(files)) if not result["ok"]: QMessageBox.warning(self, "Kontrola integrity", result["error"]) return problems = result["problems"] checked = result["checked"] suffix = " (zrušeno)" if result["cancelled"] else "" if not problems: QMessageBox.information( self, "Kontrola integrity videa", f"✅ Bez problémů — zkontrolováno {checked} souborů{suffix}.", ) return bad = [r for r in problems if r.checked] unchecked = [r for r in problems if not r.checked] detail: list[str] = [] if bad: detail.append(f"■ Poškozené / vadné ({len(bad)}):") detail += [f" • {r.path.name} — {r.error}" for r in bad] detail.append("") if unchecked: detail.append(f"■ Nešlo zkontrolovat ({len(unchecked)}):") detail += [f" • {r.path.name} — {r.error}" for r in unchecked] msg = QMessageBox(self) msg.setIcon(QMessageBox.Icon.Warning) msg.setWindowTitle("Kontrola integrity — nalezeny problémy") msg.setText( f"Zkontrolováno: {checked}{suffix}\n" f"Poškozené / vadné: {len(bad)}\n" f"Nešlo zkontrolovat: {len(unchecked)}\n\n" 'Podrobnosti zobrazíš přes „Show Details…".' ) msg.setDetailedText("\n".join(detail)) msg.exec() def closeEvent(self, event) -> None: self.filehandler.global_config["window_geometry"] = ( f"{self.width()}x{self.height()}" ) from src.core.config import save_global_config save_global_config(self.filehandler.global_config) super().closeEvent(event) def run(filehandler: FileManager, tagmanager: TagManager) -> None: app = QApplication.instance() or QApplication(sys.argv) window = QtApp(filehandler, tagmanager) window.show() app.exec()