"""
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()