Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout
This commit is contained in:
+216
-69
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user