1229 lines
51 KiB
Python
1229 lines
51 KiB
Python
"""
|
||
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"<b>Filmů v poolu:</b> {stats['count']}<br>"
|
||
f"<b>Celková velikost:</b> {size_text}<br>"
|
||
f"<b>Průměrné hodnocení ČSFD:</b> "
|
||
f"{str(avg) + ' %' if avg is not None else '—'}<br>"
|
||
f"<b>S ČSFD odkazem:</b> {stats['with_csfd']} · "
|
||
f"<b>bez:</b> {stats['without_csfd']}<br>"
|
||
f"<b>Bez štítků:</b> {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()
|