""" 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 sys import subprocess from pathlib import Path from typing import List, Optional from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, QProgressDialog, ) from src.core.file_manager import FileManager from src.core.tag_manager import TagManager from src.core.file import File from src.core.tag import Tag from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager # 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 class ImportMoviesDialog(QDialog): """Collect a Title + ČSFD link per file for a batch import into the pool. One row per source file (filename shown, Title and ČSFD link editable). More files can be added from inside the dialog. A single toggle decides whether the files are copied (default, non-destructive) or moved into the pool. """ def __init__( self, parent: QWidget, sources: List[Path], existing_names: set[str] | None = None, ) -> None: super().__init__(parent) self.setWindowTitle("Importovat filmy do poolu") self.setMinimumSize(720, 360) # Lower-cased names already present in the pool (for collision highlight) self.existing_names = {n.lower() for n in (existing_names or set())} # (source path, title field, ČSFD field) per row self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = [] layout = QVBoxLayout(self) self.table = QTableWidget(0, 4) self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""]) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(3, QHeaderView.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("🔎 Najít ČSFD odkazy") find_btn.setToolTip("Vyhledá na ČSFD podle názvu a vyplní prázdné odkazy") find_btn.clicked.connect(self._autofill_csfd) add_row.addWidget(find_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.Ok | QDialogButtonBox.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.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)) csfd_edit = QLineEdit() csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") 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, csfd_edit) self.table.setCellWidget(row, 3, remove_btn) self._rows.append((source, title_edit, csfd_edit)) self._mark_collision(title_edit) def _mark_collision(self, title_edit: QLineEdit) -> None: """Highlight the title in red when that name already exists in the pool.""" clashes = title_edit.text().strip().lower() in self.existing_names title_edit.setStyleSheet( "QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "") title_edit.setToolTip( "V poolu už existuje film s tímto názvem — při importu dostane " "číselnou příponu." if clashes else "") def _remove_row(self, title_edit: QLineEdit) -> None: """Drop a single file from the import list without restarting the dialog.""" for index, (_src, edit, _csfd) in enumerate(self._rows): if edit is title_edit: self.table.removeRow(index) self._rows.pop(index) return def _add_files(self) -> None: paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory") for path in paths: self._append_row(Path(path)) def _autofill_csfd(self) -> None: """Fill empty ČSFD fields by searching ČSFD for each file's cleaned name.""" import requests from src.core import csfd targets = [(t, c) for _, t, c in self._rows if not c.text().strip()] if not targets: QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.") return found = 0 QApplication.setOverrideCursor(Qt.WaitCursor) try: with requests.Session() as session: for title_edit, csfd_edit in targets: query = csfd.clean_filename_to_query(title_edit.text()) try: url = csfd.find_csfd_url(query, session=session) except Exception: # noqa: BLE001 — network/parse failure for one row url = None if url: csfd_edit.setText(url) found += 1 finally: QApplication.restoreOverrideCursor() QMessageBox.information( self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů." ) @property def move_files(self) -> bool: return self.move_check.isChecked() def entries(self) -> list[tuple[Path, str, str]]: """Return (source, title, csfd_link) per row; title falls back to stem.""" result: list[tuple[Path, str, str]] = [] for source, title_edit, csfd_edit in self._rows: title = title_edit.text().strip() or source.stem result.append((source, title, csfd_edit.text().strip())) return result 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.Ok | QDialogButtonBox.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.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.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.Checked elif have == 0: state = Qt.Unchecked else: state = Qt.PartiallyChecked item = QTreeWidgetItem([name]) item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.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.Checked: 1, Qt.Unchecked: 0, Qt.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 ČSFD source and folder rules. Each row defines a tag category, optionally which ČSFD field feeds it (with a value transform), and how it maps into the Filmotéka tree (a grouping folder, the output root, or no folders at all). """ # (label, csfd_field) — the CSFDMovie attributes a category can draw from FIELD_CHOICES = [ ("(žádné — uživatelská)", None), ("Žánry (genres)", "genres"), ("Rok (year)", "year"), ("Země (countries)", "countries"), ("Hodnocení (rating)", "rating"), ("Režie (directors)", "directors"), ("Herci (actors)", "actors"), ("Délka (duration)", "duration"), ] TRANSFORM_CHOICES = [ ("(žá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 ČSFD 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", "ČSFD pole", "Transform", "Tvořit složky", "Složka", "Šablona názvu"]) hdr = self.table.horizontalHeader() hdr.setSectionResizeMode(0, QHeaderView.Stretch) hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents) hdr.setSectionResizeMode(2, QHeaderView.ResizeToContents) hdr.setSectionResizeMode(3, QHeaderView.ResizeToContents) hdr.setSectionResizeMode(4, QHeaderView.Stretch) hdr.setSectionResizeMode(5, QHeaderView.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.Ok | QDialogButtonBox.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 = self.table.cellWidget(row, 0).text().strip() if not category: continue make_folders = self.table.cellWidget(row, 3).isChecked() folder = self.table.cellWidget(row, 4).text().strip() template = self.table.cellWidget(row, 5).text().strip() schema.append({ "category": category, "csfd_field": self.table.cellWidget(row, 1).currentData(), "transform": 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í ČSFD: " f"{str(avg) + ' %' if avg is not None else '—'}
" f"S ČSFD odkazem: {stats['with_csfd']}  ·  " f"bez: {stats['without_csfd']}
" 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.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.file_rows: dict[int, File] = {} # table row -> File # 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} — Filmotéka") 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 Exception: 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.refresh_sidebar() self.refresh_table() # ------------------------------------------------------------------ # 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) 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, "Přejmenovat…", self.rename_movie, "F2") self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T") self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute) self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd) self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) movie_menu.addSeparator() self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del") film_menu = bar.addMenu("Filmo&téka") self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G") self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) 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) 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.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("🔍")) 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, 3) self.table.setHorizontalHeaderLabels(["Název", "Štítky", "Velikost"]) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_table_menu) self.table.doubleClicked.connect(lambda _: self.open_movies()) self.table.itemSelectionChanged.connect(self._update_selection_status) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) # Název header.setSectionResizeMode(1, QHeaderView.Stretch) # Štítky main_layout.addWidget(self.table) splitter.addWidget(main) splitter.setStretchFactor(0, 0) splitter.setStretchFactor(1, 1) splitter.setSizes([260, 940]) self.setCentralWidget(splitter) def _build_statusbar(self) -> None: self.status = self.statusBar() # Pool / Filmotéka paths as permanent widgets on the right so they stay # visible even while the transient area shows counts or a selection. self.pool_label = QLabel() self.output_label = QLabel() self.status.addPermanentWidget(self.pool_label) self.status.addPermanentWidget(self.output_label) self._update_path_status() # ------------------------------------------------------------------ # Sidebar (tag filter) # ------------------------------------------------------------------ def refresh_sidebar(self, filtered: Optional[List[File]] = 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) 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.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.ItemIsUserCheckable | Qt.ItemIsEnabled) checked = tag.full_path in self._active_filter item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) item.setData(0, Qt.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.UserRole) if full_path is None: return # category header row, not a tag if item.checkState(0) == Qt.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_table(self, *_args) -> None: # Tag filter (AND) drives both the table and the sidebar counts; the # search box further narrows only the table. tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags()) shown = tag_filtered search = self.search_edit.text().lower() if hasattr(self, "search_edit") else "" if search: shown = [f for f in shown if search in (f.title or f.filename).lower()] shown = sorted(shown, key=lambda f: (f.title or f.filename).lower()) self.table.setRowCount(len(shown)) self.file_rows.clear() for row, f in enumerate(shown): self.file_rows[row] = f name = f.title or f.filename tags = ", ".join(t.name for t in f.tags) try: size = self._format_size(f.file_path.stat().st_size) except OSError: size = "?" for col, value in enumerate([name, tags, size]): self.table.setItem(row, col, QTableWidgetItem(value)) self.refresh_sidebar(tag_filtered) self._update_selection_status() self.status.showMessage(f"Zobrazeno {len(shown)} filmů", 4000) @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_movies(self) -> List[File]: rows = {idx.row() for idx in self.table.selectionModel().selectedRows()} return [self.file_rows[r] for r in sorted(rows) if r in self.file_rows] def _show_table_menu(self, pos) -> None: menu = QMenu(self) menu.addAction("Otevřít", self.open_movies) menu.addAction("Přejmenovat…", self.rename_movie) menu.addAction("Přiřadit štítky…", self.assign_tags) menu.addAction("Nastavit datum…", self.set_date) menu.addAction("Nastavit atribut…", self.set_attribute) menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd) menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) menu.addSeparator() menu.addAction("Odebrat z poolu…", self.remove_movies) menu.exec(self.table.viewport().mapToGlobal(pos)) def _update_selection_status(self) -> None: 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.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.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 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) if dialog.exec() != QDialog.Accepted: return entries = dialog.entries() if not entries: return move = dialog.move_files # Files whose name already exists in the pool (the red-highlighted rows) colliding = [t for s, t, _ in entries if self.filehandler.pooled_with_stem(t)] on_conflict = "suffix" if colliding: on_conflict = self._resolve_import_conflicts(colliding) if on_conflict is None: return # user cancelled the whole import imported: list[File] = [] skipped = 0 errors: list[str] = [] for source, title, csfd_link in entries: try: movie = self.filehandler.import_movie( source, title, csfd_link or None, move=move, on_conflict=on_conflict) if movie is None: skipped += 1 else: imported.append(movie) except Exception as exc: # noqa: BLE001 — surface per-file import failures errors.append(f"{source.name}: {exc}") # Enrich the freshly imported movies that carry a ČSFD link with_links = [m for m in imported if m.csfd_link] tags_total = 0 if with_links: self.status.showMessage(f"Načítám z ČSFD ({len(with_links)})…") QApplication.setOverrideCursor(Qt.WaitCursor) try: _, tags_total, csfd_errors = self._fetch_csfd_for(with_links) finally: QApplication.restoreOverrideCursor() errors.extend(csfd_errors) self.refresh_table() verb = "Přesunuto" if move else "Zkopírováno" summary = f"{verb} {len(imported)}/{len(entries)} filmů (+{tags_total} tagů z ČSFD)." 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 how to handle names already in the pool. Returns the on_conflict policy ("replace"/"suffix") or None if the user cancels the import.""" box = QMessageBox(self) box.setIcon(QMessageBox.Question) box.setWindowTitle("Název už v poolu existuje") 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"V poolu už existují filmy s těmito názvy:\n{listing}{more}\n\n" "Co s nimi?" ) replace_btn = box.addButton("Nahradit novými", QMessageBox.AcceptRole) keep_btn = box.addButton("Ponechat oba (přípona)", QMessageBox.ActionRole) box.addButton("Zrušit import", QMessageBox.RejectRole) box.exec() clicked = box.clickedButton() if clicked is replace_btn: return "replace" if clicked is keep_btn: return "suffix" 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.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 set_attribute(self) -> None: files = self._selected_movies() if not files: QMessageBox.information(self, "Atribut", "Nejprve vyberte filmy.") return known = sorted({k for f in files for k in f.attributes}) key, ok = QInputDialog.getItem( self, "Nastavit atribut", "Název atributu (např. collection_sort):", known, editable=True) key = key.strip() if not ok or not key: return # prefill with the current value when editing a single movie current = files[0].attributes.get(key, "") if len(files) == 1 else "" value, ok = QInputDialog.getText( self, "Nastavit atribut", f"Hodnota „{key}\" (prázdné = smazat):", text=current) if not ok: return for f in files: f.set_attribute(key, value.strip() or None) self.refresh_table() self.status.showMessage(f"Atribut „{key}\" nastaven u {len(files)} filmů.", 5000) 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_csfd(self) -> None: files = self._selected_movies() if len(files) != 1: QMessageBox.information(self, "ČSFD", "Vyberte právě jeden film.") return f = files[0] text, ok = QInputDialog.getText(self, "ČSFD odkaz", "URL:", text=f.csfd_link or "") if not ok: return f.set_csfd_link(text.strip() or None) self.refresh_table() def apply_csfd_tags_for_selected(self) -> None: files = [f for f in self._selected_movies() if f.csfd_link] if not files: QMessageBox.information( self, "ČSFD", "Vyberte filmy, které mají nastavený ČSFD odkaz." ) return self.status.showMessage(f"Načítám z ČSFD ({len(files)})…") QApplication.setOverrideCursor(Qt.WaitCursor) try: ok_count, tags_total, errors = self._fetch_csfd_for(files) finally: QApplication.restoreOverrideCursor() self.refresh_table() msg = f"Načteno: {ok_count}/{len(files)} filmů, přidáno {tags_total} tagů." if errors: msg += "\n\nChyby:\n" + "\n".join(errors[:5]) QMessageBox.warning(self, "ČSFD dokončeno s chybami", msg) else: QMessageBox.information(self, "ČSFD", msg) def _fetch_csfd_for(self, files) -> tuple[int, int, list[str]]: """Apply CSFD tags to each file; return (ok_count, tags_added, errors).""" ok_count = 0 tags_total = 0 errors: list[str] = [] schema = self.filehandler.tag_schema for f in files: result = f.apply_csfd_tags(schema) if result["success"]: ok_count += 1 tags_total += len(result["tags_added"]) else: errors.append(f"{f.title or f.filename}: {result['error']}") return ok_count, tags_total, errors def remove_movies(self) -> None: files = self._selected_movies() if not files: return answer = QMessageBox.question( self, "Odebrat z poolu", f"Smazat {len(files)} film(ů) z poolu včetně souboru a metadat?", ) if answer != QMessageBox.Yes: return for f in files: try: f.delete_metadata() if f.file_path.exists(): f.file_path.unlink() except OSError as exc: QMessageBox.warning(self, "Chyba", f"Nelze smazat {f.filename}: {exc}") if f in self.filehandler.filelist: self.filehandler.filelist.remove(f) self.refresh_table() def generate_filmoteka(self) -> None: out = self.filehandler.filmoteka_dir if not out: QMessageBox.warning(self, "Filmotéka", "Nejprve nastavte výstupní složku Filmotéky.") return files = self.filehandler.filelist if not files: QMessageBox.information(self, "Filmotéka", "Pool je prázdný.") return manager = HardlinkManager(out) reserved = (set(self.filehandler.copyasis_folders) | {TIPS_SUBFOLDER, RECENT_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, ) # Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked) pool = self.filehandler.pool_dir mirrored = 0 mirror_fail = 0 for name in self.filehandler.copyasis_folders: 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 # 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) msg = ( f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n" f"Copy-as-is — zrcadleno: {mirrored}\n" f"Tipy dne — náhodně: {tips}, Nově přidané: {recent}" ) 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_copyasis_folders(self) -> None: current = ", ".join(self.filehandler.copyasis_folders) text, ok = QInputDialog.getText( self, "Copy-as-is složky", "Názvy pool podsložek zrcadlených 1:1 (oddělené čárkou):", text=current ) if not ok: return self.filehandler.set_copyasis_folders(text.split(",")) self.status.showMessage( "Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000 ) def edit_tag_schema(self) -> None: dialog = TagSchemaDialog(self, self.filehandler.tag_schema) if dialog.exec() != QDialog.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 tagy z ČSFD\" a přegenerujte Filmotéku.", 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.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.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.AcceptRole) quick_btn = box.addButton("Rychlá", QMessageBox.AcceptRole) box.addButton("Zrušit", QMessageBox.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.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.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: # noqa: N802 — Qt override 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()