Adopt "Title (YYYY)" pool naming and clean Filmotéka link names

This commit is contained in:
2026-07-27 20:51:31 +02:00
parent 4c4180f28c
commit 1c78ca9013
13 changed files with 784 additions and 145 deletions
+182 -34
View File
@@ -14,8 +14,8 @@ 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.QtCore import Qt, QTimer, QRegularExpression
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -29,6 +29,7 @@ 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
from src.core.naming import canonical_pool_stem, is_valid_year, year_from_filename
# Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
# browsers sort them ahead of the genre folders at the output root.
@@ -54,21 +55,30 @@ class ImportMoviesDialog(QDialog):
self.setWindowTitle("Importovat filmy do poolu")
self.setMinimumSize(720, 360)
# Lower-cased names already present in the pool (for collision highlight)
# Lower-cased canonical stems already present in the pool (collision check)
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]] = []
# (source path, title field, year field, ČSFD field) per row
self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit]] = []
layout = QVBoxLayout(self)
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""])
info = QLabel(
"Filmy se do poolu uloží jako „Název (rok).ext“. "
"Rok je povinný (čtyřmístné číslo). "
"Modře = rok navržený z ČSFD (ověř ho); červeně = název už v poolu je.")
info.setWordWrap(True)
layout.addWidget(info)
self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(
["Soubor", "Název", "Rok", "Č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)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
layout.addWidget(self.table)
add_row = QHBoxLayout()
@@ -99,8 +109,20 @@ class ImportMoviesDialog(QDialog):
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))
year_edit = QLineEdit(year_from_filename(source.name) or "")
year_edit.setPlaceholderText("rok")
year_edit.setMaximumWidth(64)
year_edit.setValidator(
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), year_edit))
year_edit.textChanged.connect(
lambda _t, e=title_edit: self._mark_collision(e))
# Manual edits clear the "suggested" (blue) marker; ČSFD autofill re-adds it.
year_edit.textEdited.connect(lambda _t, e=year_edit: self._set_suggested(e, False))
csfd_edit = QLineEdit()
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
remove_btn = QPushButton("✕")
@@ -108,24 +130,52 @@ class ImportMoviesDialog(QDialog):
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.table.setCellWidget(row, 2, year_edit)
self.table.setCellWidget(row, 3, csfd_edit)
self.table.setCellWidget(row, 4, remove_btn)
self._rows.append((source, title_edit, year_edit, csfd_edit))
self._mark_collision(title_edit)
def _row_of(self, title_edit: QLineEdit) -> tuple[Path, QLineEdit, QLineEdit, QLineEdit] | None:
for entry in self._rows:
if entry[1] is title_edit:
return entry
return None
def _candidate_stem(self, title: str, year: str) -> str | None:
"""The canonical 'Title (year)' stem, or None if it can't be built yet."""
if not (title.strip() and is_valid_year(year)):
return None
try:
return canonical_pool_stem(title, year)
except ValueError:
return None
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
"""Red title when the resulting 'Title (year)' name already exists."""
entry = self._row_of(title_edit)
if entry is None:
return
_src, _title, year_edit, _csfd = entry
stem = self._candidate_stem(title_edit.text(), year_edit.text())
clashes = stem is not None and stem.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 "")
"V poolu už existuje film „" + stem + "“ — při importu se tě zeptám, "
"co s ním." if clashes else "")
def _set_suggested(self, year_edit: QLineEdit, suggested: bool) -> None:
"""Blue tint marks a year proposed by ČSFD (not yet confirmed by the user)."""
year_edit.setStyleSheet(
"QLineEdit { color: #2980b9; }" if suggested else "")
year_edit.setToolTip(
"Rok navržený z ČSFD — ověř ho." if suggested 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:
for index, entry in enumerate(self._rows):
if entry[1] is title_edit:
self.table.removeRow(index)
self._rows.pop(index)
return
@@ -136,11 +186,15 @@ class ImportMoviesDialog(QDialog):
self._append_row(Path(path))
def _autofill_csfd(self) -> None:
"""Fill empty ČSFD fields by searching ČSFD for each file's cleaned name."""
"""Fill empty ČSFD links (and empty years) from a ČSFD search per row.
Uses the top search hit: its URL fills an empty link, its year fills an
empty year field and is tinted blue (a suggestion to verify).
"""
import requests
from src.core import csfd
targets = [(t, c) for _, t, c in self._rows if not c.text().strip()]
targets = [(t, y, c) for _, t, y, c in self._rows if not c.text().strip()]
if not targets:
QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.")
return
@@ -149,15 +203,21 @@ class ImportMoviesDialog(QDialog):
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
with requests.Session() as session:
for title_edit, csfd_edit in targets:
for title_edit, year_edit, csfd_edit in targets:
query = csfd.clean_filename_to_query(title_edit.text())
try:
url = csfd.find_csfd_url(query, session=session)
hits = csfd.search_movies(query, limit=1, session=session)
except Exception: # noqa: BLE001 — network/parse failure for one row
url = None
if url:
csfd_edit.setText(url)
hits = []
if not hits:
continue
hit = hits[0]
if hit.url:
csfd_edit.setText(hit.url)
found += 1
if hit.year and not year_edit.text().strip():
year_edit.setText(str(hit.year))
self._set_suggested(year_edit, True)
finally:
QApplication.restoreOverrideCursor()
@@ -165,16 +225,34 @@ class ImportMoviesDialog(QDialog):
self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů."
)
def accept(self) -> None: # noqa: D401 — Qt override
"""Block the import until every row has a title and a four-digit year."""
bad_year: list[str] = []
for source, title_edit, year_edit, _csfd in self._rows:
title = title_edit.text().strip() or source.stem
if not is_valid_year(year_edit.text()):
bad_year.append(title)
if bad_year:
listing = "\n".join(f" • {t}" for t in bad_year[:10])
more = f"\n … a další ({len(bad_year) - 10})" if len(bad_year) > 10 else ""
QMessageBox.warning(
self, "Chybí rok",
"Každý film musí mít čtyřmístný rok (uloží se jako „Název "
f"(rok).ext“):\n{listing}{more}")
return
super().accept()
@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:
def entries(self) -> list[tuple[Path, str, str, str]]:
"""Return (source, title, year, csfd_link) per row; title falls back to stem."""
result: list[tuple[Path, str, str, str]] = []
for source, title_edit, year_edit, csfd_edit in self._rows:
title = title_edit.text().strip() or source.stem
result.append((source, title, csfd_edit.text().strip()))
result.append(
(source, title, year_edit.text().strip(), csfd_edit.text().strip()))
return result
@@ -499,6 +577,8 @@ class QtApp(QMainWindow):
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řejmenovat dle ČSFD (rok)…", self.rename_to_csfd_naming)
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)
@@ -772,8 +852,13 @@ class QtApp(QMainWindow):
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)]
# Rows whose canonical "Title (year)" name already exists in the pool
colliding = [
canonical_pool_stem(t, y)
for s, t, y, _ in entries
if is_valid_year(y)
and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y))
]
on_conflict = "suffix"
if colliding:
on_conflict = self._resolve_import_conflicts(colliding)
@@ -783,10 +868,11 @@ class QtApp(QMainWindow):
imported: list[File] = []
skipped = 0
errors: list[str] = []
for source, title, csfd_link in entries:
for source, title, year, csfd_link in entries:
try:
movie = self.filehandler.import_movie(
source, title, csfd_link or None, move=move, on_conflict=on_conflict)
source, title, csfd_link or None, move=move,
on_conflict=on_conflict, year=year or None)
if movie is None:
skipped += 1
else:
@@ -931,6 +1017,68 @@ class QtApp(QMainWindow):
self.refresh_table()
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
def rename_to_csfd_naming(self) -> None:
"""Rename movies to the 'Title (rok).ext' convention from their metadata."""
files = self._selected_movies() or self.filehandler.filelist
if not files:
QMessageBox.information(self, "Přejmenovat dle ČSFD", "Pool je prázdný.")
return
scope = "vybrané filmy" if self._selected_movies() else "celý pool"
answer = QMessageBox.question(
self, "Přejmenovat dle ČSFD (rok)",
f"Přejmenovat {scope} ({len(files)}) na „Název (rok).ext“ podle "
"staženého roku z ČSFD?\n\nFilmy bez známého roku se přeskočí.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if answer != QMessageBox.Yes:
return
progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self)
progress.setWindowTitle("Přejmenovat dle ČSFD (rok)")
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.rename_all_to_canonical(
files, on_progress=on_progress, should_cancel=progress.wasCanceled)
progress.setValue(len(files))
self.refresh_table()
renamed = result["renamed"]
no_year = result["skipped_no_year"]
conflicts = result["conflicts"]
suffix = " (zrušeno)" if result["cancelled"] else ""
summary = (
f"Přejmenováno: {len(renamed)}, beze změny: {result['unchanged']}, "
f"bez roku: {len(no_year)}, kolize: {len(conflicts)}{suffix}")
if not (no_year or conflicts):
QMessageBox.information(self, "Přejmenování dokončeno", summary)
else:
detail: list[str] = []
if renamed:
detail.append(f"■ Přejmenováno ({len(renamed)}):")
detail += [f" • {old} → {new}" for old, new in renamed]
detail.append("")
if no_year:
detail.append(f"■ Bez známého roku — přeskočeno ({len(no_year)}):")
detail += [f" • {n}" for n in no_year]
detail.append("")
if conflicts:
detail.append(f"■ Kolize názvu ({len(conflicts)}):")
detail += [f" • {old} — {msg}" for old, msg in conflicts]
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information)
box.setWindowTitle("Přejmenování dle ČSFD")
box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.")
box.setDetailedText("\n".join(detail))
box.exec()
self.status.showMessage(summary, 5000)
def edit_csfd(self) -> None:
files = self._selected_movies()
if len(files) != 1: