4432 lines
180 KiB
Python
4432 lines
180 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 subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import ClassVar, cast
|
||
|
||
from PySide6.QtCore import QRegularExpression, Qt, QTimer
|
||
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QApplication,
|
||
QButtonGroup,
|
||
QCheckBox,
|
||
QComboBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QFileDialog,
|
||
QFormLayout,
|
||
QGroupBox,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QInputDialog,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QProgressDialog,
|
||
QPushButton,
|
||
QRadioButton,
|
||
QSpinBox,
|
||
QSplitter,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from src.constants import APP_TITLE
|
||
from src.core import series
|
||
from src.core.file import File
|
||
from src.core.file_manager import FileManager
|
||
from src.core.hardlink_manager import HardlinkManager
|
||
from src.core.movie import (
|
||
DEFAULT_VERSION_LABEL,
|
||
normalize_3d_label,
|
||
normalize_part_label,
|
||
)
|
||
from src.core.naming import (
|
||
THREE_D_FORMATS,
|
||
canonical_pool_stem,
|
||
is_valid_year,
|
||
parse_3d_format,
|
||
parse_pool_stem,
|
||
parse_tmdb_id,
|
||
three_d_format_from_filename,
|
||
year_from_filename,
|
||
)
|
||
from src.core.tag import Tag
|
||
from src.core.tag_manager import TagManager
|
||
|
||
# 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
|
||
# Series found inside a "mixed" library (movies + series in one folder) have no
|
||
# genre tags to run through the tag tree — they're hardcopied here instead,
|
||
# same as a dedicated series library's copy-as-is mirror.
|
||
MIXED_SERIES_SUBFOLDER = "Seriály"
|
||
|
||
|
||
class ImportMoviesDialog(QDialog):
|
||
"""Collect a Title + Year + TMDb id per file for a batch import into the pool.
|
||
|
||
One row per source file (filename shown; Title, Year and TMDb id editable).
|
||
"Dohledat z TMDb" fills them from TMDb — by the given id, or by searching the
|
||
filename. A single toggle decides whether the files are copied (default) or
|
||
moved into the pool.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
parent: QWidget,
|
||
sources: list[Path],
|
||
existing_names: set[str] | None = None,
|
||
libraries: list[str] | None = None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Importovat filmy do poolu")
|
||
self.setMinimumSize(760, 360)
|
||
self._libraries = libraries or ["Filmy"]
|
||
|
||
# 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, year field, tmdb-id field, ČSFD-link field) per row
|
||
self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit, QLineEdit]] = []
|
||
# A row set here isn't imported on its own — it becomes another
|
||
# playable version of the row it points to (e.g. a 3D + a 2D file of
|
||
# the same film), added via FileManager.add_movie_version right after
|
||
# its target is imported. {version source: (target source, popisek)}.
|
||
self._version_links: dict[Path, tuple[Path, str]] = {}
|
||
self._version_buttons: dict[Path, QPushButton] = {}
|
||
# 3D formát for a row that stays a flat, single-file movie (the only
|
||
# file for that film, itself entirely 3D) — disabled whenever the row
|
||
# is a version of another one or another row is a version of it, since
|
||
# a grouped movie's 3D-ness lives on the specific version's popisek.
|
||
self._format3d_combos: dict[Path, QComboBox] = {}
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
info = QLabel(
|
||
"Filmy se uloží jako „Název (rok) [tmdbid-N].ext“ — přesně jak je "
|
||
"páruje Jellyfin. Rok je povinný; TMDb ID zajistí správné spárování "
|
||
"(nepovinné). Modře = navrženo z TMDb (ověř); červeně = název už v poolu je. "
|
||
"Je jen jeden soubor a je celý 3D → nastav mu rovnou „3D formát“. Je-li "
|
||
"soubor jen další verzí filmu z jiného řádku (2D+3D, jiná kvalita, …), "
|
||
"použij „Přiřadit jako verzi…“ místo vyplňování vlastního názvu/roku."
|
||
)
|
||
info.setWordWrap(True)
|
||
layout.addWidget(info)
|
||
|
||
self.table = QTableWidget(0, 8)
|
||
self.table.setHorizontalHeaderLabels(
|
||
[
|
||
"Soubor",
|
||
"Název",
|
||
"Rok",
|
||
"TMDb ID",
|
||
"ČSFD odkaz",
|
||
"3D formát",
|
||
"Verze",
|
||
"",
|
||
]
|
||
)
|
||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
header = self.table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(7, QHeaderView.ResizeMode.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("🔎 Dohledat z TMDb")
|
||
find_btn.setToolTip(
|
||
"Podle TMDb ID doplní název + rok; bez ID vyhledá podle názvu souboru."
|
||
)
|
||
find_btn.clicked.connect(self._autofill_tmdb)
|
||
add_row.addWidget(find_btn)
|
||
csfd_btn = QPushButton("🎬 Dohledat přes ČSFD")
|
||
csfd_btn.setToolTip(
|
||
"Najde film na ČSFD (podle vyplněného odkazu, jinak podle názvu "
|
||
"souboru), vezme z něj původní název + rok a podle nich dohledá "
|
||
"TMDb ID. Vhodné, když přímé hledání na TMDb vrací špatný film."
|
||
)
|
||
csfd_btn.clicked.connect(self._autofill_via_csfd)
|
||
add_row.addWidget(csfd_btn)
|
||
add_row.addStretch(1)
|
||
layout.addLayout(add_row)
|
||
|
||
opts_row = QHBoxLayout()
|
||
opts_row.addWidget(QLabel("Knihovna:"))
|
||
self.library_combo = QComboBox()
|
||
self.library_combo.addItems(self._libraries)
|
||
opts_row.addWidget(self.library_combo)
|
||
self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)")
|
||
opts_row.addWidget(self.move_check)
|
||
self.skip_no_tmdb_check = QCheckBox("Vynechat řádky bez TMDb ID")
|
||
self.skip_no_tmdb_check.setToolTip(
|
||
"Při importu přeskočí soubory bez vyplněného TMDb ID "
|
||
"(pak ani nevadí chybějící rok). Přeskočené řádky jsou přeškrtnuté."
|
||
)
|
||
self.skip_no_tmdb_check.toggled.connect(self._refresh_skip_marks)
|
||
opts_row.addWidget(self.skip_no_tmdb_check)
|
||
opts_row.addStretch(1)
|
||
layout.addLayout(opts_row)
|
||
# sources dropped by "Vynechat řádky bez TMDb ID" on accept
|
||
self._skipped_no_tmdb: list[Path] = []
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.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.ItemFlag.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; TMDb autofill re-adds it.
|
||
year_edit.textEdited.connect(
|
||
lambda _t, e=year_edit: self._set_suggested(e, False)
|
||
)
|
||
|
||
tmdb_edit = QLineEdit()
|
||
tmdb_edit.setPlaceholderText("TMDb ID")
|
||
tmdb_edit.setMaximumWidth(96)
|
||
tmdb_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), tmdb_edit)
|
||
)
|
||
tmdb_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e))
|
||
tmdb_edit.textChanged.connect(lambda _t: self._refresh_skip_marks())
|
||
tmdb_edit.textEdited.connect(
|
||
lambda _t, e=tmdb_edit: self._set_suggested(e, False)
|
||
)
|
||
|
||
csfd_edit = QLineEdit()
|
||
csfd_edit.setPlaceholderText("ČSFD odkaz (volitelné; uloží se + pro dohledání)")
|
||
csfd_edit.textEdited.connect(
|
||
lambda _t, e=csfd_edit: self._set_suggested(e, False)
|
||
)
|
||
|
||
# Pre-fill the row from metadata already sitting next to the source file:
|
||
# Curator's own pre-index sidecar (`.{name}.!tag`) / an old `.Curator.!index`
|
||
# first (authoritative for the ČSFD link), then a Kodi/Jellyfin `.nfo`.
|
||
# Everything filled this way is blue (suggested — verify).
|
||
self._prefill_row(source, title_edit, year_edit, tmdb_edit, csfd_edit)
|
||
|
||
format3d_combo = QComboBox()
|
||
format3d_combo.addItem("— žádný —", None)
|
||
for fmt in THREE_D_FORMATS:
|
||
format3d_combo.addItem(f"3D {fmt}", fmt)
|
||
detected = three_d_format_from_filename(source.name)
|
||
if detected:
|
||
format3d_combo.setCurrentIndex(format3d_combo.findData(detected))
|
||
format3d_combo.setToolTip(
|
||
"Je-li tenhle soubor jediná (a celá 3D) verze filmu, nastav mu tu "
|
||
"3D formát — uloží se rovnou jako „Title (rok) [tmdbid].3D."
|
||
"<FORMÁT>.ext“. Nedává smysl spolu s „Přiřadit jako verzi…“ — "
|
||
"3D verze v seskupeném filmu má formát ve svém popisku."
|
||
)
|
||
self._format3d_combos[source] = format3d_combo
|
||
|
||
version_btn = QPushButton()
|
||
version_btn.clicked.connect(lambda _c, s=source: self._link_as_version(s))
|
||
self._version_buttons[source] = version_btn
|
||
self._refresh_version_button(source)
|
||
|
||
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, year_edit)
|
||
self.table.setCellWidget(row, 3, tmdb_edit)
|
||
self.table.setCellWidget(row, 4, csfd_edit)
|
||
self.table.setCellWidget(row, 5, format3d_combo)
|
||
self.table.setCellWidget(row, 6, version_btn)
|
||
self.table.setCellWidget(row, 7, remove_btn)
|
||
self._rows.append((source, title_edit, year_edit, tmdb_edit, csfd_edit))
|
||
self._mark_collision(title_edit)
|
||
|
||
def _prefill_row(
|
||
self,
|
||
source: Path,
|
||
title_edit: QLineEdit,
|
||
year_edit: QLineEdit,
|
||
tmdb_edit: QLineEdit,
|
||
csfd_edit: QLineEdit,
|
||
) -> None:
|
||
"""Seed a new import row from metadata already next to the source file.
|
||
|
||
Curator's pre-index layouts win for the ČSFD link (a per-file
|
||
``.{name}.!tag`` sidecar, or an old ``.Curator.!index`` up the tree); a
|
||
Kodi/Jellyfin ``.nfo`` fills what they don't carry (notably the TMDb id).
|
||
"""
|
||
from src.core import legacy_meta, nfo
|
||
|
||
def fill(edit: QLineEdit, value: object) -> None:
|
||
if value and not edit.text().strip():
|
||
edit.setText(str(value))
|
||
self._set_suggested(edit, True)
|
||
|
||
legacy = legacy_meta.read_legacy_meta(source)
|
||
if legacy is not None:
|
||
fill(csfd_edit, legacy.csfd_url)
|
||
fill(year_edit, legacy.year)
|
||
if legacy.title:
|
||
title_edit.setText(legacy.title)
|
||
|
||
data = nfo.read_nfo(source)
|
||
if data is not None:
|
||
fill(csfd_edit, data.csfd_url)
|
||
fill(tmdb_edit, data.tmdb_id)
|
||
fill(year_edit, data.year)
|
||
if data.title and title_edit.text().strip() == source.stem:
|
||
title_edit.setText(data.title)
|
||
|
||
def _row_of(self, title_edit: QLineEdit):
|
||
for entry in self._rows:
|
||
if entry[1] is title_edit:
|
||
return entry
|
||
return None
|
||
|
||
def _row_by_source(self, source: Path):
|
||
for entry in self._rows:
|
||
if entry[0] == source:
|
||
return entry
|
||
return None
|
||
|
||
def _refresh_skip_marks(self) -> None:
|
||
"""Strike through rows that "Vynechat řádky bez TMDb ID" would drop.
|
||
|
||
A version row never needs its own TMDb ID (it inherits its target's),
|
||
so it's struck through only when its *target* would be dropped too.
|
||
"""
|
||
active = self.skip_no_tmdb_check.isChecked()
|
||
for row, (source, _t, _y, tmdb_edit, _c) in enumerate(self._rows):
|
||
item = self.table.item(row, 0)
|
||
if item is None:
|
||
continue
|
||
link = self._version_links.get(source)
|
||
if link is not None:
|
||
target_tmdb = self._row_by_source(link[0])
|
||
skipped = (
|
||
active
|
||
and target_tmdb is not None
|
||
and not target_tmdb[3].text().strip()
|
||
)
|
||
else:
|
||
skipped = active and not tmdb_edit.text().strip()
|
||
font = item.font()
|
||
font.setStrikeOut(skipped)
|
||
font.setItalic(skipped)
|
||
item.setFont(font)
|
||
|
||
def _candidate_stem(self, title: str, year: str, tmdb_id: str) -> str | None:
|
||
"""The canonical 'Title (year) [tmdbid]' stem, or None if incomplete."""
|
||
if not (title.strip() and is_valid_year(year)):
|
||
return None
|
||
try:
|
||
return canonical_pool_stem(title, year, tmdb_id or None)
|
||
except ValueError:
|
||
return None
|
||
|
||
def _mark_collision(self, title_edit: QLineEdit) -> None:
|
||
"""Red title when the resulting pooled name already exists."""
|
||
entry = self._row_of(title_edit)
|
||
if entry is None:
|
||
return
|
||
_src, _title, year_edit, tmdb_edit, _csfd = entry
|
||
stem = self._candidate_stem(
|
||
title_edit.text(), year_edit.text(), tmdb_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(
|
||
f"V poolu už existuje film „{stem}“ — při importu se tě zeptám, co s ním."
|
||
if clashes
|
||
else ""
|
||
)
|
||
|
||
def _set_suggested(self, edit: QLineEdit, suggested: bool) -> None:
|
||
"""Blue tint marks an auto-filled value (TMDb / ČSFD), not yet confirmed."""
|
||
edit.setStyleSheet("QLineEdit { color: #2980b9; }" if suggested else "")
|
||
edit.setToolTip("Navrženo automaticky — ověř." 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, entry in enumerate(self._rows):
|
||
if entry[1] is title_edit:
|
||
source = entry[0]
|
||
self.table.removeRow(index)
|
||
self._rows.pop(index)
|
||
self._version_buttons.pop(source, None)
|
||
self._format3d_combos.pop(source, None)
|
||
old_link = self._version_links.pop(source, None)
|
||
if old_link is not None:
|
||
# This row was a version of another — that one may have
|
||
# just lost its last follower (regains its 3D combo).
|
||
self._sync_version_row_fields(old_link[0])
|
||
# Any row versioned *to* the one just removed goes back to
|
||
# being its own film — its target no longer exists to import.
|
||
orphaned = [
|
||
s
|
||
for s, (target, _l) in self._version_links.items()
|
||
if target == source
|
||
]
|
||
for s in orphaned:
|
||
del self._version_links[s]
|
||
self._refresh_version_button(s)
|
||
self._sync_version_row_fields(s)
|
||
return
|
||
|
||
def _has_followers(self, source: Path) -> bool:
|
||
"""True when another row is linked as a version *of* ``source``."""
|
||
return any(target == source for target, _l in self._version_links.values())
|
||
|
||
def _sync_version_row_fields(self, source: Path) -> None:
|
||
"""Grey out a row's own identity fields while it's linked as a version —
|
||
its title/year/tmdbid/ČSFD come from the film it's a version *of*.
|
||
|
||
The 3D formát combo is separate: it's for a row that stays a flat,
|
||
single-file movie, so it's disabled both when the row *is* a version
|
||
(its 3D-ness belongs on its own popisek instead — see
|
||
:meth:`_link_as_version`) and when it *has* one (once grouped, 3D
|
||
lives on whichever version is actually 3D, not on the group itself).
|
||
"""
|
||
entry = self._row_by_source(source)
|
||
if entry is None:
|
||
return
|
||
_s, title_edit, year_edit, tmdb_edit, csfd_edit = entry
|
||
is_version = source in self._version_links
|
||
note = (
|
||
"Verze jiného filmu z tohoto importu — název/rok/ID se přebírají z něj."
|
||
if is_version
|
||
else ""
|
||
)
|
||
for w in (title_edit, year_edit, tmdb_edit, csfd_edit):
|
||
w.setEnabled(not is_version)
|
||
w.setToolTip(note)
|
||
if is_version:
|
||
title_edit.setStyleSheet("") # a version's own name/collision is moot
|
||
else:
|
||
self._mark_collision(title_edit)
|
||
|
||
combo = self._format3d_combos.get(source)
|
||
if combo is not None:
|
||
grouped = is_version or self._has_followers(source)
|
||
combo.setEnabled(not grouped)
|
||
if grouped:
|
||
combo.setCurrentIndex(0)
|
||
|
||
def _refresh_version_button(self, source: Path) -> None:
|
||
btn = self._version_buttons.get(source)
|
||
if btn is None:
|
||
return
|
||
link = self._version_links.get(source)
|
||
if link is None:
|
||
btn.setText("🔗 Přiřadit jako verzi…")
|
||
btn.setToolTip(
|
||
"Označ, že tenhle soubor je jen další verze filmu (2D+3D, jiná "
|
||
"kvalita, …) z jiného řádku, ne samostatný film."
|
||
)
|
||
else:
|
||
target_source, label = link
|
||
target = self._row_by_source(target_source)
|
||
target_name = target[1].text() if target else target_source.name
|
||
btn.setText(f"🔗 verze „{target_name}“")
|
||
btn.setToolTip(
|
||
f"Verze filmu „{target_name}“ s popiskem „{label or DEFAULT_VERSION_LABEL}“. "
|
||
"Klikni pro zrušení provázání."
|
||
)
|
||
|
||
def _link_as_version(self, source: Path) -> None:
|
||
if source in self._version_links:
|
||
target_source, label = self._version_links[source]
|
||
target = self._row_by_source(target_source)
|
||
target_name = target[1].text() if target else target_source.name
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Verze",
|
||
f"Zrušit provázání — „{source.name}“ nebude verze filmu "
|
||
f"„{target_name}“ (popisek „{label or DEFAULT_VERSION_LABEL}“), "
|
||
"ale samostatný film?",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
del self._version_links[source]
|
||
self._refresh_version_button(source)
|
||
self._sync_version_row_fields(source)
|
||
self._sync_version_row_fields(target_source) # may regain followers=False
|
||
self._refresh_skip_marks()
|
||
return
|
||
|
||
if any(target == source for target, _l in self._version_links.values()):
|
||
QMessageBox.information(
|
||
self,
|
||
"Verze",
|
||
"Tenhle soubor už je sám hlavní verzí pro jiný řádek v tomhle "
|
||
"importu — nemůže se zároveň stát verzí něčeho jiného.",
|
||
)
|
||
return
|
||
|
||
candidates = [
|
||
entry
|
||
for entry in self._rows
|
||
if entry[0] != source and entry[0] not in self._version_links
|
||
]
|
||
if not candidates:
|
||
QMessageBox.information(
|
||
self,
|
||
"Verze",
|
||
"Není ke komu tenhle soubor přiřadit — přidej nejdřív do "
|
||
"importu i ten druhý (hlavní) soubor filmu.",
|
||
)
|
||
return
|
||
names = [c[0].name for c in candidates]
|
||
choice, ok = QInputDialog.getItem(
|
||
self,
|
||
"Verze",
|
||
"Ke kterému filmu je tenhle soubor další verzí?",
|
||
names,
|
||
0,
|
||
False,
|
||
)
|
||
if not ok:
|
||
return
|
||
target_source = candidates[names.index(choice)][0]
|
||
|
||
fmt = three_d_format_from_filename(source.name)
|
||
dialog = VersionLabelDialog(
|
||
self,
|
||
initial=f"3D {fmt}" if fmt else "",
|
||
intro=f"„{source.name}“ → verze filmu „{choice}“.",
|
||
)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
|
||
self._version_links[source] = (target_source, dialog.label())
|
||
self._refresh_version_button(source)
|
||
self._sync_version_row_fields(source)
|
||
self._sync_version_row_fields(target_source) # now has a follower
|
||
self._refresh_skip_marks()
|
||
|
||
def version_links(self) -> dict[Path, tuple[Path, str]]:
|
||
"""``{version source: (target source, popisek)}`` set via "Přiřadit
|
||
jako verzi…" — read by the caller to import these as versions
|
||
(:meth:`FileManager.add_movie_version`) instead of standalone films."""
|
||
return dict(self._version_links)
|
||
|
||
def _add_files(self) -> None:
|
||
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
|
||
for path in paths:
|
||
self._append_row(Path(path))
|
||
|
||
def _autofill_tmdb(self) -> None:
|
||
"""Fill Title/Year/TMDb-id from TMDb per row (blue = suggested, verify).
|
||
|
||
A row that already has a TMDb id is resolved by id; otherwise the
|
||
filename is searched (narrowed by the year if present) and the top hit's
|
||
id/title/year are filled in.
|
||
"""
|
||
import re
|
||
|
||
from src.core import tmdb
|
||
|
||
if not tmdb.api_available():
|
||
QMessageBox.warning(
|
||
self,
|
||
"TMDb",
|
||
"Není nastaven TMDB_API_KEY (.env). Doplň ID ručně, nebo přidej klíč.",
|
||
)
|
||
return
|
||
|
||
found = 0
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
try:
|
||
for _src, title_edit, year_edit, tmdb_edit, _csfd in self._rows:
|
||
try:
|
||
if tmdb_edit.text().strip().isdigit():
|
||
hit = tmdb.lookup_movie(int(tmdb_edit.text().strip()))
|
||
else:
|
||
query = re.sub(r"[._]+", " ", title_edit.text()).strip()
|
||
year = year_edit.text().strip() or None
|
||
hits = tmdb.search_movie(query, year=year, limit=1)
|
||
hit = hits[0] if hits else None
|
||
except tmdb.TmdbError:
|
||
hit = None
|
||
if hit is None:
|
||
continue
|
||
found += 1
|
||
if not tmdb_edit.text().strip():
|
||
tmdb_edit.setText(str(hit.tmdb_id))
|
||
self._set_suggested(tmdb_edit, True)
|
||
title_edit.setText(hit.title)
|
||
if hit.year:
|
||
year_edit.setText(hit.year)
|
||
self._set_suggested(year_edit, True)
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
|
||
QMessageBox.information(
|
||
self, "TMDb", f"Doplněno {found} z {len(self._rows)} řádků."
|
||
)
|
||
|
||
def _autofill_via_csfd(self) -> None:
|
||
"""Fill ČSFD-odkaz / TMDb-id / Title / Year per row via the ČSFD bridge.
|
||
|
||
Per row: use the ČSFD link if given, otherwise search ČSFD by the cleaned
|
||
filename. From the ČSFD film take the original title + year and search
|
||
TMDb by those (Czech title as a fallback). Filled values are blue
|
||
(suggested — verify). Rows that already have a value keep it.
|
||
"""
|
||
from src.core import csfd, tmdb
|
||
|
||
if not csfd.dependencies_available():
|
||
QMessageBox.warning(
|
||
self, "ČSFD", "Chybí balíčky requests / beautifulsoup4 (spusť uv sync)."
|
||
)
|
||
return
|
||
|
||
session = csfd.requests.Session()
|
||
found = 0
|
||
errors: list[str] = []
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
try:
|
||
for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows:
|
||
try:
|
||
url = csfd_edit.text().strip() or csfd.find_csfd_url(
|
||
csfd.clean_filename_to_query(source.name), session=session
|
||
)
|
||
if not url:
|
||
continue
|
||
film = csfd.fetch_film(url, session=session)
|
||
if not csfd_edit.text().strip():
|
||
csfd_edit.setText(url)
|
||
self._set_suggested(csfd_edit, True)
|
||
|
||
q_title, q_year = film.tmdb_query
|
||
hits = (
|
||
tmdb.search_movie(q_title, year=q_year, limit=1)
|
||
if tmdb.api_available()
|
||
else []
|
||
)
|
||
if (
|
||
not hits
|
||
and film.czech_title
|
||
and film.czech_title != q_title
|
||
and tmdb.api_available()
|
||
):
|
||
hits = tmdb.search_movie(film.czech_title, year=q_year, limit=1)
|
||
hit = hits[0] if hits else None
|
||
|
||
if hit is None:
|
||
# No TMDb match — still surface the ČSFD title/year.
|
||
if q_title:
|
||
title_edit.setText(q_title)
|
||
if q_year and not year_edit.text().strip():
|
||
year_edit.setText(q_year)
|
||
self._set_suggested(year_edit, True)
|
||
continue
|
||
|
||
found += 1
|
||
if not tmdb_edit.text().strip():
|
||
tmdb_edit.setText(str(hit.tmdb_id))
|
||
self._set_suggested(tmdb_edit, True)
|
||
title_edit.setText(hit.title)
|
||
if hit.year:
|
||
year_edit.setText(hit.year)
|
||
self._set_suggested(year_edit, True)
|
||
except (csfd.CsfdError, tmdb.TmdbError, OSError) as exc:
|
||
errors.append(f"{source.name}: {exc}")
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
session.close()
|
||
|
||
msg = f"Přes ČSFD doplněno {found} z {len(self._rows)} řádků."
|
||
if errors:
|
||
QMessageBox.warning(
|
||
self, "ČSFD", msg + "\n\nChyby:\n" + "\n".join(errors[:5])
|
||
)
|
||
else:
|
||
QMessageBox.information(self, "ČSFD", msg)
|
||
|
||
def accept(self) -> None:
|
||
"""Block the import until every standalone row has a title and a
|
||
four-digit year — a row linked as a version needs neither (its
|
||
identity comes from the film it's a version of).
|
||
|
||
With "Vynechat řádky bez TMDb ID" ticked, standalone rows without a
|
||
TMDb ID are dropped first (and no longer need a year); a version row
|
||
is exempt on its own but goes with its target if that one is dropped.
|
||
"""
|
||
self._skipped_no_tmdb = []
|
||
if self.skip_no_tmdb_check.isChecked():
|
||
|
||
def has_tmdb(entry) -> bool:
|
||
source, _t, _y, tmdb_edit, _c = entry
|
||
link = self._version_links.get(source)
|
||
if link is None:
|
||
return bool(tmdb_edit.text().strip())
|
||
target = self._row_by_source(link[0])
|
||
return target is not None and bool(target[3].text().strip())
|
||
|
||
kept = [r for r in self._rows if has_tmdb(r)]
|
||
self._skipped_no_tmdb = [r[0] for r in self._rows if not has_tmdb(r)]
|
||
if not kept:
|
||
QMessageBox.warning(
|
||
self, "Import", "Žádný řádek nemá TMDb ID — není co importovat."
|
||
)
|
||
return
|
||
dropped = set(self._skipped_no_tmdb)
|
||
self._version_links = {
|
||
s: link
|
||
for s, link in self._version_links.items()
|
||
if s not in dropped and link[0] not in dropped
|
||
}
|
||
self._rows = kept
|
||
|
||
bad_year: list[str] = []
|
||
for source, title_edit, year_edit, _tmdb, _csfd in self._rows:
|
||
if source in self._version_links:
|
||
continue # identity comes from its target, not this row
|
||
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()
|
||
|
||
@property
|
||
def skipped_no_tmdb(self) -> list[Path]:
|
||
"""Sources dropped by "Vynechat řádky bez TMDb ID" (valid after accept)."""
|
||
return list(self._skipped_no_tmdb)
|
||
|
||
@property
|
||
def selected_library(self) -> str:
|
||
return self.library_combo.currentText()
|
||
|
||
def entries(self) -> list[tuple[Path, str, str, str, str, str | None]]:
|
||
"""Return (source, title, year, tmdb_id, csfd_url, format_3d) per
|
||
**standalone** row — a row linked as a version (see
|
||
:meth:`version_links`) is excluded, it isn't imported as a film of
|
||
its own. ``format_3d`` is always ``None`` for a row that has a
|
||
follower (see :meth:`_has_followers`) even if its combo still shows a
|
||
value — once grouped, 3D belongs to a specific version's popisek, not
|
||
the flat identity this row would otherwise import as.
|
||
"""
|
||
result: list[tuple[Path, str, str, str, str, str | None]] = []
|
||
for source, title_edit, year_edit, tmdb_edit, csfd_edit in self._rows:
|
||
if source in self._version_links:
|
||
continue
|
||
title = title_edit.text().strip() or source.stem
|
||
combo = self._format3d_combos.get(source)
|
||
format_3d = (
|
||
combo.currentData()
|
||
if combo is not None and not self._has_followers(source)
|
||
else None
|
||
)
|
||
result.append(
|
||
(
|
||
source,
|
||
title,
|
||
year_edit.text().strip(),
|
||
tmdb_edit.text().strip(),
|
||
csfd_edit.text().strip(),
|
||
format_3d,
|
||
)
|
||
)
|
||
return result
|
||
|
||
|
||
class ImportEpisodesDialog(QDialog):
|
||
"""Collect a series identity + episode files, name them Series SxxEyy.ext."""
|
||
|
||
def __init__(
|
||
self, parent: QWidget, sources: list[Path], libraries: list[str] | None = None
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Importovat epizody seriálu")
|
||
self.setMinimumSize(720, 460)
|
||
self._libraries = libraries or ["Seriály"]
|
||
self._rows: list[tuple[Path, QLineEdit]] = []
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
form = QHBoxLayout()
|
||
form.addWidget(QLabel("Seriál:"))
|
||
self.title_edit = QLineEdit()
|
||
self.title_edit.setPlaceholderText("Název seriálu")
|
||
form.addWidget(self.title_edit)
|
||
form.addWidget(QLabel("Rok:"))
|
||
self.year_edit = QLineEdit()
|
||
self.year_edit.setMaximumWidth(64)
|
||
self.year_edit.setPlaceholderText("rok")
|
||
self.year_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit)
|
||
)
|
||
form.addWidget(self.year_edit)
|
||
form.addWidget(QLabel("TMDb ID:"))
|
||
self.tmdb_edit = QLineEdit()
|
||
self.tmdb_edit.setMaximumWidth(96)
|
||
self.tmdb_edit.setPlaceholderText("TV ID")
|
||
self.tmdb_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)
|
||
)
|
||
form.addWidget(self.tmdb_edit)
|
||
find_btn = QPushButton("🔎 Dohledat")
|
||
find_btn.clicked.connect(self._autofill_tmdb)
|
||
form.addWidget(find_btn)
|
||
layout.addLayout(form)
|
||
|
||
lib_row = QHBoxLayout()
|
||
lib_row.addWidget(QLabel("Knihovna:"))
|
||
self.library_combo = QComboBox()
|
||
self.library_combo.addItems(self._libraries)
|
||
lib_row.addWidget(self.library_combo)
|
||
lib_row.addStretch(1)
|
||
layout.addLayout(lib_row)
|
||
|
||
info = QLabel(
|
||
"Epizody se uloží jako „Název SxxEyy.ext“ do Season složek. "
|
||
"Číslo sezóny/epizody se předvyplní z názvu souboru — nerozpoznané "
|
||
"(nebo špatně určené) dopiš ve sloupci SxxEyy (např. S01E05 / 1x05). "
|
||
"Je-li epizoda rozdělená na víc souborů (starý záznam na dvě kazety "
|
||
"apod.), přidej part: S01E05P2. "
|
||
"„Přidat složku…“ projde i všechny podsložky (Season 01, Season 02, …)."
|
||
)
|
||
info.setWordWrap(True)
|
||
layout.addWidget(info)
|
||
|
||
self.table = QTableWidget(0, 3)
|
||
self.table.setHorizontalHeaderLabels(["Soubor", "SxxEyy", ""])
|
||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
header = self.table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
layout.addWidget(self.table)
|
||
|
||
add_row = QHBoxLayout()
|
||
add_btn = QPushButton("➕ Přidat epizody…")
|
||
add_btn.clicked.connect(self._add_files)
|
||
add_row.addWidget(add_btn)
|
||
folder_btn = QPushButton("📁 Přidat složku…")
|
||
folder_btn.setToolTip(
|
||
"Vyber složku seriálu — Curator projde i všechny podsložky "
|
||
"a přidá každý video soubor v nich."
|
||
)
|
||
folder_btn.clicked.connect(self._add_folder)
|
||
add_row.addWidget(folder_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.StandardButton.Ok | QDialogButtonBox.StandardButton.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.ItemFlag.ItemIsEnabled)
|
||
self.table.setItem(row, 0, name_item)
|
||
code = series.parse_episode(source.name)
|
||
part = series.parse_episode_part(source.name) if code else None
|
||
prefill = f"S{code[0]:02d}E{code[1]:02d}" if code else ""
|
||
if prefill and part:
|
||
prefill += f"P{part}"
|
||
code_edit = QLineEdit(prefill)
|
||
code_edit.setPlaceholderText("SxxEyy")
|
||
code_edit.setMaximumWidth(120)
|
||
code_edit.textChanged.connect(lambda _t, e=code_edit: self._mark_code(e))
|
||
self.table.setCellWidget(row, 1, code_edit)
|
||
remove_btn = QPushButton("✕")
|
||
remove_btn.setFixedWidth(28)
|
||
remove_btn.clicked.connect(lambda _c, p=source: self._remove_row(p))
|
||
self.table.setCellWidget(row, 2, remove_btn)
|
||
self._rows.append((source, code_edit))
|
||
self._mark_code(code_edit)
|
||
|
||
def _mark_code(self, edit: QLineEdit) -> None:
|
||
"""Colour the SxxEyy field: green = valid, red = unparsable, plain = empty."""
|
||
text = edit.text().strip()
|
||
if not text:
|
||
edit.setStyleSheet("")
|
||
edit.setToolTip("Prázdné — epizoda se přeskočí. Zadej např. S01E05.")
|
||
elif series.parse_episode_code(text) is None:
|
||
edit.setStyleSheet("QLineEdit { color: #c0392b; }")
|
||
edit.setToolTip(
|
||
"Nerozpoznaný formát — použij S01E05 nebo 1x05 "
|
||
"(část epizody: S01E05P2)."
|
||
)
|
||
else:
|
||
edit.setStyleSheet("QLineEdit { color: #27ae60; }")
|
||
edit.setToolTip("")
|
||
|
||
def _remove_row(self, source: Path) -> None:
|
||
for index, (path, _edit) in enumerate(self._rows):
|
||
if path is source:
|
||
self.table.removeRow(index)
|
||
self._rows.pop(index)
|
||
return
|
||
|
||
def _add_files(self) -> None:
|
||
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber epizody")
|
||
for path in paths:
|
||
self._append_row(Path(path))
|
||
|
||
def _add_folder(self) -> None:
|
||
"""Add every video file under a picked folder (recursively, subfolders too)."""
|
||
folder = QFileDialog.getExistingDirectory(self, "Vyber složku se seriálem")
|
||
if not folder:
|
||
return
|
||
folder_path = Path(folder)
|
||
have = {path for path, _ in self._rows}
|
||
added = 0
|
||
for path in series.collect_episode_files(folder_path):
|
||
if path not in have:
|
||
self._append_row(path)
|
||
added += 1
|
||
if not added:
|
||
QMessageBox.information(
|
||
self,
|
||
"Import epizod",
|
||
"Ve složce (ani podsložkách) nejsou žádné nové video soubory.",
|
||
)
|
||
return
|
||
# Seed the series identity from the folder name if still blank.
|
||
if not self.title_edit.text().strip():
|
||
parsed = parse_pool_stem(folder_path.name)
|
||
self.title_edit.setText(parsed[0] if parsed else folder_path.name)
|
||
if parsed and not self.year_edit.text().strip():
|
||
self.year_edit.setText(str(parsed[1]))
|
||
tid = parse_tmdb_id(folder_path.name)
|
||
if tid and not self.tmdb_edit.text().strip():
|
||
self.tmdb_edit.setText(str(tid))
|
||
|
||
def _autofill_tmdb(self) -> None:
|
||
from src.core import tmdb
|
||
|
||
if not tmdb.api_available():
|
||
QMessageBox.warning(self, "TMDb", "Není nastaven TMDB_API_KEY (.env).")
|
||
return
|
||
try:
|
||
if self.tmdb_edit.text().strip().isdigit():
|
||
hit = tmdb.lookup_tv(int(self.tmdb_edit.text().strip()))
|
||
else:
|
||
hits = tmdb.search_tv(
|
||
self.title_edit.text(),
|
||
year=self.year_edit.text().strip() or None,
|
||
limit=1,
|
||
)
|
||
hit = hits[0] if hits else None
|
||
except tmdb.TmdbError as exc:
|
||
QMessageBox.warning(self, "TMDb", str(exc))
|
||
return
|
||
if hit is None:
|
||
QMessageBox.information(self, "TMDb", "Nic nenalezeno.")
|
||
return
|
||
if not self.tmdb_edit.text().strip():
|
||
self.tmdb_edit.setText(str(hit.tmdb_id))
|
||
self.title_edit.setText(hit.title)
|
||
if hit.year:
|
||
self.year_edit.setText(hit.year)
|
||
|
||
def accept(self) -> None:
|
||
if not self._rows:
|
||
QMessageBox.warning(
|
||
self, "Seriál", "Přidej aspoň jednu epizodu (soubor nebo složku)."
|
||
)
|
||
return
|
||
if not self.title_edit.text().strip():
|
||
QMessageBox.warning(self, "Seriál", "Zadej název seriálu.")
|
||
return
|
||
missing = [
|
||
p.name
|
||
for p, edit in self._rows
|
||
if series.parse_episode_code(edit.text().strip()) is None
|
||
]
|
||
if missing:
|
||
listing = "\n".join(f" • {n}" for n in missing[:10])
|
||
more = f"\n … a další ({len(missing) - 10})" if len(missing) > 10 else ""
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Chybí číslování",
|
||
f"{len(missing)} epizod nemá platné SxxEyy a při importu se "
|
||
f"přeskočí:\n{listing}{more}\n\nPokračovat?",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
super().accept()
|
||
|
||
@property
|
||
def title(self) -> str:
|
||
return self.title_edit.text().strip()
|
||
|
||
@property
|
||
def year(self) -> str:
|
||
return self.year_edit.text().strip()
|
||
|
||
@property
|
||
def tmdb_id(self) -> str:
|
||
return self.tmdb_edit.text().strip()
|
||
|
||
@property
|
||
def library(self) -> str:
|
||
return self.library_combo.currentText()
|
||
|
||
@property
|
||
def move_files(self) -> bool:
|
||
return self.move_check.isChecked()
|
||
|
||
def sources(self) -> list[Path]:
|
||
return [path for path, _ in self._rows]
|
||
|
||
def episode_codes(self) -> dict[Path, tuple[int, int, int | None]]:
|
||
"""Per-file ``(season, episode, part)`` taken from the SxxEyy column.
|
||
|
||
Covers what the user typed / corrected in the dialog; rows whose field
|
||
is empty or unparsable are omitted (they fall back to filename parsing
|
||
in ``import_episodes`` and are skipped if that fails too).
|
||
"""
|
||
codes: dict[Path, tuple[int, int, int | None]] = {}
|
||
for path, edit in self._rows:
|
||
parsed = series.parse_episode_code(edit.text().strip())
|
||
if parsed is not None:
|
||
codes[path] = parsed
|
||
return codes
|
||
|
||
|
||
class SeriesOverviewDialog(QDialog):
|
||
"""Read-only Series → Season → Episode tree with a per-series rename action."""
|
||
|
||
def __init__(self, parent: QWidget, filehandler: FileManager) -> None:
|
||
super().__init__(parent)
|
||
self.filehandler = filehandler
|
||
self.setWindowTitle("Přehled seriálů")
|
||
self.setMinimumSize(720, 520)
|
||
self._series: list = []
|
||
|
||
layout = QVBoxLayout(self)
|
||
self.tree = QTreeWidget()
|
||
self.tree.setHeaderLabels(["Seriál / sezóna / epizoda", "Stav"])
|
||
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
self.tree.itemDoubleClicked.connect(lambda _i, _c: self._edit_selected())
|
||
layout.addWidget(self.tree)
|
||
|
||
btns = QHBoxLayout()
|
||
edit_btn = QPushButton("Upravit seriál…")
|
||
edit_btn.clicked.connect(self._edit_selected)
|
||
btns.addWidget(edit_btn)
|
||
rename_btn = QPushButton("Přejmenovat epizody")
|
||
rename_btn.clicked.connect(self._rename_selected)
|
||
btns.addWidget(rename_btn)
|
||
remove_btn = QPushButton("Odebrat z poolu…")
|
||
remove_btn.setToolTip(
|
||
"Smaže to, co je ve stromu vybrané — celý seriál, jen jednu sezónu, "
|
||
"nebo jen jednu epizodu."
|
||
)
|
||
remove_btn.clicked.connect(self._remove_selected)
|
||
btns.addWidget(remove_btn)
|
||
btns.addStretch(1)
|
||
close_btn = QPushButton("Zavřít")
|
||
close_btn.clicked.connect(self.accept)
|
||
btns.addWidget(close_btn)
|
||
layout.addLayout(btns)
|
||
|
||
self._reload()
|
||
|
||
def _reload(self) -> None:
|
||
self.tree.clear()
|
||
self._series = self.filehandler.load_series()
|
||
for idx, ser in enumerate(self._series):
|
||
label = ser.title
|
||
if ser.year:
|
||
label += f" ({ser.year})"
|
||
if ser.tmdb_id:
|
||
label += f" [tmdb-{ser.tmdb_id}]"
|
||
top = QTreeWidgetItem([label, f"{len(ser.episodes)} epizod"])
|
||
top.setData(0, Qt.ItemDataRole.UserRole, idx)
|
||
self.tree.addTopLevelItem(top)
|
||
for season, eps in ser.seasons().items():
|
||
sname = (
|
||
series.season_folder_name(season) if season >= 0 else "? bez sezóny"
|
||
)
|
||
snode = QTreeWidgetItem([sname, f"{len(eps)} dílů"])
|
||
snode.setData(0, Qt.ItemDataRole.UserRole + 1, (season, eps))
|
||
top.addChild(snode)
|
||
for ep in eps:
|
||
ok = series.is_canonical_episode(ep.path, ser.title)
|
||
enode = QTreeWidgetItem(
|
||
[f"{ep.code} {ep.path.name}", "OK" if ok else "přejmenovat"]
|
||
)
|
||
enode.setData(0, Qt.ItemDataRole.UserRole + 1, ep)
|
||
if not ok:
|
||
enode.setForeground(1, Qt.GlobalColor.red)
|
||
snode.addChild(enode)
|
||
top.setExpanded(True)
|
||
|
||
def _selected_series_index(self):
|
||
item = self.tree.currentItem()
|
||
while item is not None and item.parent() is not None:
|
||
item = item.parent()
|
||
return item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
|
||
|
||
def _selected_node(self):
|
||
"""``("series"|"season"|"episode", Series, extra)`` for the current
|
||
selection, by tree depth — ``extra`` is ``None`` / ``(season, eps)`` /
|
||
the ``Episode``. ``None`` when nothing is selected."""
|
||
item = self.tree.currentItem()
|
||
if item is None:
|
||
return None
|
||
if item.parent() is None:
|
||
return "series", self._series[item.data(0, Qt.ItemDataRole.UserRole)], None
|
||
if item.parent().parent() is None:
|
||
ser = self._series[item.parent().data(0, Qt.ItemDataRole.UserRole)]
|
||
return "season", ser, item.data(0, Qt.ItemDataRole.UserRole + 1)
|
||
ser = self._series[item.parent().parent().data(0, Qt.ItemDataRole.UserRole)]
|
||
return "episode", ser, item.data(0, Qt.ItemDataRole.UserRole + 1)
|
||
|
||
def _rename_selected(self) -> None:
|
||
idx = self._selected_series_index()
|
||
if idx is None:
|
||
QMessageBox.information(self, "Seriály", "Vyber seriál.")
|
||
return
|
||
result = self.filehandler.rename_episodes_to_canonical(self._series[idx])
|
||
self._reload()
|
||
QMessageBox.information(
|
||
self,
|
||
"Přejmenování epizod",
|
||
f"Přejmenováno: {len(result['renamed'])}, "
|
||
f"přeskočeno: {len(result['skipped'])}, kolize: {len(result['conflicts'])}.",
|
||
)
|
||
|
||
def _remove_selected(self) -> None:
|
||
node = self._selected_node()
|
||
if node is None:
|
||
QMessageBox.information(
|
||
self, "Seriály", "Vyber seriál, sezónu nebo epizodu."
|
||
)
|
||
return
|
||
kind, ser, extra = node
|
||
if kind == "series":
|
||
question = (
|
||
f"Smazat seriál „{ser.title}“ z poolu včetně všech sezón a epizod?"
|
||
)
|
||
action = lambda: self.filehandler.remove_series(ser)
|
||
elif kind == "season":
|
||
season, eps = extra
|
||
name = (
|
||
series.season_folder_name(season)
|
||
if season >= 0
|
||
else "epizody bez rozpoznané sezóny"
|
||
)
|
||
question = f"Smazat „{name}“ ({len(eps)} dílů) seriálu „{ser.title}“?"
|
||
action = lambda: self.filehandler.remove_season(ser, season)
|
||
else: # episode
|
||
ep = extra
|
||
question = (
|
||
f"Smazat epizodu {ep.code} „{ep.path.name}“ ze seriálu „{ser.title}“?"
|
||
)
|
||
action = lambda: self.filehandler.remove_episodes([ep])
|
||
if (
|
||
QMessageBox.question(self, "Odebrat z poolu", question)
|
||
!= QMessageBox.StandardButton.Yes
|
||
):
|
||
return
|
||
try:
|
||
action()
|
||
except (ValueError, OSError) as exc:
|
||
QMessageBox.warning(self, "Chyba", str(exc))
|
||
return
|
||
self._reload()
|
||
|
||
def _edit_selected(self) -> None:
|
||
idx = self._selected_series_index()
|
||
if idx is None:
|
||
QMessageBox.information(self, "Seriály", "Vyber seriál.")
|
||
return
|
||
ser = self._series[idx]
|
||
dialog = SeriesEditDialog(self, ser, self.filehandler)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
# 1) apply per-episode season/episode overrides, 2) normalize episode files,
|
||
# 3) rename/move the series folder + retitle episodes to the new name.
|
||
for ep, season, number in dialog.episode_numbers():
|
||
ep.season, ep.episode = season, number
|
||
self.filehandler.rename_episodes_to_canonical(ser)
|
||
try:
|
||
self.filehandler.apply_series_edits(
|
||
ser,
|
||
title=dialog.title,
|
||
year=dialog.year or None,
|
||
tmdb_id=dialog.tmdb_id or None,
|
||
library=dialog.library,
|
||
)
|
||
except (ValueError, FileExistsError, OSError) as exc:
|
||
QMessageBox.warning(self, "Úprava seriálu selhala", str(exc))
|
||
self._reload()
|
||
|
||
|
||
class VersionLabelDialog(QDialog):
|
||
"""Pick a label for a new movie version — a 3D formát preset or free text.
|
||
|
||
Jellyfin tells versions apart purely by this label text, so a 3D preset
|
||
just *is* the label (``3D HSBS``) — same tokens it already scans for in a
|
||
flat ``.3D.HSBS`` filename, just placed after " - " instead.
|
||
"""
|
||
|
||
def __init__(self, parent: QWidget, initial: str = "", intro: str = "") -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Popisek verze")
|
||
self.setMinimumWidth(360)
|
||
|
||
layout = QVBoxLayout(self)
|
||
if intro:
|
||
intro_label = QLabel(intro)
|
||
intro_label.setWordWrap(True)
|
||
layout.addWidget(intro_label)
|
||
layout.addWidget(
|
||
QLabel(
|
||
"Jak tuhle verzi Jellyfin rozliší od ostatních (uvidí ji v přehrávači "
|
||
"jako volbu)."
|
||
)
|
||
)
|
||
|
||
form = QFormLayout()
|
||
self.format3d_combo = QComboBox()
|
||
self.format3d_combo.addItem("— vlastní popisek —", None)
|
||
for fmt in THREE_D_FORMATS:
|
||
self.format3d_combo.addItem(f"3D {fmt}", fmt)
|
||
self.format3d_combo.currentIndexChanged.connect(self._sync_label)
|
||
form.addRow("3D formát:", self.format3d_combo)
|
||
|
||
self.label_edit = QLineEdit()
|
||
self.label_edit.setPlaceholderText(
|
||
"např. 4K, Director's Cut (prázdné = obyčejná 2D verze, Výchozí)"
|
||
)
|
||
form.addRow("Popisek:", self.label_edit)
|
||
layout.addLayout(form)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
preset = normalize_3d_label(initial)
|
||
if preset:
|
||
self.format3d_combo.setCurrentIndex(self.format3d_combo.findData(preset))
|
||
elif initial:
|
||
self.label_edit.setText(initial)
|
||
|
||
def _sync_label(self) -> None:
|
||
fmt = self.format3d_combo.currentData()
|
||
self.label_edit.setEnabled(fmt is None)
|
||
self.label_edit.setText(f"3D {fmt}" if fmt else "")
|
||
|
||
def label(self) -> str:
|
||
return self.label_edit.text().strip()
|
||
|
||
|
||
class DuplicatesDialog(QDialog):
|
||
"""Nástroje → Kontrola duplicit: movies present in the pool more than once.
|
||
|
||
Each group lists the colliding movies with why they were flagged; a movie
|
||
can be folded into another one as a version (the usual fix for a 2D + 3D
|
||
pair lying flat side by side) or deleted when it's a plain copy.
|
||
"""
|
||
|
||
def __init__(self, parent: QWidget, filehandler: FileManager) -> None:
|
||
super().__init__(parent)
|
||
self.filehandler = filehandler
|
||
self.changed = False
|
||
self.setWindowTitle("Kontrola duplicit")
|
||
self.setMinimumSize(820, 480)
|
||
|
||
layout = QVBoxLayout(self)
|
||
self.summary = QLabel()
|
||
self.summary.setWordWrap(True)
|
||
layout.addWidget(self.summary)
|
||
|
||
self.tree = QTreeWidget()
|
||
self.tree.setHeaderLabels(["Film / soubor", "Knihovna", "Verzí", "Velikost"])
|
||
self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
self.tree.currentItemChanged.connect(lambda *_: self._sync_buttons())
|
||
layout.addWidget(self.tree)
|
||
|
||
btns = QHBoxLayout()
|
||
self.merge_btn = QPushButton("Sloučit jako verzi do…")
|
||
self.merge_btn.setToolTip(
|
||
"Přesune vybraný soubor do složky druhého filmu jako další verzi; "
|
||
"metadata druhého filmu zůstanou, štítky a ČSFD odkaz se doplní."
|
||
)
|
||
self.merge_btn.clicked.connect(self._merge_selected)
|
||
btns.addWidget(self.merge_btn)
|
||
self.remove_btn = QPushButton("Smazat vybraný z poolu…")
|
||
self.remove_btn.clicked.connect(self._remove_selected)
|
||
btns.addWidget(self.remove_btn)
|
||
btns.addStretch(1)
|
||
close_btn = QPushButton("Zavřít")
|
||
close_btn.clicked.connect(self.accept)
|
||
btns.addWidget(close_btn)
|
||
layout.addLayout(btns)
|
||
|
||
self._reload()
|
||
|
||
def _reload(self) -> None:
|
||
self.groups = self.filehandler.find_duplicates()
|
||
self.tree.clear()
|
||
for gi, group in enumerate(self.groups):
|
||
first = group["movies"][0]
|
||
top = QTreeWidgetItem(
|
||
[f"{first.title or first.filename} — {', '.join(group['reasons'])}"]
|
||
)
|
||
font = top.font(0)
|
||
font.setBold(True)
|
||
top.setFont(0, font)
|
||
top.setData(0, Qt.ItemDataRole.UserRole, (gi, None))
|
||
for mi, movie in enumerate(group["movies"]):
|
||
versions = self.filehandler.list_movie_versions(movie)
|
||
grouped = self.filehandler.is_movie_group_member(movie)
|
||
name = f"{movie.file_path.parent.name}/" if grouped else movie.filename
|
||
size = QtApp._format_size(
|
||
sum(
|
||
v.file_path.stat().st_size
|
||
for v in versions
|
||
if v.file_path.exists()
|
||
)
|
||
)
|
||
child = QTreeWidgetItem(
|
||
[
|
||
name,
|
||
self.filehandler.library_of(movie) or "—",
|
||
str(len(versions)),
|
||
size,
|
||
]
|
||
)
|
||
child.setToolTip(0, "\n".join(str(v.file_path) for v in versions))
|
||
child.setData(0, Qt.ItemDataRole.UserRole, (gi, mi))
|
||
top.addChild(child)
|
||
self.tree.addTopLevelItem(top)
|
||
top.setExpanded(True)
|
||
if self.groups:
|
||
self.summary.setText(
|
||
f"Nalezeno {len(self.groups)} skupin možných duplicit. Vyber soubor "
|
||
"a buď ho slouč jako verzi do druhého filmu (např. 2D + 3D), nebo "
|
||
"smaž, pokud je to jen kopie."
|
||
)
|
||
else:
|
||
self.summary.setText(
|
||
"✅ Žádné duplicity — každý film je v poolu jen jednou."
|
||
)
|
||
self._sync_buttons()
|
||
|
||
def _selected(self) -> tuple[dict, File] | None:
|
||
item = self.tree.currentItem()
|
||
if item is None:
|
||
return None
|
||
gi, mi = item.data(0, Qt.ItemDataRole.UserRole)
|
||
if mi is None:
|
||
return None
|
||
group = self.groups[gi]
|
||
return group, group["movies"][mi]
|
||
|
||
def _sync_buttons(self) -> None:
|
||
picked = self._selected()
|
||
self.merge_btn.setEnabled(picked is not None)
|
||
self.remove_btn.setEnabled(picked is not None)
|
||
|
||
def _merge_selected(self) -> None:
|
||
picked = self._selected()
|
||
if picked is None:
|
||
return
|
||
group, other = picked
|
||
candidates = [m for m in group["movies"] if m is not other]
|
||
if len(candidates) == 1:
|
||
keep = candidates[0]
|
||
else:
|
||
names = [m.file_path.name for m in candidates]
|
||
choice, ok = QInputDialog.getItem(
|
||
self, "Sloučit", "Do kterého filmu přidat jako verzi?", names, 0, False
|
||
)
|
||
if not ok:
|
||
return
|
||
keep = candidates[names.index(choice)]
|
||
|
||
fmt = parse_3d_format(other.file_path.stem)
|
||
dialog = VersionLabelDialog(
|
||
self,
|
||
initial=f"3D {fmt}" if fmt else "",
|
||
intro=f"„{other.filename}“ → verze filmu „{keep.title or keep.filename}“.",
|
||
)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
try:
|
||
self.filehandler.merge_as_version(keep, other, dialog.label())
|
||
except (ValueError, RuntimeError, OSError) as exc:
|
||
QMessageBox.warning(self, "Sloučení se nezdařilo", str(exc))
|
||
return
|
||
self.changed = True
|
||
self._reload()
|
||
|
||
def _remove_selected(self) -> None:
|
||
picked = self._selected()
|
||
if picked is None:
|
||
return
|
||
_group, movie = picked
|
||
versions = self.filehandler.list_movie_versions(movie)
|
||
extra = f" (všech {len(versions)} verzí)" if len(versions) > 1 else ""
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Smazat z poolu",
|
||
f"Smazat „{movie.file_path.name}“{extra} z poolu včetně souboru, "
|
||
"titulků a metadat?",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
try:
|
||
self.filehandler.remove_movie(movie)
|
||
except OSError as exc:
|
||
QMessageBox.warning(self, "Chyba", str(exc))
|
||
return
|
||
self.changed = True
|
||
self._reload()
|
||
|
||
|
||
class MovieEditDialog(QDialog):
|
||
"""One dialog for every editable parameter of a movie."""
|
||
|
||
def __init__(
|
||
self, parent: QWidget, file_obj: File, filehandler: FileManager
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Upravit film")
|
||
self.setMinimumWidth(540)
|
||
self.file_obj = file_obj
|
||
self.filehandler = filehandler
|
||
ctx = file_obj.name_context()
|
||
|
||
layout = QVBoxLayout(self)
|
||
form = QFormLayout()
|
||
self.title_edit = QLineEdit(file_obj.title or file_obj.file_path.stem)
|
||
form.addRow("Název:", self.title_edit)
|
||
self.year_edit = QLineEdit(str(ctx.get("year") or ""))
|
||
self.year_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit)
|
||
)
|
||
form.addRow("Rok:", self.year_edit)
|
||
|
||
tmdb_row = QHBoxLayout()
|
||
self.tmdb_edit = QLineEdit()
|
||
self.tmdb_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)
|
||
)
|
||
tmdb_row.addWidget(self.tmdb_edit)
|
||
self.find_btn = QPushButton("Dohledat z TMDb")
|
||
self.find_btn.clicked.connect(self._lookup)
|
||
tmdb_row.addWidget(self.find_btn)
|
||
tmdb_wrap = QWidget()
|
||
tmdb_wrap.setLayout(tmdb_row)
|
||
form.addRow("TMDb ID:", tmdb_wrap)
|
||
|
||
self.library_combo = QComboBox()
|
||
self.library_combo.addItems(filehandler.libraries_accepting_movies)
|
||
cur_lib = filehandler.library_of(file_obj)
|
||
if cur_lib:
|
||
i = self.library_combo.findText(cur_lib)
|
||
if i >= 0:
|
||
self.library_combo.setCurrentIndex(i)
|
||
form.addRow("Knihovna:", self.library_combo)
|
||
|
||
self.date_edit = QLineEdit(file_obj.date or "")
|
||
self.date_edit.setPlaceholderText("YYYY-MM-DD")
|
||
form.addRow("Datum přidání:", self.date_edit)
|
||
|
||
self.csfd_edit = QLineEdit(file_obj.csfd_url or "")
|
||
self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/… (jen uloží se)")
|
||
form.addRow("ČSFD odkaz:", self.csfd_edit)
|
||
|
||
self.format3d_combo = QComboBox()
|
||
self.format3d_combo.addItem("— žádný —", None)
|
||
for fmt in THREE_D_FORMATS:
|
||
self.format3d_combo.addItem(fmt, fmt)
|
||
form.addRow("3D formát:", self.format3d_combo)
|
||
layout.addLayout(form)
|
||
self._refresh_identity_fields()
|
||
|
||
layout.addWidget(
|
||
QLabel(
|
||
"Titulky (externí soubor vedle videa — Jellyfin je přehraje bez muxování; "
|
||
"sledují film i při přejmenování):"
|
||
)
|
||
)
|
||
self.subs_table = QTableWidget(0, 2)
|
||
self.subs_table.setHorizontalHeaderLabels(["Soubor", "Jazyk / příznak"])
|
||
self.subs_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self.subs_table.horizontalHeader().setSectionResizeMode(
|
||
0, QHeaderView.ResizeMode.Stretch
|
||
)
|
||
self.subs_table.setMaximumHeight(110)
|
||
self._subtitles: list = []
|
||
layout.addWidget(self.subs_table)
|
||
subs_btns = QHBoxLayout()
|
||
add_sub_btn = QPushButton("➕ Přidat titulky…")
|
||
add_sub_btn.clicked.connect(self._add_subtitle)
|
||
subs_btns.addWidget(add_sub_btn)
|
||
del_sub_btn = QPushButton("➖ Odebrat")
|
||
del_sub_btn.clicked.connect(self._remove_subtitle)
|
||
subs_btns.addWidget(del_sub_btn)
|
||
subs_btns.addStretch(1)
|
||
layout.addLayout(subs_btns)
|
||
self._reload_subtitles()
|
||
|
||
# --- Média: flat (one file) or a folder of versions / parts ---------
|
||
media_box = QGroupBox("Média filmu")
|
||
media_layout = QVBoxLayout(media_box)
|
||
self.layout_label = QLabel()
|
||
self.layout_label.setWordWrap(True)
|
||
media_layout.addWidget(self.layout_label)
|
||
|
||
mode_row = QHBoxLayout()
|
||
mode_row.addWidget(QLabel("Ve složce:"))
|
||
self.mode_versions = QRadioButton("Verze — alternativy k výběru")
|
||
self.mode_versions.setToolTip(
|
||
"Každý soubor je celý film, Jellyfin nabídne výběr mezi nimi — "
|
||
"kinoverze, režisérská verze, 3D, 4K…"
|
||
)
|
||
self.mode_parts = QRadioButton("Díly — přehrají se za sebou")
|
||
self.mode_parts.setToolTip(
|
||
"Film rozdělený do víc souborů (díl 1, díl 2…) — Jellyfin je "
|
||
"přehraje za sebou jako jeden film."
|
||
)
|
||
self.mode_group = QButtonGroup(self)
|
||
self.mode_group.addButton(self.mode_versions)
|
||
self.mode_group.addButton(self.mode_parts)
|
||
self.mode_versions.setChecked(True)
|
||
self.mode_group.buttonClicked.connect(self._on_mode_clicked)
|
||
mode_row.addWidget(self.mode_versions)
|
||
mode_row.addWidget(self.mode_parts)
|
||
mode_row.addStretch(1)
|
||
media_layout.addLayout(mode_row)
|
||
|
||
self.versions_table = QTableWidget(0, 3)
|
||
self.versions_table.setHorizontalHeaderLabels(["Soubor", "Typ", "Popisek"])
|
||
self.versions_table.setEditTriggers(
|
||
QAbstractItemView.EditTrigger.NoEditTriggers
|
||
)
|
||
self.versions_table.setSelectionBehavior(
|
||
QAbstractItemView.SelectionBehavior.SelectRows
|
||
)
|
||
self.versions_table.horizontalHeader().setSectionResizeMode(
|
||
0, QHeaderView.ResizeMode.Stretch
|
||
)
|
||
self.versions_table.setMaximumHeight(130)
|
||
self._versions: list = []
|
||
media_layout.addWidget(self.versions_table)
|
||
|
||
media_btns = QHBoxLayout()
|
||
add_media_btn = QPushButton("➕ Přidat médium…")
|
||
add_media_btn.setToolTip(
|
||
"Přidá další soubor jako verzi, nebo jako díl — podle volby „Ve "
|
||
"složce“. Samostatný film se tím přesune do vlastní složky."
|
||
)
|
||
add_media_btn.clicked.connect(self._add_media)
|
||
media_btns.addWidget(add_media_btn)
|
||
self.label_btn = QPushButton("✏️ Popisek / 3D…")
|
||
self.label_btn.clicked.connect(self._rename_version)
|
||
media_btns.addWidget(self.label_btn)
|
||
self.up_btn = QPushButton("↑")
|
||
self.up_btn.setToolTip("Posunout díl dopředu")
|
||
self.up_btn.setFixedWidth(36)
|
||
self.up_btn.clicked.connect(lambda: self._move_part(-1))
|
||
media_btns.addWidget(self.up_btn)
|
||
self.down_btn = QPushButton("↓")
|
||
self.down_btn.setToolTip("Posunout díl dozadu")
|
||
self.down_btn.setFixedWidth(36)
|
||
self.down_btn.clicked.connect(lambda: self._move_part(+1))
|
||
media_btns.addWidget(self.down_btn)
|
||
del_media_btn = QPushButton("➖ Odebrat")
|
||
del_media_btn.clicked.connect(self._remove_version)
|
||
media_btns.addWidget(del_media_btn)
|
||
media_btns.addStretch(1)
|
||
media_layout.addLayout(media_btns)
|
||
layout.addWidget(media_box)
|
||
self._reload_versions()
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
def _reload_subtitles(self) -> None:
|
||
self._subtitles = self.filehandler.list_subtitles(self.file_obj)
|
||
self.subs_table.setRowCount(0)
|
||
for sub in self._subtitles:
|
||
row = self.subs_table.rowCount()
|
||
self.subs_table.insertRow(row)
|
||
self.subs_table.setItem(row, 0, QTableWidgetItem(sub.path.name))
|
||
self.subs_table.setItem(row, 1, QTableWidgetItem(sub.label))
|
||
|
||
def _add_subtitle(self) -> None:
|
||
import re
|
||
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self,
|
||
"Vyber soubor s titulky",
|
||
filter="Titulky (*.srt *.ass *.ssa *.sub *.vtt)",
|
||
)
|
||
if not path:
|
||
return
|
||
language_text, ok = QInputDialog.getText(
|
||
self, "Jazyk titulků", "Kód jazyka (např. cs, en; prázdné = bez tagu):"
|
||
)
|
||
if not ok:
|
||
return
|
||
language: str | None = language_text.strip().lower() or None
|
||
if language and not re.fullmatch(r"[a-z]{2,3}", language):
|
||
QMessageBox.warning(
|
||
self, "Titulky", "Kód jazyka zadej jako 2–3 písmena (např. cs)."
|
||
)
|
||
return
|
||
forced = (
|
||
QMessageBox.question(
|
||
self,
|
||
"Titulky",
|
||
"Jsou to vynucené (forced) titulky?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
== QMessageBox.StandardButton.Yes
|
||
)
|
||
try:
|
||
self.filehandler.attach_subtitle(
|
||
self.file_obj,
|
||
Path(path),
|
||
language=language,
|
||
flag="forced" if forced else None,
|
||
)
|
||
except (ValueError, OSError) as exc:
|
||
QMessageBox.warning(self, "Titulky", f"Nelze přidat titulky: {exc}")
|
||
return
|
||
self._reload_subtitles()
|
||
|
||
def _remove_subtitle(self) -> None:
|
||
row = self.subs_table.currentRow()
|
||
if row < 0 or row >= len(self._subtitles):
|
||
QMessageBox.information(self, "Titulky", "Vyber titulky k odebrání.")
|
||
return
|
||
sub = self._subtitles[row]
|
||
answer = QMessageBox.question(
|
||
self, "Odebrat titulky", f"Smazat soubor „{sub.path.name}“?"
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
try:
|
||
self.filehandler.remove_subtitle(sub.path)
|
||
except OSError as exc:
|
||
QMessageBox.warning(self, "Titulky", str(exc))
|
||
return
|
||
self._reload_subtitles()
|
||
|
||
def _refresh_identity_fields(self) -> None:
|
||
"""(Re)fill TMDb ID and enable/disable the identity fields for the
|
||
current grouping state of ``self.file_obj`` — called at open and again
|
||
after adding/removing a version, since that can promote or demote it.
|
||
"""
|
||
grouped = self.filehandler.is_movie_group_member(self.file_obj)
|
||
# A grouped movie's own filename carries " - Label", not the plain
|
||
# "Title (rok) [tmdbid]" the identity parsers expect — read those from
|
||
# the shared container folder's name instead.
|
||
identity_stem = (
|
||
self.file_obj.file_path.parent.name
|
||
if grouped
|
||
else self.file_obj.file_path.stem
|
||
)
|
||
self.tmdb_edit.setText(str(parse_tmdb_id(identity_stem) or ""))
|
||
|
||
note = (
|
||
"Film má víc verzí — název/rok/TMDb ID/knihovna se zatím "
|
||
"needitují společně (odeber verzi, nebo uprav soubory ručně)."
|
||
if grouped
|
||
else ""
|
||
)
|
||
for w in (
|
||
self.title_edit,
|
||
self.year_edit,
|
||
self.tmdb_edit,
|
||
self.find_btn,
|
||
self.library_combo,
|
||
):
|
||
w.setEnabled(not grouped)
|
||
w.setToolTip(note)
|
||
|
||
current_3d = None if grouped else parse_3d_format(identity_stem)
|
||
idx = self.format3d_combo.findData(current_3d)
|
||
self.format3d_combo.setCurrentIndex(max(idx, 0))
|
||
self.format3d_combo.setEnabled(not grouped)
|
||
self.format3d_combo.setToolTip(
|
||
"U filmu s víc verzemi patří 3D do popisku té konkrétní verze "
|
||
"(viz „Verze“ níže), ne sem."
|
||
if grouped
|
||
else "Jellyfin pozná 3D podle „.3D.<FORMÁT>“ v názvu souboru — "
|
||
"HSBS/FSBS = side-by-side (half/full), HTAB/FTAB = top-and-bottom "
|
||
"(half/full), MVC = nativní 3D blu-ray stream."
|
||
)
|
||
|
||
def _reload_versions(self, select: File | None = None) -> None:
|
||
"""Refill the media table and the mode controls from disk."""
|
||
mode = self.filehandler.movie_group_mode(self.file_obj)
|
||
grouped = mode != "flat"
|
||
self._versions = self.filehandler.ordered_media(self.file_obj)
|
||
self.versions_table.setRowCount(0)
|
||
for version in self._versions:
|
||
row = self.versions_table.rowCount()
|
||
self.versions_table.insertRow(row)
|
||
self.versions_table.setItem(
|
||
row, 0, QTableWidgetItem(version.file_path.name)
|
||
)
|
||
# A flat movie has exactly one file and no on-disk label —
|
||
# "Výchozí" is just the table's name for it.
|
||
label = version._identity_from_path()[4] if grouped else ""
|
||
part = normalize_part_label(label)
|
||
if part is not None:
|
||
kind, label = f"díl {part}", ""
|
||
elif label and label != DEFAULT_VERSION_LABEL:
|
||
kind = "verze"
|
||
else:
|
||
kind, label = DEFAULT_VERSION_LABEL, ""
|
||
self.versions_table.setItem(row, 1, QTableWidgetItem(kind))
|
||
self.versions_table.setItem(row, 2, QTableWidgetItem(label))
|
||
if select is not None and version.file_path == select.file_path:
|
||
self.versions_table.selectRow(row)
|
||
|
||
if grouped:
|
||
# the radio shows the folder's actual mode
|
||
(self.mode_parts if mode == "parts" else self.mode_versions).setChecked(
|
||
True
|
||
)
|
||
self.layout_label.setText(
|
||
f"Složka filmu — {len(self._versions)} médií"
|
||
+ (
|
||
" (díly, přehrají se za sebou)."
|
||
if mode == "parts"
|
||
else " (verze, Jellyfin nabídne výběr)."
|
||
)
|
||
)
|
||
else:
|
||
self.layout_label.setText(
|
||
"Samostatný film — jeden soubor v kořeni knihovny (+ titulky). "
|
||
"Přidáním dalšího média se film přesune do vlastní složky "
|
||
"jako verze, nebo jako díl — podle volby níže."
|
||
)
|
||
parts = self._effective_mode() == "parts"
|
||
self.label_btn.setEnabled(grouped and not parts)
|
||
self.up_btn.setEnabled(grouped and parts)
|
||
self.down_btn.setEnabled(grouped and parts)
|
||
|
||
def _effective_mode(self) -> str:
|
||
"""``"parts"`` or ``"versions"`` — the folder's, or for a flat movie
|
||
what the next added medium will become."""
|
||
return "parts" if self.mode_parts.isChecked() else "versions"
|
||
|
||
def _selected_media(self) -> File | None:
|
||
row = self.versions_table.currentRow()
|
||
if row < 0 or row >= len(self._versions):
|
||
return None
|
||
return self._versions[row]
|
||
|
||
def _after_media_change(self, select: File | None = None) -> None:
|
||
self._reload_versions(select)
|
||
self._reload_subtitles()
|
||
self._refresh_identity_fields()
|
||
|
||
def _on_mode_clicked(self, _button) -> None:
|
||
"""Switch a folder between versions and parts (a flat movie only
|
||
remembers the choice for the next added medium)."""
|
||
current = self.filehandler.movie_group_mode(self.file_obj)
|
||
wanted = self._effective_mode()
|
||
if current == "flat" or current == wanted:
|
||
self._reload_versions()
|
||
return
|
||
try:
|
||
if wanted == "parts":
|
||
order = "\n".join(
|
||
f" díl {n}: {f.file_path.name}"
|
||
for n, f in enumerate(self._versions, 1)
|
||
)
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Přepnout na díly",
|
||
f"Soubory se přejmenují na díly v tomto pořadí (pak jde "
|
||
f"pořadí změnit šipkami):\n{order}",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
self._reload_versions()
|
||
return
|
||
self.filehandler.set_movie_group_mode(self.file_obj, "parts")
|
||
else:
|
||
labels: list[str] = []
|
||
for f in self._versions[1:]:
|
||
dialog = VersionLabelDialog(
|
||
self,
|
||
intro=f"Popisek verze pro „{f.file_path.name}“ "
|
||
f"(první soubor bude výchozí verze).",
|
||
)
|
||
if (
|
||
dialog.exec() != QDialog.DialogCode.Accepted
|
||
or not dialog.label()
|
||
):
|
||
self._reload_versions()
|
||
return
|
||
labels.append(dialog.label())
|
||
self.filehandler.set_movie_group_mode(self.file_obj, "versions", labels)
|
||
except (ValueError, OSError) as exc:
|
||
QMessageBox.warning(self, "Režim složky", str(exc))
|
||
self._after_media_change()
|
||
|
||
def _add_media(self) -> None:
|
||
"""Add one more file — as a part or a version, by the chosen mode."""
|
||
path, _ = QFileDialog.getOpenFileName(self, "Vyber další médium filmu")
|
||
if not path:
|
||
return
|
||
try:
|
||
if self._effective_mode() == "parts":
|
||
added = self.filehandler.add_movie_part(self.file_obj, Path(path))
|
||
else:
|
||
dialog = VersionLabelDialog(self)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
added = self.filehandler.add_movie_version(
|
||
self.file_obj, Path(path), dialog.label()
|
||
)
|
||
except (ValueError, RuntimeError, FileExistsError) as exc:
|
||
QMessageBox.warning(self, "Média", f"Nelze přidat: {exc}")
|
||
return
|
||
self._after_media_change(added)
|
||
|
||
def _move_part(self, delta: int) -> None:
|
||
part = self._selected_media()
|
||
if part is None:
|
||
QMessageBox.information(self, "Díly", "Vyber díl k posunutí.")
|
||
return
|
||
try:
|
||
self.filehandler.move_movie_part(part, delta)
|
||
except (ValueError, OSError) as exc:
|
||
QMessageBox.warning(self, "Díly", str(exc))
|
||
return
|
||
self._after_media_change(part)
|
||
|
||
def _rename_version(self) -> None:
|
||
version = self._selected_media()
|
||
if version is None:
|
||
QMessageBox.information(self, "Verze", "Vyber verzi.")
|
||
return
|
||
if self.filehandler.movie_group_mode(self.file_obj) != "versions":
|
||
return
|
||
label_item = self.versions_table.item(self.versions_table.currentRow(), 2)
|
||
dialog = VersionLabelDialog(
|
||
self, initial=label_item.text() if label_item else ""
|
||
)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
try:
|
||
self.filehandler.rename_movie_version(version, dialog.label())
|
||
except (ValueError, FileExistsError, OSError) as exc:
|
||
QMessageBox.warning(self, "Verze", f"Nelze přejmenovat verzi: {exc}")
|
||
return
|
||
self._after_media_change(version)
|
||
|
||
def _remove_version(self) -> None:
|
||
version = self._selected_media()
|
||
if version is None:
|
||
QMessageBox.information(self, "Média", "Vyber médium k odebrání.")
|
||
return
|
||
if len(self._versions) < 2:
|
||
QMessageBox.information(
|
||
self,
|
||
"Média",
|
||
"Film má jen tenhle jeden soubor — celý film "
|
||
"odebereš z hlavní tabulky (Odebrat z poolu).",
|
||
)
|
||
return
|
||
answer = QMessageBox.question(
|
||
self, "Odebrat médium", f"Smazat soubor „{version.file_path.name}“?"
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
# The dialog may be open on the very file being removed (it opens on
|
||
# the movie's primary version) — carry on with a sibling instead. All
|
||
# versions share one movie record, so nothing else in the dialog changes.
|
||
sibling = next(v for v in self._versions if v is not version)
|
||
try:
|
||
self.filehandler.remove_movie_version(version)
|
||
except ValueError as exc:
|
||
QMessageBox.warning(self, "Média", str(exc))
|
||
return
|
||
if version is self.file_obj or version.file_path == self.file_obj.file_path:
|
||
self.file_obj = sibling
|
||
self._after_media_change()
|
||
|
||
def _lookup(self) -> None:
|
||
from src.core import tmdb
|
||
|
||
if not tmdb.api_available():
|
||
QMessageBox.warning(self, "TMDb", "Není nastaven TMDB_API_KEY (.env).")
|
||
return
|
||
try:
|
||
if self.tmdb_edit.text().strip().isdigit():
|
||
hit = tmdb.lookup_movie(int(self.tmdb_edit.text().strip()))
|
||
else:
|
||
hits = tmdb.search_movie(
|
||
self.title_edit.text(),
|
||
year=self.year_edit.text().strip() or None,
|
||
limit=1,
|
||
)
|
||
hit = hits[0] if hits else None
|
||
except tmdb.TmdbError as exc:
|
||
QMessageBox.warning(self, "TMDb", str(exc))
|
||
return
|
||
if hit is None:
|
||
QMessageBox.information(self, "TMDb", "Nic nenalezeno.")
|
||
return
|
||
self.tmdb_edit.setText(str(hit.tmdb_id))
|
||
self.title_edit.setText(hit.title)
|
||
if hit.year:
|
||
self.year_edit.setText(hit.year)
|
||
|
||
# --- getters consumed by the handler ---
|
||
@property
|
||
def title(self) -> str:
|
||
return self.title_edit.text().strip()
|
||
|
||
@property
|
||
def year(self) -> str:
|
||
return self.year_edit.text().strip()
|
||
|
||
@property
|
||
def tmdb_id(self) -> str:
|
||
return self.tmdb_edit.text().strip()
|
||
|
||
@property
|
||
def library(self) -> str:
|
||
return self.library_combo.currentText()
|
||
|
||
@property
|
||
def date(self) -> str:
|
||
return self.date_edit.text().strip()
|
||
|
||
@property
|
||
def csfd_url(self) -> str:
|
||
return self.csfd_edit.text().strip()
|
||
|
||
@property
|
||
def format_3d(self) -> str:
|
||
"""Selected 3D tag, or ``""`` for "— žádný —" (clears any existing tag)."""
|
||
return self.format3d_combo.currentData() or ""
|
||
|
||
|
||
class SeriesEditDialog(QDialog):
|
||
"""Edit a series (title/year/id/library) and each episode's SxxEyy."""
|
||
|
||
def __init__(self, parent: QWidget, ser, filehandler: FileManager) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle(f"Upravit seriál — {ser.title}")
|
||
self.setMinimumSize(640, 520)
|
||
self.series = ser
|
||
self._spins: list[tuple] = [] # (episode, season_spin, episode_spin)
|
||
|
||
layout = QVBoxLayout(self)
|
||
form = QFormLayout()
|
||
self.title_edit = QLineEdit(ser.title)
|
||
form.addRow("Název:", self.title_edit)
|
||
self.year_edit = QLineEdit(ser.year or "")
|
||
self.year_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), self.year_edit)
|
||
)
|
||
form.addRow("Rok:", self.year_edit)
|
||
self.tmdb_edit = QLineEdit(str(ser.tmdb_id or ""))
|
||
self.tmdb_edit.setValidator(
|
||
QRegularExpressionValidator(QRegularExpression(r"\d{0,9}"), self.tmdb_edit)
|
||
)
|
||
tmdb_row = QHBoxLayout()
|
||
tmdb_row.addWidget(self.tmdb_edit)
|
||
lookup_btn = QPushButton("🔎 Dohledat")
|
||
lookup_btn.setToolTip(
|
||
"Doplní název a rok z TMDb — podle TMDb ID, bez něj podle názvu."
|
||
)
|
||
lookup_btn.clicked.connect(self._lookup)
|
||
tmdb_row.addWidget(lookup_btn)
|
||
form.addRow("TMDb ID:", tmdb_row)
|
||
# One-off: some "series" exist on TMDb only as a movie (a miniseries cut
|
||
# together, a documentary in parts, …). The id is then a *movie* id —
|
||
# good for pulling the title/year once, but useless (and misleading)
|
||
# in the series folder name, where Jellyfin's TV provider would look
|
||
# it up as a TV id and match some unrelated show.
|
||
self.movie_id_check = QCheckBox("Metadata projednou z ID filmu (ne seriálu)")
|
||
self.movie_id_check.setToolTip(
|
||
"TMDb vede tohle jako film, ne seriál. „Dohledat“ pak použije filmové "
|
||
"ID / vyhledávání filmů. Filmové ID se do názvu složky seriálu "
|
||
"neuloží — Jellyfin by ho hledal jako ID seriálu a spároval něco "
|
||
"jiného; seriál pak spáruje podle názvu a roku."
|
||
)
|
||
form.addRow("", self.movie_id_check)
|
||
self.library_combo = QComboBox()
|
||
self.library_combo.addItems(filehandler.series_libraries)
|
||
i = self.library_combo.findText(ser.library)
|
||
if i >= 0:
|
||
self.library_combo.setCurrentIndex(i)
|
||
form.addRow("Knihovna:", self.library_combo)
|
||
layout.addLayout(form)
|
||
|
||
layout.addWidget(QLabel("Epizody — uprav sezónu/díl (uloží se jako SxxEyy):"))
|
||
table = QTableWidget(0, 3)
|
||
table.setHorizontalHeaderLabels(["Soubor", "Sezóna", "Díl"])
|
||
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
for ep in ser.episodes:
|
||
row = table.rowCount()
|
||
table.insertRow(row)
|
||
name_item = QTableWidgetItem(ep.path.name)
|
||
name_item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||
table.setItem(row, 0, name_item)
|
||
season_spin = QSpinBox()
|
||
season_spin.setRange(0, 99)
|
||
season_spin.setValue(ep.season if ep.season is not None else 1)
|
||
table.setCellWidget(row, 1, season_spin)
|
||
ep_spin = QSpinBox()
|
||
ep_spin.setRange(0, 999)
|
||
ep_spin.setValue(ep.episode if ep.episode is not None else 1)
|
||
table.setCellWidget(row, 2, ep_spin)
|
||
self._spins.append((ep, season_spin, ep_spin))
|
||
layout.addWidget(table)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
def _lookup(self) -> None:
|
||
"""Fill title + year from TMDb — by the typed id, else by the title.
|
||
|
||
Uses the movie endpoints instead of the TV ones while "Metadata
|
||
projednou z ID filmu" is ticked.
|
||
"""
|
||
from src.core import tmdb
|
||
|
||
if not tmdb.api_available():
|
||
QMessageBox.warning(self, "TMDb", "Není nastaven TMDB_API_KEY (.env).")
|
||
return
|
||
as_movie = self.movie_id_check.isChecked()
|
||
typed_id = self.tmdb_edit.text().strip()
|
||
try:
|
||
if typed_id.isdigit():
|
||
lookup = tmdb.lookup_movie if as_movie else tmdb.lookup_tv
|
||
hit = lookup(int(typed_id))
|
||
else:
|
||
search = tmdb.search_movie if as_movie else tmdb.search_tv
|
||
hits = search(
|
||
self.title_edit.text(),
|
||
year=self.year_edit.text().strip() or None,
|
||
limit=1,
|
||
)
|
||
hit = hits[0] if hits else None
|
||
except tmdb.TmdbError as exc:
|
||
QMessageBox.warning(self, "TMDb", str(exc))
|
||
return
|
||
if hit is None:
|
||
QMessageBox.information(
|
||
self, "TMDb", "Film nenalezen." if as_movie else "Seriál nenalezen."
|
||
)
|
||
return
|
||
self.title_edit.setText(hit.title)
|
||
if hit.year:
|
||
self.year_edit.setText(hit.year)
|
||
if not typed_id:
|
||
self.tmdb_edit.setText(str(hit.tmdb_id))
|
||
|
||
def accept(self) -> None:
|
||
if not self.title_edit.text().strip():
|
||
QMessageBox.warning(self, "Seriál", "Zadej název seriálu.")
|
||
return
|
||
super().accept()
|
||
|
||
def episode_numbers(self) -> list[tuple]:
|
||
return [(ep, s.value(), e.value()) for ep, s, e in self._spins]
|
||
|
||
@property
|
||
def title(self) -> str:
|
||
return self.title_edit.text().strip()
|
||
|
||
@property
|
||
def year(self) -> str:
|
||
return self.year_edit.text().strip()
|
||
|
||
@property
|
||
def tmdb_id(self) -> str:
|
||
"""The TMDb TV id for the series folder name.
|
||
|
||
Empty while "Metadata projednou z ID filmu" is ticked: the typed id is
|
||
then a *movie* id, and Jellyfin would look it up as a TV id and match
|
||
an unrelated show — without it, Jellyfin matches by title and year.
|
||
"""
|
||
if self.movie_id_check.isChecked():
|
||
return ""
|
||
return self.tmdb_edit.text().strip()
|
||
|
||
@property
|
||
def library(self) -> str:
|
||
return self.library_combo.currentText()
|
||
|
||
|
||
class LibraryOrganizerDialog(QDialog):
|
||
"""Manage the pool's libraries (the top-level folders): name + kind."""
|
||
|
||
KINDS: ClassVar[list[tuple[str, str]]] = [
|
||
("Filmy (jednotlivé filmy)", "movie"),
|
||
("Seriály", "series"),
|
||
("Mix (filmy + seriály)", "mixed"),
|
||
("Domácí videa (bez TMDb)", "homevideo"),
|
||
]
|
||
|
||
def __init__(self, parent: QWidget, libraries: list[dict]) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Knihovny poolu")
|
||
self.setMinimumSize(560, 420)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(
|
||
QLabel(
|
||
"Knihovny = složky v kořeni poolu, každá je jedna knihovna v Jellyfinu.\n"
|
||
"Typ určuje, jak Curator složku zpracuje."
|
||
)
|
||
)
|
||
|
||
self.table = QTableWidget(0, 2)
|
||
self.table.setHorizontalHeaderLabels(["Název složky", "Typ"])
|
||
self.table.horizontalHeader().setSectionResizeMode(
|
||
0, QHeaderView.ResizeMode.Stretch
|
||
)
|
||
self.table.horizontalHeader().setSectionResizeMode(
|
||
1, QHeaderView.ResizeMode.ResizeToContents
|
||
)
|
||
layout.addWidget(self.table)
|
||
for lib in libraries:
|
||
self._add_row(lib.get("name", ""), lib.get("kind", "movie"))
|
||
|
||
row = QHBoxLayout()
|
||
add_btn = QPushButton("➕ Přidat knihovnu")
|
||
add_btn.clicked.connect(lambda: self._add_row("", "movie"))
|
||
del_btn = QPushButton("➖ Odebrat")
|
||
del_btn.clicked.connect(self._remove_row)
|
||
row.addWidget(add_btn)
|
||
row.addWidget(del_btn)
|
||
row.addStretch(1)
|
||
layout.addLayout(row)
|
||
|
||
note = QLabel(
|
||
"Pozn.: „Mix“ zpracuje filmy (v kořeni) i seriály (v podsložkách); "
|
||
"„Domácí videa“ přibudou v další fázi. Po uložení nabídnu smazání "
|
||
"prázdných složek, které už nejsou knihovnou (s obsahem nechám být)."
|
||
)
|
||
note.setWordWrap(True)
|
||
note.setStyleSheet("color: #888;")
|
||
layout.addWidget(note)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
def _add_row(self, name: str, kind: str) -> None:
|
||
row = self.table.rowCount()
|
||
self.table.insertRow(row)
|
||
self.table.setItem(row, 0, QTableWidgetItem(name))
|
||
combo = QComboBox()
|
||
for label, value in self.KINDS:
|
||
combo.addItem(label, value)
|
||
idx = next((i for i, (_l, v) in enumerate(self.KINDS) if v == kind), 0)
|
||
combo.setCurrentIndex(idx)
|
||
self.table.setCellWidget(row, 1, combo)
|
||
|
||
def _remove_row(self) -> None:
|
||
row = self.table.currentRow()
|
||
if row >= 0:
|
||
self.table.removeRow(row)
|
||
|
||
def libraries(self) -> list[dict]:
|
||
result: list[dict] = []
|
||
for row in range(self.table.rowCount()):
|
||
name_item = self.table.item(row, 0)
|
||
name = name_item.text().strip() if name_item else ""
|
||
if not name:
|
||
continue
|
||
combo = cast(QComboBox, self.table.cellWidget(row, 1))
|
||
result.append({"name": name, "kind": combo.currentData()})
|
||
return result
|
||
|
||
|
||
class MinidlnaLibrariesDialog(QDialog):
|
||
"""Choose which pool libraries feed miniDLNA generation."""
|
||
|
||
def __init__(
|
||
self,
|
||
parent: QWidget,
|
||
libraries: list[dict],
|
||
selected: list[str] | None,
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Knihovny pro generování miniDLNA")
|
||
self.setMinimumSize(420, 360)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(
|
||
QLabel(
|
||
"Které knihovny se mají promítnout do miniDLNA stromu (žánry, Tipy "
|
||
"dne, Nově přidané, zrcadlené seriály)?"
|
||
)
|
||
)
|
||
warn = QLabel(
|
||
"⚠️ Odškrtnutá knihovna: její odkazy ve stromu se při dalším "
|
||
"generování odeberou jako zastaralé, dokud ji znovu nezaškrtneš."
|
||
)
|
||
warn.setWordWrap(True)
|
||
warn.setStyleSheet("color: #c0392b;")
|
||
layout.addWidget(warn)
|
||
|
||
self.list = QListWidget()
|
||
chosen = set(selected) if selected is not None else None # None = all
|
||
for lib in libraries:
|
||
item = QListWidgetItem(f"{lib['name']} ({lib['kind']})")
|
||
item.setData(Qt.ItemDataRole.UserRole, lib["name"])
|
||
item.setCheckState(
|
||
Qt.CheckState.Checked
|
||
if chosen is None or lib["name"] in chosen
|
||
else Qt.CheckState.Unchecked
|
||
)
|
||
self.list.addItem(item)
|
||
layout.addWidget(self.list)
|
||
|
||
quick = QHBoxLayout()
|
||
all_btn = QPushButton("Vše")
|
||
all_btn.clicked.connect(lambda: self._set_all(Qt.CheckState.Checked))
|
||
none_btn = QPushButton("Nic")
|
||
none_btn.clicked.connect(lambda: self._set_all(Qt.CheckState.Unchecked))
|
||
quick.addWidget(all_btn)
|
||
quick.addWidget(none_btn)
|
||
quick.addStretch(1)
|
||
layout.addLayout(quick)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
def _set_all(self, state) -> None:
|
||
for i in range(self.list.count()):
|
||
self.list.item(i).setCheckState(state)
|
||
|
||
def selected_libraries(self) -> list[str]:
|
||
return [
|
||
self.list.item(i).data(Qt.ItemDataRole.UserRole)
|
||
for i in range(self.list.count())
|
||
if self.list.item(i).checkState() == Qt.CheckState.Checked
|
||
]
|
||
|
||
|
||
class MigrateDialog(QDialog):
|
||
"""Review TMDb match proposals for an existing pool and apply the ids."""
|
||
|
||
_STATUS: ClassVar[dict[str, str]] = {
|
||
"auto": "jisté",
|
||
"ambiguous": "nejednoznačné",
|
||
"no_match": "bez shody",
|
||
"error": "chyba",
|
||
}
|
||
|
||
def __init__(
|
||
self, parent: QWidget, filehandler: FileManager, proposals: list
|
||
) -> None:
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Spárovat pool s TMDb (migrace)")
|
||
self.setMinimumSize(820, 560)
|
||
self.filehandler = filehandler
|
||
self._rows: list[tuple] = [] # (proposal, combo | None)
|
||
|
||
counts: dict[str, int] = {}
|
||
for p in proposals:
|
||
counts[p.status] = counts.get(p.status, 0) + 1
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(
|
||
QLabel(
|
||
"Návrhy párování (zelené jisté jsou předvybrané). Zkontroluj, uprav "
|
||
"u nejednoznačných a přejmenuj. "
|
||
+ " · ".join(
|
||
f"{self._STATUS.get(k, k)}: {v}" for k, v in sorted(counts.items())
|
||
)
|
||
)
|
||
)
|
||
|
||
table = QTableWidget(len(proposals), 3)
|
||
table.setHorizontalHeaderLabels(["Soubor", "Stav", "Kandidát z TMDb"])
|
||
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
for row, p in enumerate(proposals):
|
||
name_item = QTableWidgetItem(p.current_name)
|
||
name_item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||
if getattr(p, "note", ""):
|
||
name_item.setToolTip(p.note)
|
||
name_item.setForeground(Qt.GlobalColor.darkBlue)
|
||
table.setItem(row, 0, name_item)
|
||
status_item = QTableWidgetItem(self._STATUS.get(p.status, p.status))
|
||
status_item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||
if p.status == "auto":
|
||
status_item.setForeground(Qt.GlobalColor.darkGreen)
|
||
elif p.status in ("no_match", "error"):
|
||
status_item.setForeground(Qt.GlobalColor.red)
|
||
table.setItem(row, 1, status_item)
|
||
|
||
if p.candidates:
|
||
combo = QComboBox()
|
||
for c in p.candidates:
|
||
combo.addItem(f"{c.title} ({c.year or '?'}) · tmdb {c.tmdb_id}", c)
|
||
combo.addItem("(přeskočit)", None)
|
||
if p.chosen is not None:
|
||
idx = next(
|
||
(
|
||
i
|
||
for i, c in enumerate(p.candidates)
|
||
if c.tmdb_id == p.chosen.tmdb_id
|
||
),
|
||
0,
|
||
)
|
||
combo.setCurrentIndex(idx)
|
||
if p.status != "auto":
|
||
combo.setCurrentIndex(combo.count() - 1) # default: skip
|
||
table.setCellWidget(row, 2, combo)
|
||
self._rows.append((p, combo))
|
||
else:
|
||
cell = QTableWidgetItem(p.error or "nic nenalezeno")
|
||
cell.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||
table.setItem(row, 2, cell)
|
||
self._rows.append((p, None))
|
||
layout.addWidget(table)
|
||
|
||
btns = QHBoxLayout()
|
||
auto_btn = QPushButton("Přejmenovat jen jisté")
|
||
auto_btn.clicked.connect(lambda: self._apply(only_auto=True))
|
||
btns.addWidget(auto_btn)
|
||
sel_btn = QPushButton("Přejmenovat vybrané")
|
||
sel_btn.clicked.connect(lambda: self._apply(only_auto=False))
|
||
btns.addWidget(sel_btn)
|
||
btns.addStretch(1)
|
||
close_btn = QPushButton("Zavřít")
|
||
close_btn.clicked.connect(self.accept)
|
||
btns.addWidget(close_btn)
|
||
layout.addLayout(btns)
|
||
|
||
def _apply(self, only_auto: bool) -> None:
|
||
done = 0
|
||
errors: list[str] = []
|
||
for proposal, combo in self._rows:
|
||
if only_auto and proposal.status != "auto":
|
||
continue
|
||
candidate = combo.currentData() if combo is not None else None
|
||
if candidate is None:
|
||
continue
|
||
try:
|
||
self.filehandler.assign_tmdb_id(
|
||
proposal.file, candidate.tmdb_id, candidate.year
|
||
)
|
||
done += 1
|
||
except (ValueError, FileExistsError, OSError) as exc:
|
||
errors.append(f"{proposal.current_name}: {exc}")
|
||
msg = f"Přejmenováno {done} filmů."
|
||
if errors:
|
||
msg += "\n\nChyby:\n" + "\n".join(errors[:8])
|
||
QMessageBox.warning(self, "Migrace s chybami", msg)
|
||
else:
|
||
QMessageBox.information(self, "Migrace", msg)
|
||
self.accept()
|
||
|
||
|
||
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.StandardButton.Ok | QDialogButtonBox.StandardButton.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.ItemFlag.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.CheckState.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.CheckState.Checked
|
||
elif have == 0:
|
||
state = Qt.CheckState.Unchecked
|
||
else:
|
||
state = Qt.CheckState.PartiallyChecked
|
||
item = QTreeWidgetItem([name])
|
||
item.setFlags(
|
||
Qt.ItemFlag.ItemIsUserCheckable
|
||
| Qt.ItemFlag.ItemIsEnabled
|
||
| Qt.ItemFlag.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.CheckState.Checked: 1,
|
||
Qt.CheckState.Unchecked: 0,
|
||
Qt.CheckState.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 NFO source and folder rules.
|
||
|
||
Each row defines a tag category, optionally which NFO field feeds it (with a
|
||
value transform), and how it maps into the miniDLNA tree (a grouping folder,
|
||
the output root, or no folders at all).
|
||
"""
|
||
|
||
# (label, source field) — the NFO fields a category can draw from
|
||
FIELD_CHOICES: ClassVar[list[tuple[str, str | None]]] = [
|
||
("(žádné — uživatelská)", None),
|
||
("Žánry (genres)", "genres"),
|
||
("Rok (year)", "year"),
|
||
("Země (countries)", "countries"),
|
||
("Hodnocení (rating)", "rating"),
|
||
]
|
||
TRANSFORM_CHOICES: ClassVar[list[tuple[str, str | None]]] = [
|
||
("(žá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 NFO 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",
|
||
"Zdroj (NFO)",
|
||
"Transform",
|
||
"Tvořit složky",
|
||
"Složka",
|
||
"Šablona názvu",
|
||
]
|
||
)
|
||
hdr = self.table.horizontalHeader()
|
||
hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||
hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
hdr.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||
hdr.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||
hdr.setSectionResizeMode(5, QHeaderView.ResizeMode.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.StandardButton.Ok | QDialogButtonBox.StandardButton.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 = cast(QLineEdit, self.table.cellWidget(row, 0)).text().strip()
|
||
if not category:
|
||
continue
|
||
make_folders = cast(QCheckBox, self.table.cellWidget(row, 3)).isChecked()
|
||
folder = cast(QLineEdit, self.table.cellWidget(row, 4)).text().strip()
|
||
template = cast(QLineEdit, self.table.cellWidget(row, 5)).text().strip()
|
||
schema.append(
|
||
{
|
||
"category": category,
|
||
"csfd_field": cast(
|
||
QComboBox, self.table.cellWidget(row, 1)
|
||
).currentData(),
|
||
"transform": cast(
|
||
QComboBox, 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í:</b> "
|
||
f"{str(avg) + ' %' if avg is not None else '—'}<br>"
|
||
f"<b>S NFO metadaty:</b> {stats['with_nfo']} · "
|
||
f"<b>bez:</b> {stats['without_nfo']}<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.StandardButton.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.row_items: dict[int, object] = {} # table row -> File or series.Series
|
||
self.series_list: list = [] # cached series (movies live in filehandler.filelist)
|
||
# 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} — Jellyfin knihovna")
|
||
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 ValueError, AttributeError:
|
||
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.reload_series()
|
||
self.refresh_sidebar()
|
||
self.refresh_table()
|
||
|
||
def reload_series(self) -> None:
|
||
"""Refresh the cached series list (scans the series/mixed libraries)."""
|
||
self.series_list = (
|
||
self.filehandler.load_series() if self.filehandler.pool_dir else []
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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)
|
||
self._add_action(pool_menu, "Knihovny…", self.edit_libraries)
|
||
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, "Upravit film…", self.edit_movie, "Return")
|
||
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
|
||
self._add_action(movie_menu, "Přesunout do knihovny…", self.move_to_library)
|
||
self._add_action(
|
||
movie_menu,
|
||
"Přejmenovat dle metadat (rok)…",
|
||
self.rename_to_canonical_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, "Načíst žánry z TMDb", self.refresh_from_tmdb)
|
||
movie_menu.addSeparator()
|
||
self._add_action(movie_menu, "Odebrat z poolu…", self.remove_movies, "Del")
|
||
|
||
series_menu = bar.addMenu("&Seriály")
|
||
self._add_action(series_menu, "Importovat epizody…", self.import_episodes)
|
||
self._add_action(series_menu, "Přehled seriálů…", self.show_series)
|
||
self._add_action(series_menu, "Napravit názvy souborů…", self.rename_all_series)
|
||
self._add_action(series_menu, "Odebrat z poolu…", lambda: self.remove_series())
|
||
|
||
film_menu = bar.addMenu("&miniDLNA")
|
||
self._add_action(
|
||
film_menu, "Knihovny pro generování…", self.select_minidlna_libraries
|
||
)
|
||
self._add_action(
|
||
film_menu, "Vygenerovat miniDLNA strom", self.generate_filmoteka, "Ctrl+G"
|
||
)
|
||
|
||
jellyfin_menu = bar.addMenu("&Jellyfin")
|
||
self._add_action(jellyfin_menu, "Skenovat knihovnu", self.jellyfin_scan)
|
||
self._add_action(jellyfin_menu, "Ověřit proti poolu…", self.jellyfin_verify)
|
||
self._add_action(
|
||
jellyfin_menu, "Zkontrolovat připojení", self.jellyfin_check_connection
|
||
)
|
||
|
||
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)
|
||
|
||
tools_menu = bar.addMenu("&Nástroje")
|
||
self._add_action(
|
||
tools_menu, "Spárovat pool s TMDb (migrace)…", self.migrate_pool
|
||
)
|
||
self._add_action(tools_menu, "Kontrola duplicit…", self.check_duplicates)
|
||
self._add_action(
|
||
tools_menu, "Sjednotit názvy výchozích verzí…", self.normalize_default_names
|
||
)
|
||
|
||
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.Orientation.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("Knihovna:"))
|
||
self.library_filter = QComboBox()
|
||
self.library_filter.currentIndexChanged.connect(self.refresh_table)
|
||
search_row.addWidget(self.library_filter)
|
||
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, 5)
|
||
self.table.setHorizontalHeaderLabels(
|
||
["Název", "Typ", "Knihovna", "Štítky", "Velikost"]
|
||
)
|
||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self.table.customContextMenuRequested.connect(self._show_table_menu)
|
||
self.table.doubleClicked.connect(lambda _: self.edit_item())
|
||
self.table.itemSelectionChanged.connect(self._update_selection_status)
|
||
header = self.table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Název
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Typ
|
||
header.setSectionResizeMode(
|
||
2, QHeaderView.ResizeMode.ResizeToContents
|
||
) # Knihovna
|
||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) # Štítky
|
||
main_layout.addWidget(self.table)
|
||
self._refresh_library_filter()
|
||
splitter.addWidget(main)
|
||
|
||
# Right — details of the selected movie
|
||
inspector = QWidget()
|
||
ins_layout = QVBoxLayout(inspector)
|
||
ins_layout.setContentsMargins(8, 8, 8, 8)
|
||
ins_layout.addWidget(QLabel("<b>Detail filmu</b>"))
|
||
form = QFormLayout()
|
||
self.d_filename = QLabel("—")
|
||
self.d_filename.setWordWrap(True)
|
||
self.d_filename.setTextInteractionFlags(
|
||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||
)
|
||
self.d_title = QLabel("—")
|
||
self.d_title.setWordWrap(True)
|
||
self.d_library = QLabel("—")
|
||
self.d_size = QLabel("—")
|
||
form.addRow("Soubor:", self.d_filename)
|
||
form.addRow("Český název:", self.d_title)
|
||
form.addRow("Knihovna:", self.d_library)
|
||
form.addRow("Velikost:", self.d_size)
|
||
ins_layout.addLayout(form)
|
||
ins_layout.addStretch(1)
|
||
splitter.addWidget(inspector)
|
||
|
||
splitter.setStretchFactor(0, 0)
|
||
splitter.setStretchFactor(1, 1)
|
||
splitter.setStretchFactor(2, 0)
|
||
splitter.setSizes([230, 730, 260])
|
||
self.setCentralWidget(splitter)
|
||
|
||
def _update_details(self) -> None:
|
||
"""Fill the right panel from the single selected item (movie or series)."""
|
||
if not hasattr(self, "d_filename"):
|
||
return
|
||
items = self._selected_items()
|
||
if len(items) != 1:
|
||
for label in (self.d_filename, self.d_title, self.d_library, self.d_size):
|
||
label.setText("—")
|
||
return
|
||
item = items[0]
|
||
if isinstance(item, File):
|
||
if self.filehandler.is_movie_group_member(item):
|
||
count = len(self.filehandler.list_movie_versions(item))
|
||
self.d_filename.setText(
|
||
f"{item.file_path.parent.name}/ "
|
||
f"({count} {self._versions_word(count)})"
|
||
)
|
||
else:
|
||
self.d_filename.setText(item.filename)
|
||
self.d_title.setText(item.title or "—")
|
||
self.d_library.setText(self.filehandler.library_of(item) or "—")
|
||
else: # series.Series
|
||
self.d_filename.setText(
|
||
f"{item.folder.name} ({len(item.episodes)} epizod)"
|
||
)
|
||
self.d_title.setText(item.title or "—")
|
||
self.d_library.setText(item.library)
|
||
self.d_size.setText(self._item_size(item))
|
||
|
||
def _build_statusbar(self) -> None:
|
||
self.status = self.statusBar()
|
||
# Pool / Filmotéka paths + Jellyfin state as permanent widgets on the
|
||
# right so they stay visible while the transient area shows counts.
|
||
self.jellyfin_label = QLabel()
|
||
self.pool_label = QLabel()
|
||
self.output_label = QLabel()
|
||
self.status.addPermanentWidget(self.jellyfin_label)
|
||
self.status.addPermanentWidget(self.pool_label)
|
||
self.status.addPermanentWidget(self.output_label)
|
||
self._update_path_status()
|
||
self._update_jellyfin_status(None)
|
||
|
||
def _update_jellyfin_status(self, version: str | None) -> None:
|
||
"""Show Jellyfin connection state; None = not checked / not configured."""
|
||
from src.core import jellyfin
|
||
|
||
if not jellyfin.api_available():
|
||
self.jellyfin_label.setText("Jellyfin: ⚙ nenastaveno")
|
||
elif version is None:
|
||
self.jellyfin_label.setText("Jellyfin: ? nezkontrolováno")
|
||
else:
|
||
self.jellyfin_label.setText(f"Jellyfin: ● {version}")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Sidebar (tag filter)
|
||
# ------------------------------------------------------------------
|
||
|
||
def refresh_sidebar(self, filtered: list[File] | None = 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)
|
||
if cat is not None:
|
||
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.ItemFlag.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.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled
|
||
)
|
||
checked = tag.full_path in self._active_filter
|
||
item.setCheckState(
|
||
0, Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
|
||
)
|
||
item.setData(0, Qt.ItemDataRole.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.ItemDataRole.UserRole)
|
||
if full_path is None:
|
||
return # category header row, not a tag
|
||
if item.checkState(0) == Qt.CheckState.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_library_filter(self) -> None:
|
||
"""Repopulate the library filter combo, keeping the current choice."""
|
||
if not hasattr(self, "library_filter"):
|
||
return
|
||
current = self.library_filter.currentText()
|
||
self.library_filter.blockSignals(True)
|
||
self.library_filter.clear()
|
||
self.library_filter.addItem("Vše")
|
||
for name in self.filehandler.libraries_accepting_movies:
|
||
self.library_filter.addItem(name)
|
||
idx = self.library_filter.findText(current)
|
||
self.library_filter.setCurrentIndex(max(idx, 0))
|
||
self.library_filter.blockSignals(False)
|
||
|
||
def _selected_library_filter(self) -> str | None:
|
||
"""The library the table is filtered to, or None for 'Vše'."""
|
||
if not hasattr(self, "library_filter"):
|
||
return None
|
||
text = self.library_filter.currentText()
|
||
return None if text in ("", "Vše") else text
|
||
|
||
def refresh_table(self, *_args) -> None:
|
||
# Movies: tag (AND) + library + search filter. Series: library + search
|
||
# (they carry no tags, so a tag filter hides them).
|
||
tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags())
|
||
# A multi-version movie contributes only its primary version as a row
|
||
# — the rest live under "Upravit film → Verze", not as separate rows.
|
||
visible_paths = {f.file_path for f in self.filehandler.visible_movies()}
|
||
tag_filtered = [f for f in tag_filtered if f.file_path in visible_paths]
|
||
library = self._selected_library_filter()
|
||
search = self.search_edit.text().lower() if hasattr(self, "search_edit") else ""
|
||
|
||
movies = tag_filtered
|
||
if library:
|
||
movies = [f for f in movies if self.filehandler.library_of(f) == library]
|
||
if search:
|
||
movies = [f for f in movies if search in (f.title or f.filename).lower()]
|
||
|
||
series = [] if self._active_filter else list(self.series_list)
|
||
if library:
|
||
series = [s for s in series if s.library == library]
|
||
if search:
|
||
series = [s for s in series if search in s.title.lower()]
|
||
|
||
def sort_key(item):
|
||
return (item.title or getattr(item, "filename", "")).lower()
|
||
|
||
rows = sorted(movies + series, key=sort_key)
|
||
self.table.setRowCount(len(rows))
|
||
self.row_items.clear()
|
||
for row, item in enumerate(rows):
|
||
self.row_items[row] = item
|
||
self._fill_row(row, item)
|
||
|
||
self.refresh_sidebar(tag_filtered)
|
||
self._update_selection_status()
|
||
self.status.showMessage(
|
||
f"Zobrazeno {len(movies)} filmů, {len(series)} seriálů", 4000
|
||
)
|
||
|
||
@staticmethod
|
||
def _versions_word(count: int) -> str:
|
||
return "verze" if 2 <= count <= 4 else "verzí"
|
||
|
||
def _fill_row(self, row: int, item) -> None:
|
||
"""Fill one table row from a movie (File) or a series.Series.
|
||
|
||
A movie made of several media files (a version folder) is marked so it
|
||
doesn't pass for a single file: ``▣`` before the name in bold, the
|
||
version count in the Typ column, the summed size and a tooltip listing
|
||
every file.
|
||
"""
|
||
versions: list[File] = []
|
||
if isinstance(item, File):
|
||
name = item.title or item.filename
|
||
kind = "Film"
|
||
if self.filehandler.is_movie_group_member(item):
|
||
versions = self.filehandler.list_movie_versions(item)
|
||
name = f"▣ {name}"
|
||
n = len(versions)
|
||
if all(
|
||
normalize_part_label(v._identity_from_path()[4]) is not None
|
||
for v in versions
|
||
):
|
||
kind = f"Film · {n} {'díly' if 2 <= n <= 4 else 'dílů'}"
|
||
else:
|
||
kind = f"Film · {n} {self._versions_word(n)}"
|
||
values = [
|
||
name,
|
||
kind,
|
||
self.filehandler.library_of(item) or "—",
|
||
", ".join(t.name for t in item.tags),
|
||
self._item_size(item),
|
||
]
|
||
else: # series.Series
|
||
values = [
|
||
item.title,
|
||
"Seriál",
|
||
item.library,
|
||
f"{len(item.episodes)} epizod",
|
||
self._item_size(item),
|
||
]
|
||
for col, value in enumerate(values):
|
||
cell = QTableWidgetItem(value)
|
||
if versions:
|
||
if col == 0:
|
||
font = cell.font()
|
||
font.setBold(True)
|
||
cell.setFont(font)
|
||
cell.setToolTip(
|
||
"Verze filmu:\n"
|
||
+ "\n".join(f" • {v.file_path.name}" for v in versions)
|
||
)
|
||
self.table.setItem(row, col, cell)
|
||
|
||
def _item_size(self, item) -> str:
|
||
"""Formatted size of a movie (all its versions), or a series' episodes."""
|
||
try:
|
||
if isinstance(item, File):
|
||
total = sum(
|
||
v.file_path.stat().st_size
|
||
for v in self.filehandler.list_movie_versions(item)
|
||
)
|
||
else:
|
||
total = sum(ep.path.stat().st_size for ep in item.episodes)
|
||
return self._format_size(total)
|
||
except OSError:
|
||
return "?"
|
||
|
||
@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_items(self) -> list:
|
||
rows = {idx.row() for idx in self.table.selectionModel().selectedRows()}
|
||
return [self.row_items[r] for r in sorted(rows) if r in self.row_items]
|
||
|
||
def _selected_movies(self) -> list[File]:
|
||
return [i for i in self._selected_items() if isinstance(i, File)]
|
||
|
||
def _selected_series(self) -> list:
|
||
return [i for i in self._selected_items() if not isinstance(i, File)]
|
||
|
||
def edit_item(self) -> None:
|
||
"""Open the right editor for the single selected row (movie or series)."""
|
||
items = self._selected_items()
|
||
if len(items) != 1:
|
||
QMessageBox.information(self, "Upravit", "Vyber právě jednu položku.")
|
||
return
|
||
if isinstance(items[0], File):
|
||
self.edit_movie()
|
||
else:
|
||
self.edit_series(items[0])
|
||
|
||
def _show_table_menu(self, pos) -> None:
|
||
items = self._selected_items()
|
||
series = [i for i in items if not isinstance(i, File)]
|
||
menu = QMenu(self)
|
||
# Series-only selection → series actions; otherwise movie actions.
|
||
if series and not any(isinstance(i, File) for i in items):
|
||
if len(series) == 1:
|
||
menu.addAction("Upravit seriál…", lambda: self.edit_series(series[0]))
|
||
menu.addSeparator()
|
||
menu.addAction("Odebrat z poolu…", lambda: self.remove_series(series))
|
||
else:
|
||
menu.addAction("Upravit film…", self.edit_movie)
|
||
menu.addAction("Otevřít", self.open_movies)
|
||
menu.addAction("Přejmenovat…", self.rename_movie)
|
||
menu.addAction("Přesunout do knihovny…", self.move_to_library)
|
||
menu.addAction("Přiřadit štítky…", self.assign_tags)
|
||
menu.addAction("Nastavit datum…", self.set_date)
|
||
menu.addAction("Načíst žánry z TMDb", self.refresh_from_tmdb)
|
||
menu.addSeparator()
|
||
menu.addAction("Odebrat z poolu…", self.remove_movies)
|
||
menu.exec(self.table.viewport().mapToGlobal(pos))
|
||
|
||
def _update_selection_status(self) -> None:
|
||
self._update_details()
|
||
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.reload_series()
|
||
self._refresh_library_filter()
|
||
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.reload_series()
|
||
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
|
||
# NOTE: hit a native SIGSEGV here once (2026-09-13), inside Qt's own
|
||
# QFileDialog teardown (QPersistentModelIndex::~QPersistentModelIndex,
|
||
# preceded by "QAbstractItemModel::endRemoveRows: Invalid index" from
|
||
# QFileSystemModel) — a known long-standing Qt/PySide bug (Qt5 through
|
||
# 6.11), not a Curator issue; confirmed via `coredumpctl` + gdb
|
||
# backtrace, no Python frame involved. That run's control group showed
|
||
# the process launched from inside VS Code's Snap-sandboxed terminal,
|
||
# which can block D-Bus access to xdg-desktop-portal and force Qt's
|
||
# own (non-native) file dialog — the buggy implementation the trace
|
||
# actually shows in use. If this recurs, try launching Curator from a
|
||
# plain (non-snap-sandboxed) terminal first.
|
||
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, self.filehandler.libraries_accepting_movies
|
||
)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
entries = dialog.entries()
|
||
version_links = dialog.version_links()
|
||
skipped_no_tmdb = dialog.skipped_no_tmdb
|
||
if not entries:
|
||
if skipped_no_tmdb:
|
||
QMessageBox.information(
|
||
self,
|
||
"Import",
|
||
f"Přeskočeno {len(skipped_no_tmdb)} souborů bez TMDb ID, "
|
||
"nic k importu.",
|
||
)
|
||
return
|
||
move = dialog.move_files
|
||
library = dialog.selected_library
|
||
|
||
# Rows whose film (by identity — title, year, TMDb id; a 3D tag or a
|
||
# grouped folder doesn't change it) is already in the pool
|
||
def is_colliding(t: str, y: str, tid: str) -> bool:
|
||
return bool(
|
||
is_valid_year(y)
|
||
and self.filehandler.pooled_with_stem(
|
||
canonical_pool_stem(t, y, tid or None)
|
||
)
|
||
)
|
||
|
||
colliding = [
|
||
canonical_pool_stem(t, y, tid or None)
|
||
for s, t, y, tid, _c, _fmt in entries
|
||
if is_colliding(t, y, tid)
|
||
]
|
||
on_conflict = "suffix"
|
||
version_labels: dict[Path, str] = {}
|
||
if colliding:
|
||
choice = self._resolve_import_conflicts(colliding)
|
||
if choice is None:
|
||
return # user cancelled the whole import
|
||
on_conflict = choice
|
||
if on_conflict == "version":
|
||
# Each already-pooled film's new file gets its own popisek.
|
||
for s, t, y, tid, _c, fmt in entries:
|
||
if not is_colliding(t, y, tid):
|
||
continue
|
||
label_dialog = VersionLabelDialog(
|
||
self,
|
||
initial=f"3D {fmt}" if fmt else "",
|
||
intro=f"„{s.name}“ → nová verze filmu „{t}“, který už v poolu je.",
|
||
)
|
||
if label_dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return # cancelled — nothing imported yet
|
||
version_labels[s] = label_dialog.label()
|
||
|
||
imported: list[File] = []
|
||
imported_by_source: dict[Path, File] = {}
|
||
skipped = 0
|
||
errors: list[str] = []
|
||
for source, title, year, tmdb_id, csfd_url, format_3d in entries:
|
||
try:
|
||
movie = self.filehandler.import_movie(
|
||
source,
|
||
title,
|
||
move=move,
|
||
on_conflict=on_conflict,
|
||
year=year or None,
|
||
tmdb_id=tmdb_id or None,
|
||
library=library,
|
||
csfd_url=csfd_url or None,
|
||
format_3d=format_3d,
|
||
version_label=version_labels.get(source),
|
||
)
|
||
if movie is None:
|
||
skipped += 1
|
||
else:
|
||
imported.append(movie)
|
||
imported_by_source[source] = movie
|
||
except Exception as exc: # noqa: BLE001 — surface per-file import failures
|
||
errors.append(f"{source.name}: {exc}")
|
||
|
||
# Rows marked "Přiřadit jako verzi…" become another version of the
|
||
# film their target row just landed as, instead of a film of their own.
|
||
total = len(entries) + len(version_links)
|
||
for source, (target_source, label) in version_links.items():
|
||
target = imported_by_source.get(target_source)
|
||
if target is None:
|
||
errors.append(
|
||
f"{source.name}: hlavní verze ({target_source.name}) se "
|
||
"nenaimportovala, tahle verze se přeskakuje."
|
||
)
|
||
skipped += 1
|
||
continue
|
||
try:
|
||
imported.append(
|
||
self.filehandler.add_movie_version(target, source, label, move=move)
|
||
)
|
||
except (ValueError, RuntimeError, FileExistsError) as exc:
|
||
errors.append(f"{source.name}: {exc}")
|
||
|
||
self.refresh_table()
|
||
|
||
verb = "Přesunuto" if move else "Zkopírováno"
|
||
summary = f"{verb} {len(imported)}/{total} filmů."
|
||
if skipped_no_tmdb:
|
||
summary += f" Přeskočeno {len(skipped_no_tmdb)} bez TMDb ID."
|
||
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 what to do with films already in the pool. Returns the policy
|
||
for :meth:`FileManager.import_movie` — ``"version"`` (add the new file
|
||
as another version of that film), ``"replace"`` (the whole old film
|
||
goes, then the new one is imported) or ``"skip"`` — or ``None`` when
|
||
the user cancels the import.
|
||
|
||
There's deliberately no "keep both as separate films": these share a
|
||
TMDb id, so they would share one movie record anyway — the same film
|
||
twice is a duplicate, and a duplicate is what *versions* are for.
|
||
"""
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Icon.Question)
|
||
box.setWindowTitle("Film už v poolu je")
|
||
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"Tyhle filmy už v poolu jsou:\n{listing}{more}\n\nCo s nimi?")
|
||
version_btn = box.addButton(
|
||
"Přidat jako verzi", QMessageBox.ButtonRole.AcceptRole
|
||
)
|
||
replace_btn = box.addButton(
|
||
"Nahradit celý film novým", QMessageBox.ButtonRole.DestructiveRole
|
||
)
|
||
skip_btn = box.addButton("Přeskočit", QMessageBox.ButtonRole.ActionRole)
|
||
box.addButton("Zrušit import", QMessageBox.ButtonRole.RejectRole)
|
||
box.setDefaultButton(version_btn)
|
||
box.exec()
|
||
clicked = box.clickedButton()
|
||
if clicked is version_btn:
|
||
return "version"
|
||
if clicked is replace_btn:
|
||
return "replace"
|
||
if clicked is skip_btn:
|
||
return "skip"
|
||
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.DialogCode.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 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_movie(self) -> None:
|
||
"""Open the consolidated edit dialog for the selected movie."""
|
||
files = self._selected_movies()
|
||
if len(files) != 1:
|
||
QMessageBox.information(self, "Upravit film", "Vyber právě jeden film.")
|
||
return
|
||
dialog = MovieEditDialog(self, files[0], self.filehandler)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
self.refresh_table() # versions may have been added/removed meanwhile
|
||
return
|
||
f = dialog.file_obj # its own file may have been removed — a sibling then
|
||
try:
|
||
self.filehandler.apply_movie_edits(
|
||
f,
|
||
title=dialog.title,
|
||
year=dialog.year or None,
|
||
tmdb_id=dialog.tmdb_id or None,
|
||
library=dialog.library,
|
||
date=dialog.date,
|
||
csfd_url=dialog.csfd_url,
|
||
format_3d=dialog.format_3d,
|
||
)
|
||
except (ValueError, FileExistsError, OSError) as exc:
|
||
QMessageBox.warning(self, "Úprava selhala", str(exc))
|
||
return
|
||
self._refresh_library_filter()
|
||
self.refresh_table()
|
||
self.status.showMessage(f"Upraveno: {f.filename}", 5000)
|
||
|
||
def edit_series(self, ser) -> None:
|
||
"""Open the series editor (params + per-episode SxxEyy tree) for ``ser``."""
|
||
dialog = SeriesEditDialog(self, ser, self.filehandler)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
for episode, season, number in dialog.episode_numbers():
|
||
episode.season, episode.episode = season, number
|
||
self.filehandler.rename_episodes_to_canonical(ser)
|
||
try:
|
||
self.filehandler.apply_series_edits(
|
||
ser,
|
||
title=dialog.title,
|
||
year=dialog.year or None,
|
||
tmdb_id=dialog.tmdb_id or None,
|
||
library=dialog.library,
|
||
)
|
||
except (ValueError, FileExistsError, OSError) as exc:
|
||
QMessageBox.warning(self, "Úprava seriálu selhala", str(exc))
|
||
self.reload_series()
|
||
self._refresh_library_filter()
|
||
self.refresh_table()
|
||
self.status.showMessage(f"Seriál upraven: {ser.title}", 5000)
|
||
|
||
def rename_to_canonical_naming(self) -> None:
|
||
"""Rename movies to the 'Title (rok) [tmdbid].ext' convention from metadata."""
|
||
files = self._selected_movies() or self.filehandler.filelist
|
||
if not files:
|
||
QMessageBox.information(self, "Přejmenovat dle metadat", "Pool je prázdný.")
|
||
return
|
||
scope = "vybrané filmy" if self._selected_movies() else "celý pool"
|
||
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Přejmenovat dle metadat (rok)",
|
||
f"Přejmenovat {scope} ({len(files)}) na „Název (rok) [tmdbid].ext“ "
|
||
"podle metadat?\n\nFilmy bez známého roku se přeskočí.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self)
|
||
progress.setWindowTitle("Přejmenovat dle metadat (rok)")
|
||
progress.setWindowModality(Qt.WindowModality.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.Icon.Warning if conflicts else QMessageBox.Icon.Information
|
||
)
|
||
box.setWindowTitle("Přejmenování dle metadat")
|
||
box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.")
|
||
box.setDetailedText("\n".join(detail))
|
||
box.exec()
|
||
self.status.showMessage(summary, 5000)
|
||
|
||
def import_episodes(self) -> None:
|
||
"""Import episode files into a series (Series/Season NN/Series SxxEyy).
|
||
|
||
The dialog opens empty — add episodes file-by-file or pick a whole series
|
||
folder (its subfolders are scanned too).
|
||
"""
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.")
|
||
return
|
||
dialog = ImportEpisodesDialog(self, [], self.filehandler.series_libraries)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
result = self.filehandler.import_episodes(
|
||
dialog.sources(),
|
||
dialog.title,
|
||
year=dialog.year or None,
|
||
tmdb_id=dialog.tmdb_id or None,
|
||
library=dialog.library,
|
||
move=dialog.move_files,
|
||
codes=dialog.episode_codes(),
|
||
)
|
||
msg = (
|
||
f"Naimportováno {result['imported']} epizod. "
|
||
f"Nerozpoznáno (bez SxxEyy): {len(result['skipped_no_code'])}."
|
||
)
|
||
if result["errors"]:
|
||
msg += "\n\nChyby:\n" + "\n".join(result["errors"][:5])
|
||
QMessageBox.warning(self, "Import epizod s chybami", msg)
|
||
else:
|
||
QMessageBox.information(self, "Import epizod", msg)
|
||
self.reload_series()
|
||
self.refresh_table()
|
||
self.status.showMessage(msg, 5000)
|
||
|
||
def show_series(self) -> None:
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.information(self, "Seriály", "Nejprve nastavte pool.")
|
||
return
|
||
SeriesOverviewDialog(self, self.filehandler).exec()
|
||
self.reload_series()
|
||
self.refresh_table()
|
||
|
||
def rename_all_series(self) -> None:
|
||
"""Bulk-fix every series' episode filenames to ``SxxEyy - {série}.ext``."""
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.warning(self, "Pool", "Nejprve nastavte pool.")
|
||
return
|
||
count = len(self.filehandler.load_series())
|
||
if not count:
|
||
QMessageBox.information(self, "Seriály", "Žádné seriály v poolu.")
|
||
return
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Napravit názvy souborů",
|
||
f"Přejmenovat epizody u {count} seriálů na „SxxEyy - Název "
|
||
"seriálu.ext“ a uklidit prázdné Season složky?\n\nEpizody bez "
|
||
"rozpoznaného SxxEyy se ponechají a vypíšou.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, count, self)
|
||
progress.setWindowTitle("Napravit názvy souborů seriálů")
|
||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||
progress.setMinimumDuration(0)
|
||
|
||
def on_progress(done: int, total: int, name: str) -> None:
|
||
progress.setValue(done)
|
||
progress.setLabelText(f"[{done}/{total}] {name}")
|
||
QApplication.processEvents()
|
||
|
||
result = self.filehandler.rename_all_series_to_canonical(
|
||
on_progress=on_progress, should_cancel=progress.wasCanceled
|
||
)
|
||
progress.setValue(count)
|
||
self.reload_series()
|
||
self.refresh_table()
|
||
|
||
renamed = result["renamed"]
|
||
skipped = result["skipped_no_code"]
|
||
conflicts = result["conflicts"]
|
||
swept = result["empty_dirs_removed"]
|
||
tail = " (zrušeno)" if result["cancelled"] else ""
|
||
summary = (
|
||
f"Přejmenováno: {len(renamed)}, bez SxxEyy: {len(skipped)}, "
|
||
f"kolize: {len(conflicts)}, uklizené složky: {len(swept)}{tail}"
|
||
)
|
||
|
||
if not (skipped or conflicts):
|
||
QMessageBox.information(self, "Hotovo", summary)
|
||
else:
|
||
detail: list[str] = []
|
||
if renamed:
|
||
detail.append(f"■ Přejmenováno ({len(renamed)}):")
|
||
detail += [f" • [{s}] {old} → {new}" for s, old, new in renamed]
|
||
detail.append("")
|
||
if skipped:
|
||
detail.append(f"■ Bez rozpoznaného SxxEyy ({len(skipped)}):")
|
||
detail += [f" • [{s}] {n}" for s, n in skipped]
|
||
detail.append("")
|
||
if conflicts:
|
||
detail.append(f"■ Kolize ({len(conflicts)}):")
|
||
detail += [f" • [{s}] {n} — {msg}" for s, n, msg in conflicts]
|
||
box = QMessageBox(self)
|
||
box.setIcon(
|
||
QMessageBox.Icon.Warning if conflicts else QMessageBox.Icon.Information
|
||
)
|
||
box.setWindowTitle("Napravit názvy souborů seriálů")
|
||
box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.")
|
||
box.setDetailedText("\n".join(detail))
|
||
box.exec()
|
||
self.status.showMessage(summary, 5000)
|
||
|
||
def normalize_default_names(self) -> None:
|
||
"""Nástroje: rename "<folder> - Výchozí.ext" to the bare "<folder>.ext"."""
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.information(self, "Výchozí verze", "Nejprve nastavte pool.")
|
||
return
|
||
self.filehandler.load_pool_movies()
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Sjednotit názvy výchozích verzí",
|
||
"Výchozí verze filmů s víc verzemi se přejmenuje z „Název - Výchozí.ext“ "
|
||
"na holé „Název.ext“ (titulky a metadata se přenesou). Pokračovat?",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
renamed = self.filehandler.normalize_default_version_names()
|
||
self.refresh_table()
|
||
QMessageBox.information(
|
||
self,
|
||
"Sjednotit názvy výchozích verzí",
|
||
f"Přejmenováno: {len(renamed)}." if renamed else "Nic k přejmenování.",
|
||
)
|
||
|
||
def check_duplicates(self) -> None:
|
||
"""Nástroje: list movies present more than once and offer merge / delete."""
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.information(self, "Kontrola duplicit", "Nejprve nastavte pool.")
|
||
return
|
||
dialog = DuplicatesDialog(self, self.filehandler)
|
||
dialog.exec()
|
||
if dialog.changed:
|
||
self.refresh_table()
|
||
|
||
def migrate_pool(self) -> None:
|
||
"""Match the existing pool against TMDb and assign ids (one-off migration)."""
|
||
from src.core import tmdb
|
||
|
||
if not tmdb.api_available():
|
||
QMessageBox.warning(
|
||
self, "Migrace", "Nastav TMDB_API_KEY v .env (potřeba pro párování)."
|
||
)
|
||
return
|
||
files = self.filehandler.filelist
|
||
if not files:
|
||
QMessageBox.information(self, "Migrace", "Pool je prázdný.")
|
||
return
|
||
|
||
progress = QProgressDialog("Hledám na TMDb…", "Zrušit", 0, len(files), self)
|
||
progress.setWindowTitle("Migrace — párování s TMDb")
|
||
progress.setWindowModality(Qt.WindowModality.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()
|
||
|
||
proposals = self.filehandler.match_pool_to_tmdb(
|
||
files, on_progress=on_progress, should_cancel=progress.wasCanceled
|
||
)
|
||
progress.setValue(len(files))
|
||
|
||
if not proposals:
|
||
QMessageBox.information(
|
||
self, "Migrace", "Všechny filmy už mají TMDb ID — není co migrovat."
|
||
)
|
||
return
|
||
MigrateDialog(self, self.filehandler, proposals).exec()
|
||
self._refresh_library_filter()
|
||
self.refresh_table()
|
||
|
||
def refresh_from_tmdb(self) -> None:
|
||
"""(Re)load genre/year/rating tags from TMDb (by the tmdbid in the name)."""
|
||
files = self._selected_movies() or self.filehandler.filelist
|
||
if not files:
|
||
QMessageBox.information(self, "TMDb", "Pool je prázdný.")
|
||
return
|
||
progress = QProgressDialog(
|
||
"Načítám žánry z TMDb…", "Zrušit", 0, len(files), self
|
||
)
|
||
progress.setWindowTitle("Načíst žánry z TMDb")
|
||
progress.setWindowModality(Qt.WindowModality.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.refresh_tags_from_tmdb(
|
||
files, on_progress=on_progress, should_cancel=progress.wasCanceled
|
||
)
|
||
progress.setValue(len(files))
|
||
if not result["ok"]:
|
||
QMessageBox.warning(self, "TMDb", result["error"])
|
||
return
|
||
self.refresh_table()
|
||
msg = (
|
||
f"Načteno z TMDb: {result['updated']}/{len(files)} filmů. "
|
||
f"Bez TMDb ID: {result['without_id']}."
|
||
)
|
||
if result["without_id"] and not result["updated"]:
|
||
msg += "\n\nFilmy nemají [tmdbid] v názvu — nejdřív je spáruj (import/migrace)."
|
||
QMessageBox.information(self, "Načíst žánry z TMDb", msg)
|
||
self.status.showMessage(msg, 5000)
|
||
|
||
def remove_movies(self) -> None:
|
||
files = self._selected_movies()
|
||
if not files:
|
||
return
|
||
grouped = sum(1 for f in files if self.filehandler.is_movie_group_member(f))
|
||
note = (
|
||
f"\n\n{grouped} z nich má více verzí — smažou se všechny verze."
|
||
if grouped
|
||
else ""
|
||
)
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Odebrat z poolu",
|
||
f"Smazat {len(files)} film(ů) z poolu včetně souborů, titulků a "
|
||
f"metadat?{note}",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
for f in files:
|
||
try:
|
||
self.filehandler.remove_movie(f)
|
||
except OSError as exc:
|
||
QMessageBox.warning(self, "Chyba", f"Nelze smazat {f.filename}: {exc}")
|
||
self.refresh_table()
|
||
|
||
def remove_series(self, series_list: list | None = None) -> None:
|
||
"""Delete whole series folders from the pool (all seasons/episodes)."""
|
||
targets = series_list if series_list is not None else self._selected_series()
|
||
if not targets:
|
||
return
|
||
listing = "\n".join(f" • {s.title}" for s in targets[:10])
|
||
more = f"\n … a další ({len(targets) - 10})" if len(targets) > 10 else ""
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Odebrat z poolu",
|
||
f"Smazat {len(targets)} seriál(ů) z poolu včetně všech sezón a "
|
||
f"epizod?\n{listing}{more}",
|
||
)
|
||
if answer != QMessageBox.StandardButton.Yes:
|
||
return
|
||
for ser in targets:
|
||
try:
|
||
self.filehandler.remove_series(ser)
|
||
except (ValueError, OSError) as exc:
|
||
QMessageBox.warning(self, "Chyba", f"Nelze smazat „{ser.title}“: {exc}")
|
||
self.reload_series()
|
||
self.refresh_table()
|
||
|
||
def select_minidlna_libraries(self) -> None:
|
||
"""Choose which pool libraries feed miniDLNA generation (persisted)."""
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.information(self, "miniDLNA", "Nejprve nastavte pool.")
|
||
return
|
||
dialog = MinidlnaLibrariesDialog(
|
||
self, self.filehandler.libraries, self.filehandler.minidlna_libraries
|
||
)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
chosen = dialog.selected_libraries()
|
||
all_names = {lib["name"] for lib in self.filehandler.libraries}
|
||
# Selecting every library is the same as "all" (None) — no scoping.
|
||
self.filehandler.set_minidlna_libraries(
|
||
None if set(chosen) == all_names else chosen
|
||
)
|
||
n = len(chosen)
|
||
self.status.showMessage(
|
||
f"miniDLNA: {n}/{len(all_names)} knihoven vybráno pro generování.", 6000
|
||
)
|
||
|
||
def generate_filmoteka(self) -> None:
|
||
out = self.filehandler.filmoteka_dir
|
||
if not out:
|
||
QMessageBox.warning(
|
||
self,
|
||
"miniDLNA",
|
||
"Nejprve nastavte výstupní složku (Pool → Nastavit Filmotéku).",
|
||
)
|
||
return
|
||
files, mirror_libs, mixed_series = self.filehandler.minidlna_scope()
|
||
scoped = self.filehandler.minidlna_libraries is not None
|
||
if not files and not mirror_libs and not mixed_series:
|
||
QMessageBox.information(
|
||
self,
|
||
"miniDLNA",
|
||
"Pool je prázdný."
|
||
if not scoped
|
||
else "Vybrané knihovny neobsahují nic k vygenerování — "
|
||
"zkontroluj miniDLNA → „Knihovny pro generování…“.",
|
||
)
|
||
return
|
||
# Refresh genres/year from TMDb so the genre tree is current.
|
||
self.filehandler.refresh_tags_from_tmdb(files)
|
||
manager = HardlinkManager(out)
|
||
# Every mirrored library (series/homevideo, + the mixed-library series
|
||
# bucket) stays reserved — protected from tag-tree cleanup — even when
|
||
# this run skips mirroring it.
|
||
reserved = set(self.filehandler.mirror_as_is_libraries) | {
|
||
TIPS_SUBFOLDER,
|
||
RECENT_SUBFOLDER,
|
||
MIXED_SERIES_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,
|
||
)
|
||
|
||
# Mirrored libraries (series copy-as-is + homevideo): hardlink each
|
||
# whole library folder 1:1 into output/<jeho vlastní název> — neither
|
||
# kind has genre tags to build a tree from.
|
||
pool = self.filehandler.pool_dir
|
||
mirrored = 0
|
||
mirror_fail = 0
|
||
for name in mirror_libs:
|
||
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
|
||
|
||
# Series found inside a "mixed" library: no genre tags apply, so just
|
||
# hardcopy each one into output/Seriály/<jeho vlastní název> — same
|
||
# mechanism as a dedicated series library, scoped to one series' folder.
|
||
for ser in mixed_series:
|
||
if ser.folder.is_dir():
|
||
m_created, m_failed = manager.mirror_as_is(
|
||
ser.folder, f"{MIXED_SERIES_SUBFOLDER}/{ser.folder.name}"
|
||
)
|
||
mirrored += m_created
|
||
mirror_fail += m_failed
|
||
|
||
# Mirrors have no obsolete-link cleanup of their own (unlike the genre
|
||
# tree) — a library/series deselected this run would otherwise linger
|
||
# in the output forever. Remove exactly what fell out of scope.
|
||
stale_libs, valid_bucket = self.filehandler.minidlna_prune_targets(
|
||
mirror_libs, mixed_series, MIXED_SERIES_SUBFOLDER
|
||
)
|
||
pruned = sum(1 for name in stale_libs if manager.remove_mirror(name))
|
||
bucket_dir = out / MIXED_SERIES_SUBFOLDER
|
||
if bucket_dir.is_dir():
|
||
for entry in list(bucket_dir.iterdir()):
|
||
if entry.name not in valid_bucket and manager.remove_mirror(
|
||
f"{MIXED_SERIES_SUBFOLDER}/{entry.name}"
|
||
):
|
||
pruned += 1
|
||
|
||
# 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
|
||
)
|
||
|
||
scope_note = (
|
||
f"\n(omezeno na {len(self.filehandler.minidlna_libraries or [])} knihoven — "
|
||
"miniDLNA → „Knihovny pro generování…“)"
|
||
if scoped
|
||
else ""
|
||
)
|
||
pruned_note = f", odebráno mimo výběr: {pruned}" if pruned else ""
|
||
msg = (
|
||
f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n"
|
||
f"Zrcadleno (seriály / homevideo): {mirrored}{pruned_note}\n"
|
||
f"Tipy dne — náhodně: {tips}, Nově přidané: {recent}{scope_note}"
|
||
)
|
||
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_libraries(self) -> None:
|
||
"""Open the library organizer (manage the pool's top-level libraries)."""
|
||
if not self.filehandler.pool_dir:
|
||
QMessageBox.information(self, "Knihovny", "Nejprve nastavte pool.")
|
||
return
|
||
dialog = LibraryOrganizerDialog(self, self.filehandler.libraries)
|
||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
self.filehandler.set_libraries(dialog.libraries())
|
||
pool = self.filehandler.pool_dir
|
||
for lib in self.filehandler.libraries:
|
||
(pool / lib["name"]).mkdir(parents=True, exist_ok=True)
|
||
self._refresh_library_filter()
|
||
self.refresh_table()
|
||
self._cleanup_orphan_folders()
|
||
names = ", ".join(
|
||
f"{lib['name']} ({lib['kind']})" for lib in self.filehandler.libraries
|
||
)
|
||
self.status.showMessage("Knihovny: " + names, 6000)
|
||
|
||
def _cleanup_orphan_folders(self) -> None:
|
||
"""Offer to delete empty pool folders that are no longer libraries."""
|
||
orphans = self.filehandler.orphan_pool_folders()
|
||
empty = [n for n, is_empty in orphans if is_empty]
|
||
nonempty = [n for n, is_empty in orphans if not is_empty]
|
||
if empty:
|
||
listing = "\n".join(f" • {n}" for n in empty[:15])
|
||
more = f"\n … a další ({len(empty) - 15})" if len(empty) > 15 else ""
|
||
ans = QMessageBox.question(
|
||
self,
|
||
"Úklid složek",
|
||
f"V poolu jsou prázdné složky, které nejsou knihovnou "
|
||
f"({len(empty)}):\n{listing}{more}\n\nSmazat je?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if ans == QMessageBox.StandardButton.Yes:
|
||
removed = sum(
|
||
1 for n in empty if self.filehandler.remove_empty_pool_folder(n)
|
||
)
|
||
self.status.showMessage(f"Smazáno {removed} prázdných složek", 5000)
|
||
if nonempty:
|
||
listing = "\n".join(f" • {n}" for n in nonempty[:15])
|
||
more = f"\n … a další ({len(nonempty) - 15})" if len(nonempty) > 15 else ""
|
||
QMessageBox.information(
|
||
self,
|
||
"Neznámé složky s obsahem",
|
||
f"Tyto složky nejsou knihovnou, ale obsahují soubory — nesmazány "
|
||
f"({len(nonempty)}):\n{listing}{more}\n\nStaré knihovny přesuň "
|
||
"do některé knihovny, nebo smaž ručně.",
|
||
)
|
||
|
||
def move_to_library(self) -> None:
|
||
"""Move the selected movies into another library."""
|
||
files = self._selected_movies()
|
||
if not files:
|
||
QMessageBox.information(self, "Přesun", "Nejprve vyberte filmy.")
|
||
return
|
||
libraries = self.filehandler.libraries_accepting_movies
|
||
target, ok = QInputDialog.getItem(
|
||
self, "Přesunout do knihovny", "Cílová knihovna:", libraries, 0, False
|
||
)
|
||
if not ok or not target:
|
||
return
|
||
moved, errors = 0, []
|
||
for f in files:
|
||
try:
|
||
self.filehandler.move_to_library(f, target)
|
||
moved += 1
|
||
except (ValueError, FileExistsError, OSError) as exc:
|
||
errors.append(f"{f.filename}: {exc}")
|
||
self._refresh_library_filter()
|
||
self.refresh_table()
|
||
summary = f"Přesunuto {moved}/{len(files)} do „{target}“."
|
||
if errors:
|
||
QMessageBox.warning(
|
||
self, "Přesun s chybami", summary + "\n\n" + "\n".join(errors[:5])
|
||
)
|
||
self.status.showMessage(summary, 5000)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Jellyfin
|
||
# ------------------------------------------------------------------
|
||
|
||
def _jellyfin_ready(self) -> bool:
|
||
from src.core import jellyfin
|
||
|
||
if not jellyfin.api_available():
|
||
QMessageBox.warning(
|
||
self,
|
||
"Jellyfin",
|
||
"Nastav JELLYFIN_URL a JELLYFIN_API_KEY v .env "
|
||
"(klíč vytvoříš v Jellyfin → Dashboard → API Keys).",
|
||
)
|
||
return False
|
||
return True
|
||
|
||
def _jellyfin_call(self, fn):
|
||
"""Run a Jellyfin call under a wait cursor; return (result, error_str)."""
|
||
from src.core import jellyfin
|
||
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
try:
|
||
return fn(), None
|
||
except jellyfin.JellyfinError as exc:
|
||
return None, str(exc)
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
|
||
def jellyfin_check_connection(self) -> None:
|
||
from src.core import jellyfin
|
||
|
||
if not self._jellyfin_ready():
|
||
return
|
||
version, err = self._jellyfin_call(lambda: jellyfin.server_version(timeout=8))
|
||
if err:
|
||
self._update_jellyfin_status(None)
|
||
QMessageBox.warning(self, "Jellyfin", err)
|
||
return
|
||
self._update_jellyfin_status(version)
|
||
QMessageBox.information(self, "Jellyfin", f"Připojeno — verze {version}.")
|
||
|
||
def jellyfin_scan(self) -> None:
|
||
from src.core import jellyfin
|
||
|
||
if not self._jellyfin_ready():
|
||
return
|
||
|
||
def scan_then_version() -> str:
|
||
jellyfin.scan_all()
|
||
return jellyfin.server_version()
|
||
|
||
version, err = self._jellyfin_call(scan_then_version)
|
||
if err:
|
||
self._update_jellyfin_status(None)
|
||
QMessageBox.warning(self, "Jellyfin", err)
|
||
return
|
||
self._update_jellyfin_status(version)
|
||
self.status.showMessage("Jellyfin: sken knihovny spuštěn", 5000)
|
||
QMessageBox.information(self, "Jellyfin", "Sken knihovny spuštěn.")
|
||
|
||
def jellyfin_verify(self) -> None:
|
||
from src.core import jellyfin
|
||
|
||
if not self._jellyfin_ready():
|
||
return
|
||
data, err = self._jellyfin_call(
|
||
lambda: (jellyfin.fetch_movies(), jellyfin.server_version())
|
||
)
|
||
if err:
|
||
self._update_jellyfin_status(None)
|
||
QMessageBox.warning(self, "Jellyfin", err)
|
||
return
|
||
movies, version = data
|
||
self._update_jellyfin_status(version)
|
||
result = self.filehandler.verify_against_jellyfin(movies)
|
||
|
||
pool_only, jf_only = result["pool_only"], result["jellyfin_only"]
|
||
summary = (
|
||
f"Spárováno: {result['matched']}/{result['pool_total']} | "
|
||
f"Jellyfin má: {result['jellyfin_total']}"
|
||
)
|
||
if not pool_only and not jf_only:
|
||
QMessageBox.information(
|
||
self, "Ověření proti Jellyfin", "✅ Vše sedí.\n\n" + summary
|
||
)
|
||
return
|
||
|
||
detail: list[str] = []
|
||
if pool_only:
|
||
detail.append(
|
||
f"■ V poolu, Jellyfin nevidí ({len(pool_only)}) — přeskenuj / "
|
||
"oprav match v Jellyfinu:"
|
||
)
|
||
detail += [f" • {n}" for n in pool_only]
|
||
detail.append("")
|
||
if jf_only:
|
||
detail.append(f"■ V Jellyfinu, ne v poolu ({len(jf_only)}):")
|
||
detail += [f" • {n}" for n in jf_only]
|
||
|
||
box = QMessageBox(self)
|
||
box.setIcon(QMessageBox.Icon.Warning)
|
||
box.setWindowTitle("Ověření proti Jellyfin — nesrovnalosti")
|
||
box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.")
|
||
box.setDetailedText("\n".join(detail))
|
||
box.exec()
|
||
|
||
def edit_tag_schema(self) -> None:
|
||
dialog = TagSchemaDialog(self, self.filehandler.tag_schema)
|
||
if dialog.exec() != QDialog.DialogCode.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 žánry z TMDb" a přegenerujte miniDLNA.',
|
||
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.Icon.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.Icon.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.ButtonRole.AcceptRole)
|
||
quick_btn = box.addButton("Rychlá", QMessageBox.ButtonRole.AcceptRole)
|
||
box.addButton("Zrušit", QMessageBox.ButtonRole.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.WindowModality.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.Icon.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:
|
||
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()
|