Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout

This commit is contained in:
2026-06-15 17:31:52 +02:00
parent 86c689b9f1
commit b3a61f9e86
18 changed files with 1407 additions and 168 deletions
+216 -69
View File
@@ -12,15 +12,15 @@ import os
import sys
import subprocess
from pathlib import Path
from typing import List
from typing import List, Optional
from PySide6.QtCore import Qt
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,
QFormLayout, QHeaderView, QMenu, QAbstractItemView,
QHeaderView, QMenu, QAbstractItemView, QCheckBox,
)
from src.core.file_manager import FileManager
@@ -30,39 +30,125 @@ from src.core.tag import Tag
from src.core.constants import APP_NAME, VERSION
from src.core.hardlink_manager import HardlinkManager
# Categories that drive the generated Filmotéka tree (see PROJECT.md)
FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Země původu", "Hodnocení"]
# Layout of the generated Filmotéka tree: category → root folder under the
# output (see PROJECT.md). Genres sit directly at the output root (next to the
# copy-as-is Seriály mirror); Rok and Země původu get their own grouping folder.
FILMOTEKA_CATEGORY_ROOTS = {
"Žánr": "",
"Rok": "Dle roku",
"Země původu": "Dle země původu",
"Hodnocení": "Dle hodnocení",
}
class ImportMovieDialog(QDialog):
"""Collect the Title and ČSFD link for a movie being imported into the pool."""
class ImportMoviesDialog(QDialog):
"""Collect a Title + ČSFD link per file for a batch import into the pool.
def __init__(self, parent: QWidget, default_title: str) -> None:
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]) -> None:
super().__init__(parent)
self.setWindowTitle("Importovat film do poolu")
self.setMinimumWidth(420)
self.setWindowTitle("Importovat filmy do poolu")
self.setMinimumSize(680, 360)
# (source path, title field, ČSFD field) per row
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
layout = QVBoxLayout(self)
form = QFormLayout()
self.title_edit = QLineEdit(default_title)
self.csfd_edit = QLineEdit()
self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
form.addRow("Název:", self.title_edit)
form.addRow("ČSFD odkaz:", self.csfd_edit)
layout.addLayout(form)
self.table = QTableWidget(0, 3)
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)
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)
@property
def title(self) -> str:
return self.title_edit.text().strip()
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)
csfd_edit = QLineEdit()
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
self.table.setCellWidget(row, 1, title_edit)
self.table.setCellWidget(row, 2, csfd_edit)
self._rows.append((source, title_edit, csfd_edit))
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 csfd_link(self) -> str:
return self.csfd_edit.text().strip()
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):
@@ -127,6 +213,9 @@ class QtApp(QMainWindow):
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_NAME} {VERSION} — Filmotéka")
@@ -163,7 +252,8 @@ class QtApp(QMainWindow):
self._add_action(pool_menu, "Konec", self.close, "Ctrl+Q")
movie_menu = bar.addMenu("&Filmy")
self._add_action(movie_menu, "Importovat film…", self.import_movie, "Ctrl+I")
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, "Upravit ČSFD odkaz…", self.edit_csfd)
@@ -208,7 +298,7 @@ class QtApp(QMainWindow):
self.search_edit.setPlaceholderText("Hledat film…")
self.search_edit.textChanged.connect(self.refresh_table)
search_row.addWidget(self.search_edit)
import_btn = QPushButton(" Importovat film")
import_btn = QPushButton(" Importovat filmy")
import_btn.clicked.connect(self.import_movie)
search_row.addWidget(import_btn)
main_layout.addLayout(search_row)
@@ -241,14 +331,24 @@ class QtApp(QMainWindow):
# Sidebar (tag filter)
# ------------------------------------------------------------------
def refresh_sidebar(self) -> None:
self.tag_tree.blockSignals(True)
self.tag_tree.clear()
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 self.filehandler.filelist:
for f in filtered:
for t in f.tags:
counts[t.full_path] = counts.get(t.full_path, 0) + 1
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)
@@ -256,27 +356,32 @@ class QtApp(QMainWindow):
cat_item.setExpanded(True)
for tag in self.tagmanager.get_tags_in_category(category):
count = counts.get(tag.full_path, 0)
label = f"{tag.name} ({count})" if count else tag.name
item = QTreeWidgetItem([label])
item = QTreeWidgetItem([f"{tag.name} ({count})"])
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setCheckState(0, Qt.Unchecked)
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:
self.refresh_table()
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 _checked_filter_tags(self) -> List[Tag]:
def _active_filter_tags(self) -> List[Tag]:
tags: List[Tag] = []
for i in range(self.tag_tree.topLevelItemCount()):
cat = self.tag_tree.topLevelItem(i)
for j in range(cat.childCount()):
child = cat.child(j)
if child.checkState(0) == Qt.Checked:
full_path = child.data(0, Qt.UserRole)
category, name = full_path.split("/", 1)
tags.append(Tag(category, name))
for full_path in self._active_filter:
category, name = full_path.split("/", 1)
tags.append(Tag(category, name))
return tags
# ------------------------------------------------------------------
@@ -284,15 +389,18 @@ class QtApp(QMainWindow):
# ------------------------------------------------------------------
def refresh_table(self, *_args) -> None:
filtered = self.filehandler.filter_files_by_tags(self._checked_filter_tags())
# 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:
filtered = [f for f in filtered if search in (f.title or f.filename).lower()]
filtered.sort(key=lambda f: (f.title or f.filename).lower())
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(filtered))
self.table.setRowCount(len(shown))
self.file_rows.clear()
for row, f in enumerate(filtered):
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)
@@ -303,9 +411,9 @@ class QtApp(QMainWindow):
for col, value in enumerate([name, tags, size]):
self.table.setItem(row, col, QTableWidgetItem(value))
self.refresh_sidebar()
self.refresh_sidebar(tag_filtered)
self._update_selection_status()
self.status.showMessage(f"Zobrazeno {len(filtered)} filmů", 4000)
self.status.showMessage(f"Zobrazeno {len(shown)} filmů", 4000)
@staticmethod
def _format_size(size_bytes: float) -> str:
@@ -322,6 +430,7 @@ class QtApp(QMainWindow):
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("Upravit ČSFD odkaz…", self.edit_csfd)
@@ -382,36 +491,51 @@ class QtApp(QMainWindow):
if not self.filehandler.movies_dir:
QMessageBox.warning(self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool).")
return
path, _ = QFileDialog.getOpenFileName(self, "Vyber video soubor")
if not path:
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
if not paths:
return
source = Path(path)
dialog = ImportMovieDialog(self, default_title=source.stem)
sources = [Path(p) for p in paths]
dialog = ImportMoviesDialog(self, sources)
if dialog.exec() != QDialog.Accepted:
return
try:
movie = self.filehandler.import_movie(source, dialog.title, dialog.csfd_link)
except Exception as exc: # noqa: BLE001 — surface any import failure to the user
QMessageBox.critical(self, "Chyba importu", str(exc))
entries = dialog.entries()
if not entries:
return
move = dialog.move_files
# If a ČSFD link was given, enrich the movie with tags right away
if movie.csfd_link:
self.status.showMessage("Načítám z ČSFD…")
imported: list[File] = []
errors: list[str] = []
for source, title, csfd_link in entries:
try:
movie = self.filehandler.import_movie(source, title, csfd_link or None, move=move)
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, errors = self._fetch_csfd_for([movie])
_, tags_total, csfd_errors = self._fetch_csfd_for(with_links)
finally:
QApplication.restoreOverrideCursor()
if errors:
QMessageBox.warning(self, "ČSFD", "Tagy se nepodařilo načíst:\n" + errors[0])
else:
self.status.showMessage(
f"Importováno: {movie.title} (+{tags_total} tagů z ČSFD)", 5000
)
errors.extend(csfd_errors)
self.refresh_table()
self.status.showMessage(f"Importováno: {dialog.title}", 5000)
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 open_movies(self) -> None:
for f in self._selected_movies():
@@ -456,6 +580,27 @@ class QtApp(QMainWindow):
f.set_date(text.strip() or None)
self.refresh_table()
def rename_movie(self) -> None:
files = self._selected_movies()
if len(files) != 1:
QMessageBox.information(self, "Přejmenovat", "Vyberte právě jeden film.")
return
f = files[0]
current = f.file_path.stem # name without extension
text, ok = QInputDialog.getText(
self, "Přejmenovat film",
f"Nový název (bez přípony {f.file_path.suffix}):", text=current,
)
if not ok:
return
try:
self.filehandler.rename_movie(f, text)
except (ValueError, FileExistsError, OSError) as exc:
QMessageBox.warning(self, "Přejmenování selhalo", str(exc))
return
self.refresh_table()
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
def edit_csfd(self) -> None:
files = self._selected_movies()
if len(files) != 1:
@@ -536,7 +681,9 @@ class QtApp(QMainWindow):
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
return
manager = HardlinkManager(out)
created, create_fail, removed, remove_fail = manager.sync_structure(files, FILMOTEKA_CATEGORIES)
created, create_fail, removed, remove_fail = manager.sync_structure(
files, category_roots=FILMOTEKA_CATEGORY_ROOTS
)
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
pool = self.filehandler.pool_dir