Adopt "Title (YYYY)" pool naming and clean Filmotéka link names
This commit is contained in:
+182
-34
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user