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

This commit is contained in:
2026-06-15 17:31:52 +02:00
parent 86c689b9f1
commit b3a61f9e86
18 changed files with 1407 additions and 168 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "0.1.0"
__version__ = "1.0.0"
+116 -14
View File
@@ -48,6 +48,9 @@ ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
ANUBIS_MAX_NONCE = 50_000_000
# Keep only the top-billed cast from a movie's actor list.
MAX_ACTORS = 10
@dataclass
class CSFDMovie:
@@ -61,7 +64,9 @@ class CSFDMovie:
rating: Optional[int] = None # Percentage 0-100
rating_count: Optional[int] = None
duration: Optional[int] = None # Minutes
country: Optional[str] = None
# A movie can be a co-production, so the origin is a list of countries
# (ČSFD writes them slash-separated, e.g. "Japonsko / USA").
countries: list[str] = field(default_factory=list)
poster_url: Optional[str] = None
plot: Optional[str] = None
csfd_id: Optional[int] = None
@@ -78,7 +83,7 @@ class CSFDMovie:
"rating": self.rating,
"rating_count": self.rating_count,
"duration": self.duration,
"country": self.country,
"countries": self.countries,
"poster_url": self.poster_url,
"plot": self.plot,
"csfd_id": self.csfd_id,
@@ -87,6 +92,10 @@ class CSFDMovie:
@classmethod
def from_dict(cls, data: dict) -> "CSFDMovie":
"""Deserialize from a plain dict (e.g. loaded from .!tag cache)."""
countries = data.get("countries")
if countries is None:
# Legacy cache stored a single "country" string (possibly slash-joined)
countries = _split_countries(data.get("country"))
return cls(
title=data.get("title", ""),
url=data.get("url", ""),
@@ -97,7 +106,7 @@ class CSFDMovie:
rating=data.get("rating"),
rating_count=data.get("rating_count"),
duration=data.get("duration"),
country=data.get("country"),
countries=countries,
poster_url=data.get("poster_url"),
plot=data.get("plot"),
csfd_id=data.get("csfd_id"),
@@ -111,11 +120,34 @@ class CSFDMovie:
parts.append(f"Hodnocení: {self.rating}%")
if self.genres:
parts.append(f"Žánr: {', '.join(self.genres)}")
if self.countries:
parts.append(f"Země původu: {', '.join(self.countries)}")
if self.directors:
parts.append(f"Režie: {', '.join(self.directors)}")
return " | ".join(parts)
def rating_band(rating: int) -> str:
"""Bucket a 0100 ČSFD rating into a ten-point band label (e.g. "8089 %").
The top bucket spans 90100 % so a perfect 100 still lands in a band.
"""
low = min((rating // 10) * 10, 90)
high = 100 if low == 90 else low + 9
return f"{low}{high} %"
def _split_countries(text: Optional[str]) -> list[str]:
"""Split a ČSFD origin country string into individual countries.
ČSFD writes co-productions slash-separated, e.g. ``"Japonsko / USA"`` →
``["Japonsko", "USA"]``. ``None``/empty yields an empty list.
"""
if not text:
return []
return [part.strip() for part in text.split("/") if part.strip()]
def _check_dependencies():
"""Check if required dependencies are installed."""
if not HAS_DEPENDENCIES:
@@ -275,11 +307,11 @@ def fetch_movie(url: str, session=None) -> CSFDMovie:
if movie_data.get("plot") is None:
movie_data["plot"] = _extract_plot(soup)
# Get country and year from origin info
# Get countries and year from origin info
origin_info = _extract_origin_info(soup)
if origin_info:
if movie_data.get("country") is None:
movie_data["country"] = origin_info.get("country")
if not movie_data.get("countries"):
movie_data["countries"] = origin_info.get("countries", [])
if movie_data.get("year") is None:
movie_data["year"] = origin_info.get("year")
if movie_data.get("duration") is None:
@@ -289,6 +321,9 @@ def fetch_movie(url: str, session=None) -> CSFDMovie:
if not movie_data.get("genres"):
movie_data["genres"] = _extract_genres(soup)
# Keep only the leading cast (ČSFD lists them in billing order)
movie_data["actors"] = movie_data.get("actors", [])[:MAX_ACTORS]
return CSFDMovie(**movie_data)
@@ -303,7 +338,7 @@ def _extract_json_ld(soup: BeautifulSoup) -> dict:
"rating": None,
"rating_count": None,
"duration": None,
"country": None,
"countries": [],
"poster_url": None,
"plot": None,
}
@@ -441,12 +476,13 @@ def _extract_genres(soup: BeautifulSoup) -> list[str]:
def _extract_origin_info(soup: BeautifulSoup) -> dict:
"""Extract country, year, duration from the origin info line.
"""Extract countries, year, duration from the origin info line.
CSFD separates the values with inline bullet ``<span>`` elements (no commas),
so ``get_text(strip=True)`` would glue them together (e.g. "USA1999136 min").
We tokenize on those inline boundaries (and on commas, for the older format)
before extracting each field.
before extracting each field. The country segment of a co-production is
slash-separated (e.g. "USA / Velká Británie") and is split into a list.
"""
info: dict = {}
@@ -468,20 +504,23 @@ def _extract_origin_info(soup: BeautifulSoup) -> dict:
if duration_match:
info["duration"] = int(duration_match.group(1))
continue
# Country: first alphabetic token that doesn't start with a digit.
if "country" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
info["country"] = token
# Countries: first alphabetic token that doesn't start with a digit;
# may list several slash-separated countries for a co-production.
if "countries" not in info and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
info["countries"] = _split_countries(token)
return info
def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
def search_movies(query: str, limit: int = 10, session=None) -> list[CSFDMovie]:
"""
Search for movies on CSFD.cz.
Args:
query: Search query string
limit: Maximum number of results to return
session: Optional ``requests.Session`` to reuse (keeps the Anubis auth
cookie across calls so only the first lookup pays the PoW cost).
Returns:
List of CSFDMovie objects with basic info (title, url, year)
@@ -489,8 +528,14 @@ def search_movies(query: str, limit: int = 10) -> list[CSFDMovie]:
_check_dependencies()
search_url = f"{CSFD_SEARCH_URL}?q={requests.utils.quote(query)}"
with requests.Session() as session:
own_session = session is None
if own_session:
session = requests.Session()
try:
response = _get_page(session, search_url)
finally:
if own_session:
session.close()
soup = BeautifulSoup(response.text, "html.parser")
results = []
@@ -538,3 +583,60 @@ def fetch_movie_by_id(csfd_id: int) -> CSFDMovie:
"""
url = f"{CSFD_BASE_URL}/film/{csfd_id}/"
return fetch_movie(url)
# Release-name tokens that mark the end of the actual title in a filename.
_RELEASE_MARKERS = {
"bluray", "blu-ray", "brrip", "bdrip", "bdremux", "remux", "webrip", "web",
"web-dl", "webdl", "hdtv", "dvdrip", "dvd", "dvd5", "dvd9", "hdrip", "cam",
"ts", "tc", "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx",
"aac", "ac3", "eac3", "dts", "dd5", "ddp5", "truehd", "atmos", "flac",
"10bit", "8bit", "hdr", "hdr10", "dolby", "sdr", "proper", "repack",
"extended", "unrated", "remastered", "imax", "multi", "dual", "complete",
"internal", "limited", "uncut",
}
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
def clean_filename_to_query(filename: str) -> str:
"""Turn a (possibly release-named) filename into a ČSFD search query.
Strips the path/extension, splits on common separators and keeps the words
before the first release marker (year, resolution, codec, source, …). The
detected year is appended back as a disambiguator. Example::
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
"""
from pathlib import Path
stem = Path(filename).stem
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
title_words: list[str] = []
year: Optional[str] = None
for token in tokens:
bare = token.strip("()[]{}")
if _YEAR_RE.match(bare):
year = bare
break
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
break
# also stop at a release group glued with a dash (e.g. "x264-GROUP")
title_words.append(token)
# If nothing survived (title started with a marker), fall back to the stem.
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
return f"{title} {year}".strip() if year else title
def find_csfd_url(query: str, session=None) -> Optional[str]:
"""Return the first ČSFD film URL matching a query, or None.
Thin wrapper over :func:`search_movies` that takes the top result. Pass a
shared ``session`` to reuse the Anubis auth cookie across several lookups.
"""
if not query.strip():
return None
results = search_movies(query, limit=1, session=session)
return results[0].url if results else None
+35 -5
View File
@@ -3,7 +3,8 @@ import json
from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
CSFD_CACHE_VERSION = 1
# v2: country (str) → countries (list[str]) for co-productions.
CSFD_CACHE_VERSION = 2
class File:
@@ -109,6 +110,22 @@ class File:
elif self.metadata_filename.exists():
self.metadata_filename.unlink()
def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along.
The physical file must already have been moved/renamed by the caller.
Drops the metadata under the old path (index key or sidecar) and rebinds
to the new path; call ``save_metadata()`` afterwards to write it back.
"""
old_metadata_filename = self.metadata_filename
if self.index is not None:
self.index.delete(self.file_path)
self.file_path = Path(new_path)
self.filename = self.file_path.name
self.metadata_filename = self.file_path.parent / f".{self.filename}.!tag"
if self.index is None and old_metadata_filename.exists():
old_metadata_filename.rename(self.metadata_filename)
def set_date(self, date_str: str | None):
"""Nastaví datum (např. '2025-09-25') nebo None pro smazání."""
if date_str is None or date_str == "":
@@ -137,9 +154,18 @@ class File:
return None
def apply_csfd_tags(
self, add_genres: bool = True, add_year: bool = True, add_country: bool = True
self,
add_genres: bool = True,
add_year: bool = True,
add_country: bool = True,
add_rating: bool = True,
) -> dict:
"""Načte informace z CSFD a přiřadí tagy (Žánr, Rok, Země původu); cachuje data.
"""Načte informace z CSFD a přiřadí tagy; cachuje data.
Tagy: Žánr, Rok, Země původu a Hodnocení (procenta zařazená do desítkového
pásma, např. ``8089 %``). Režie a herci se z ČSFD **stahují a cachují**
(``csfd_cache``), ale záměrně se z nich netvoří tagy ani složky — bylo by
jich příliš mnoho.
Returns:
dict s klíči 'success', 'movie'/'error', 'tags_added'
@@ -169,8 +195,12 @@ class File:
_add("Žánr", genre)
if add_year and movie.year:
_add("Rok", str(movie.year))
if add_country and movie.country:
_add("Země původu", movie.country)
if add_country:
for country in movie.countries:
_add("Země původu", country)
if add_rating and movie.rating is not None:
from .csfd import rating_band
_add("Hodnocení", rating_band(movie.rating))
# Use the CSFD title if we don't have one yet
if movie.title and not self.title:
+44 -4
View File
@@ -93,10 +93,13 @@ class FileManager:
file_obj = File(each, self.tagmanager, index=self.index)
self.filelist.append(file_obj)
def import_movie(self, source: Path, title: str, csfd_link: str | None = None) -> File:
"""Copy a video file into pool/Filmy as 'Title.ext', index its metadata.
def import_movie(
self, source: Path, title: str, csfd_link: str | None = None, move: bool = False
) -> File:
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata.
The original file is left in place (non-destructive copy).
By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind.
"""
movies = self.movies_dir
pool = self.pool_dir
@@ -117,7 +120,10 @@ class FileManager:
target = movies / f"{safe_title}_{counter}{source.suffix}"
counter += 1
shutil.copy2(source, target)
if move:
shutil.move(str(source), str(target))
else:
shutil.copy2(source, target)
file_obj = File(target, self.tagmanager, index=self.index)
file_obj.title = safe_title
@@ -129,6 +135,40 @@ class FileManager:
self.on_files_changed(self.filelist)
return file_obj
def rename_movie(self, file_obj: File, new_title: str) -> File:
"""Rename a pooled movie's file to ``<new_title>.<ext>`` and reindex it.
Renames the physical file in pool/Filmy (keeping its extension), moves
the metadata to the new key, and syncs ``title``/``filename``. The
extension is preserved; ``new_title`` is the bare name without it.
Raises:
ValueError: empty name or a name containing a path separator.
FileExistsError: another pooled file already uses that name.
"""
new_title = new_title.strip()
if not new_title:
raise ValueError("Název nesmí být prázdný.")
if "/" in new_title or "\\" in new_title:
raise ValueError("Název nesmí obsahovat lomítka.")
old_path = file_obj.file_path
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
if new_path == old_path:
return file_obj # no change
if new_path.exists():
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
old_path.rename(new_path)
file_obj.relocate(new_path)
file_obj.title = new_title
file_obj.save_metadata()
if self.on_files_changed:
self.on_files_changed(self.filelist)
return file_obj
def append(self, folder: Path) -> None:
"""Add a folder to scan for files"""
self.folders.append(folder)
+126 -53
View File
@@ -19,12 +19,21 @@ Example:
"""
import os
from pathlib import Path
from typing import List, Tuple, Optional
from typing import List, Tuple, Optional, Dict, Set
from .file import File
class HardlinkManager:
"""Manager for creating hardlink-based directory structures from tagged files."""
"""Manager for creating hardlink-based directory structures from tagged files.
The output layout is driven by a *category → root folder* mapping
(``category_roots``). Each tag is placed at
``output/<root>/<tag_name>/<file>``; an empty root means the tag's own
folders sit directly at the output root (e.g. genre folders next to the
"Dle roku" / "Dle země původu" folders). The legacy ``categories`` list
(folder == category name) is still accepted and treated as the identity
mapping ``{cat: cat}``.
"""
def __init__(self, output_dir: Path):
"""
@@ -37,11 +46,61 @@ class HardlinkManager:
self.created_links: List[Path] = []
self.errors: List[Tuple[Path, str]] = []
def _resolve_roots(
self,
categories: Optional[List[str]],
category_roots: Optional[Dict[str, str]],
) -> Optional[Dict[str, str]]:
"""Normalize the two filter styles into a category → root-folder map.
``None`` means "all categories", folder == category name.
"""
if category_roots is not None:
return dict(category_roots)
if categories is not None:
return {cat: cat for cat in categories}
return None
def _target_dir(self, tag, roots: Optional[Dict[str, str]]) -> Optional[Path]:
"""Output directory for a tag, or None if its category is excluded."""
if roots is None:
folder = tag.category
elif tag.category in roots:
folder = roots[tag.category]
else:
return None
base = self.output_dir / folder if folder else self.output_dir
return base / tag.name
def _managed_top_dirs(
self, files: List[File], roots: Optional[Dict[str, str]]
) -> Optional[Set[str]]:
"""Top-level output folders owned by the tag tree (None = all of them).
For a category with a non-empty root the root folder is owned; for a
category placed at the output root (empty root, e.g. genres) each of its
tag names is its own top-level folder. This lets cleanup skip unrelated
root entries such as the copy-as-is mirror (Seriály).
"""
if roots is None:
return None
tops: Set[str] = set()
for cat, folder in roots.items():
if folder:
tops.add(folder)
else:
for file_obj in files:
for tag in file_obj.tags:
if tag.category == cat:
tops.add(tag.name)
return tops
def create_structure_for_files(
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None,
) -> Tuple[int, int]:
"""
Create hardlink structure for given files based on their tags.
@@ -50,6 +109,8 @@ class HardlinkManager:
files: List of File objects to process
categories: Optional list of categories to include (None = all)
dry_run: If True, only simulate without creating actual links
category_roots: Optional category → root-folder map (see class doc);
overrides ``categories`` when given.
Returns:
Tuple of (successful_links, failed_links)
@@ -57,6 +118,7 @@ class HardlinkManager:
self.created_links = []
self.errors = []
roots = self._resolve_roots(categories, category_roots)
success_count = 0
fail_count = 0
@@ -65,12 +127,10 @@ class HardlinkManager:
continue
for tag in file_obj.tags:
# Skip if category filter is set and this category is not included
if categories is not None and tag.category not in categories:
# Resolve the target dir; None means this category is excluded
target_dir = self._target_dir(tag, roots)
if target_dir is None:
continue
# Create target directory path: output/category/tag_name/
target_dir = self.output_dir / tag.category / tag.name
target_file = target_dir / file_obj.filename
try:
@@ -204,17 +264,25 @@ class HardlinkManager:
except OSError:
pass
def get_preview(self, files: List[File], categories: Optional[List[str]] = None) -> List[Tuple[Path, Path]]:
def get_preview(
self,
files: List[File],
categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, str]] = None,
) -> List[Tuple[Path, Path]]:
"""
Get a preview of what links would be created.
Args:
files: List of File objects
categories: Optional list of categories to include
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
List of tuples (source_path, target_path)
"""
roots = self._resolve_roots(categories, category_roots)
preview = []
for file_obj in files:
@@ -222,10 +290,9 @@ class HardlinkManager:
continue
for tag in file_obj.tags:
if categories is not None and tag.category not in categories:
target_dir = self._target_dir(tag, roots)
if target_dir is None:
continue
target_dir = self.output_dir / tag.category / tag.name
target_file = target_dir / file_obj.filename
preview.append((file_obj.file_path, target_file))
@@ -235,26 +302,33 @@ class HardlinkManager:
def find_obsolete_links(
self,
files: List[File],
categories: Optional[List[str]] = None
categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, str]] = None,
) -> List[Tuple[Path, Path]]:
"""
Find hardlinks in the output directory that no longer match file tags.
Scans the output directory for hardlinks that point to source files,
but whose category/tag path no longer matches the file's current tags.
Scans the managed parts of the output directory for hardlinks that point
to source files but whose path no longer matches the file's current tags.
Only the tag-tree's own top-level folders are scanned, so copy-as-is
mirrors (e.g. Seriály) are left untouched.
Args:
files: List of File objects (source files)
categories: Optional list of categories to check (None = all)
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
List of tuples (link_path, source_path) for obsolete links
"""
obsolete = []
obsolete: List[Tuple[Path, Path]] = []
if not self.output_dir.exists():
return obsolete
roots = self._resolve_roots(categories, category_roots)
# Build a map of source file inodes to File objects
inode_to_file: dict[int, File] = {}
for file_obj in files:
@@ -272,44 +346,33 @@ class HardlinkManager:
expected_paths[inode] = set()
for tag in file_obj.tags:
if categories is not None and tag.category not in categories:
target_dir = self._target_dir(tag, roots)
if target_dir is None:
continue
target = self.output_dir / tag.category / tag.name / file_obj.filename
expected_paths[inode].add(target)
expected_paths[inode].add(target_dir / file_obj.filename)
except OSError:
continue
# Scan output directory for existing hardlinks
for category_dir in self.output_dir.iterdir():
if not category_dir.is_dir():
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
top_dirs = self._managed_top_dirs(files, roots)
for top in self.output_dir.iterdir():
if not top.is_dir():
continue
if top_dirs is not None and top.name not in top_dirs:
continue
# Filter by categories if specified
if categories is not None and category_dir.name not in categories:
continue
for tag_dir in category_dir.iterdir():
if not tag_dir.is_dir():
# Depth-agnostic: genres sit one level deep, "Dle roku"/"Dle země
# původu" two levels deep — walk all files under the managed folder.
for link_file in top.rglob("*"):
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
if link_inode in expected_paths:
if link_file not in expected_paths[link_inode]:
obsolete.append((link_file, inode_to_file[link_inode].file_path))
except OSError:
continue
for link_file in tag_dir.iterdir():
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
# Check if this inode belongs to one of our source files
if link_inode in inode_to_file:
source_file = inode_to_file[link_inode]
# Check if this link path is expected
if link_inode in expected_paths:
if link_file not in expected_paths[link_inode]:
# This link exists but tag was removed
obsolete.append((link_file, source_file.file_path))
except OSError:
continue
return obsolete
@@ -317,7 +380,8 @@ class HardlinkManager:
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None,
) -> Tuple[int, List[Path]]:
"""
Remove hardlinks that no longer match file tags.
@@ -326,11 +390,13 @@ class HardlinkManager:
files: List of File objects
categories: Optional list of categories to check
dry_run: If True, only return what would be removed
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
Tuple of (removed_count, list_of_removed_paths)
"""
obsolete = self.find_obsolete_links(files, categories)
obsolete = self.find_obsolete_links(files, categories, category_roots)
removed_paths = []
if dry_run:
@@ -352,7 +418,8 @@ class HardlinkManager:
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None,
) -> Tuple[int, int, int, int]:
"""
Synchronize hardlink structure with current file tags.
@@ -365,19 +432,25 @@ class HardlinkManager:
files: List of File objects
categories: Optional list of categories to sync
dry_run: If True, only simulate
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
Tuple of (created, create_failed, removed, remove_failed)
"""
# First find how many obsolete links there are
obsolete_count = len(self.find_obsolete_links(files, categories))
obsolete_count = len(self.find_obsolete_links(files, categories, category_roots))
# Remove obsolete links
removed, removed_paths = self.remove_obsolete_links(files, categories, dry_run)
removed, removed_paths = self.remove_obsolete_links(
files, categories, dry_run, category_roots
)
remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links
created, create_failed = self.create_structure_for_files(files, categories, dry_run)
created, create_failed = self.create_structure_for_files(
files, categories, dry_run, category_roots
)
return created, create_failed, removed, remove_failed
+216 -69
View File
@@ -12,15 +12,15 @@ import os
import sys
import subprocess
from pathlib import Path
from typing import List
from typing import List, Optional
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
QFormLayout, QHeaderView, QMenu, QAbstractItemView,
QHeaderView, QMenu, QAbstractItemView, QCheckBox,
)
from src.core.file_manager import FileManager
@@ -30,39 +30,125 @@ from src.core.tag import Tag
from src.core.constants import APP_NAME, VERSION
from src.core.hardlink_manager import HardlinkManager
# Categories that drive the generated Filmotéka tree (see PROJECT.md)
FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Země původu", "Hodnocení"]
# Layout of the generated Filmotéka tree: category → root folder under the
# output (see PROJECT.md). Genres sit directly at the output root (next to the
# copy-as-is Seriály mirror); Rok and Země původu get their own grouping folder.
FILMOTEKA_CATEGORY_ROOTS = {
"Žánr": "",
"Rok": "Dle roku",
"Země původu": "Dle země původu",
"Hodnocení": "Dle hodnocení",
}
class ImportMovieDialog(QDialog):
"""Collect the Title and ČSFD link for a movie being imported into the pool."""
class ImportMoviesDialog(QDialog):
"""Collect a Title + ČSFD link per file for a batch import into the pool.
def __init__(self, parent: QWidget, default_title: str) -> None:
One row per source file (filename shown, Title and ČSFD link editable). More
files can be added from inside the dialog. A single toggle decides whether
the files are copied (default, non-destructive) or moved into the pool.
"""
def __init__(self, parent: QWidget, sources: List[Path]) -> None:
super().__init__(parent)
self.setWindowTitle("Importovat film do poolu")
self.setMinimumWidth(420)
self.setWindowTitle("Importovat filmy do poolu")
self.setMinimumSize(680, 360)
# (source path, title field, ČSFD field) per row
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
layout = QVBoxLayout(self)
form = QFormLayout()
self.title_edit = QLineEdit(default_title)
self.csfd_edit = QLineEdit()
self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
form.addRow("Název:", self.title_edit)
form.addRow("ČSFD odkaz:", self.csfd_edit)
layout.addLayout(form)
self.table = QTableWidget(0, 3)
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz"])
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.Stretch)
layout.addWidget(self.table)
add_row = QHBoxLayout()
add_btn = QPushButton(" Přidat soubory…")
add_btn.clicked.connect(self._add_files)
add_row.addWidget(add_btn)
find_btn = QPushButton("🔎 Najít ČSFD odkazy")
find_btn.setToolTip("Vyhledá na ČSFD podle názvu a vyplní prázdné odkazy")
find_btn.clicked.connect(self._autofill_csfd)
add_row.addWidget(find_btn)
add_row.addStretch(1)
layout.addLayout(add_row)
self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)")
layout.addWidget(self.move_check)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
@property
def title(self) -> str:
return self.title_edit.text().strip()
for source in sources:
self._append_row(source)
def _append_row(self, source: Path) -> None:
row = self.table.rowCount()
self.table.insertRow(row)
name_item = QTableWidgetItem(source.name)
name_item.setFlags(Qt.ItemIsEnabled)
self.table.setItem(row, 0, name_item)
title_edit = QLineEdit(source.stem)
csfd_edit = QLineEdit()
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
self.table.setCellWidget(row, 1, title_edit)
self.table.setCellWidget(row, 2, csfd_edit)
self._rows.append((source, title_edit, csfd_edit))
def _add_files(self) -> None:
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
for path in paths:
self._append_row(Path(path))
def _autofill_csfd(self) -> None:
"""Fill empty ČSFD fields by searching ČSFD for each file's cleaned name."""
import requests
from src.core import csfd
targets = [(t, c) for _, t, c in self._rows if not c.text().strip()]
if not targets:
QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.")
return
found = 0
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
with requests.Session() as session:
for title_edit, csfd_edit in targets:
query = csfd.clean_filename_to_query(title_edit.text())
try:
url = csfd.find_csfd_url(query, session=session)
except Exception: # noqa: BLE001 — network/parse failure for one row
url = None
if url:
csfd_edit.setText(url)
found += 1
finally:
QApplication.restoreOverrideCursor()
QMessageBox.information(
self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů."
)
@property
def csfd_link(self) -> str:
return self.csfd_edit.text().strip()
def move_files(self) -> bool:
return self.move_check.isChecked()
def entries(self) -> list[tuple[Path, str, str]]:
"""Return (source, title, csfd_link) per row; title falls back to stem."""
result: list[tuple[Path, str, str]] = []
for source, title_edit, csfd_edit in self._rows:
title = title_edit.text().strip() or source.stem
result.append((source, title, csfd_edit.text().strip()))
return result
class AssignTagsDialog(QDialog):
@@ -127,6 +213,9 @@ class QtApp(QMainWindow):
self.filehandler = filehandler
self.tagmanager = tagmanager
self.file_rows: dict[int, File] = {} # table row -> File
# Active AND-filter as the source of truth (survives sidebar rebuilds);
# holds tag full_paths ("Category/Name").
self._active_filter: set[str] = set()
self.filehandler.on_files_changed = lambda _=None: self.refresh_table()
self.setWindowTitle(f"{APP_NAME} {VERSION} — Filmotéka")
@@ -163,7 +252,8 @@ class QtApp(QMainWindow):
self._add_action(pool_menu, "Konec", self.close, "Ctrl+Q")
movie_menu = bar.addMenu("&Filmy")
self._add_action(movie_menu, "Importovat film…", self.import_movie, "Ctrl+I")
self._add_action(movie_menu, "Importovat filmy", self.import_movie, "Ctrl+I")
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd)
@@ -208,7 +298,7 @@ class QtApp(QMainWindow):
self.search_edit.setPlaceholderText("Hledat film…")
self.search_edit.textChanged.connect(self.refresh_table)
search_row.addWidget(self.search_edit)
import_btn = QPushButton(" Importovat film")
import_btn = QPushButton(" Importovat filmy")
import_btn.clicked.connect(self.import_movie)
search_row.addWidget(import_btn)
main_layout.addLayout(search_row)
@@ -241,14 +331,24 @@ class QtApp(QMainWindow):
# Sidebar (tag filter)
# ------------------------------------------------------------------
def refresh_sidebar(self) -> None:
self.tag_tree.blockSignals(True)
self.tag_tree.clear()
def refresh_sidebar(self, filtered: Optional[List[File]] = None) -> None:
"""Rebuild the filter tree, preserving the active filter and updating counts.
The count after each tag is how many of ``filtered`` (the movies matching
the current filter; all movies when nothing is checked) also carry that
tag — i.e. how many would remain if that tag were checked. Check state is
restored from ``self._active_filter`` so it survives the rebuild.
"""
if filtered is None:
filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags())
counts: dict[str, int] = {}
for f in self.filehandler.filelist:
for f in filtered:
for t in f.tags:
counts[t.full_path] = counts.get(t.full_path, 0) + 1
self.tag_tree.blockSignals(True)
self.tag_tree.clear()
for category in self.tagmanager.get_categories():
cat_item = QTreeWidgetItem([category])
cat_item.setFlags(Qt.ItemIsEnabled)
@@ -256,27 +356,32 @@ class QtApp(QMainWindow):
cat_item.setExpanded(True)
for tag in self.tagmanager.get_tags_in_category(category):
count = counts.get(tag.full_path, 0)
label = f"{tag.name} ({count})" if count else tag.name
item = QTreeWidgetItem([label])
item = QTreeWidgetItem([f"{tag.name} ({count})"])
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setCheckState(0, Qt.Unchecked)
checked = tag.full_path in self._active_filter
item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked)
item.setData(0, Qt.UserRole, tag.full_path)
cat_item.addChild(item)
self.tag_tree.blockSignals(False)
def _on_tag_filter_changed(self, _item, _col) -> None:
self.refresh_table()
def _on_tag_filter_changed(self, item, _col) -> None:
full_path = item.data(0, Qt.UserRole)
if full_path is None:
return # category header row, not a tag
if item.checkState(0) == Qt.Checked:
self._active_filter.add(full_path)
else:
self._active_filter.discard(full_path)
# Defer the refresh: rebuilding the tree (clear()) *inside* its own
# itemChanged signal deletes the item Qt is still processing → SIGSEGV.
# Running it on the next event-loop tick lets Qt finish first.
QTimer.singleShot(0, self.refresh_table)
def _checked_filter_tags(self) -> List[Tag]:
def _active_filter_tags(self) -> List[Tag]:
tags: List[Tag] = []
for i in range(self.tag_tree.topLevelItemCount()):
cat = self.tag_tree.topLevelItem(i)
for j in range(cat.childCount()):
child = cat.child(j)
if child.checkState(0) == Qt.Checked:
full_path = child.data(0, Qt.UserRole)
category, name = full_path.split("/", 1)
tags.append(Tag(category, name))
for full_path in self._active_filter:
category, name = full_path.split("/", 1)
tags.append(Tag(category, name))
return tags
# ------------------------------------------------------------------
@@ -284,15 +389,18 @@ class QtApp(QMainWindow):
# ------------------------------------------------------------------
def refresh_table(self, *_args) -> None:
filtered = self.filehandler.filter_files_by_tags(self._checked_filter_tags())
# Tag filter (AND) drives both the table and the sidebar counts; the
# search box further narrows only the table.
tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags())
shown = tag_filtered
search = self.search_edit.text().lower() if hasattr(self, "search_edit") else ""
if search:
filtered = [f for f in filtered if search in (f.title or f.filename).lower()]
filtered.sort(key=lambda f: (f.title or f.filename).lower())
shown = [f for f in shown if search in (f.title or f.filename).lower()]
shown = sorted(shown, key=lambda f: (f.title or f.filename).lower())
self.table.setRowCount(len(filtered))
self.table.setRowCount(len(shown))
self.file_rows.clear()
for row, f in enumerate(filtered):
for row, f in enumerate(shown):
self.file_rows[row] = f
name = f.title or f.filename
tags = ", ".join(t.name for t in f.tags)
@@ -303,9 +411,9 @@ class QtApp(QMainWindow):
for col, value in enumerate([name, tags, size]):
self.table.setItem(row, col, QTableWidgetItem(value))
self.refresh_sidebar()
self.refresh_sidebar(tag_filtered)
self._update_selection_status()
self.status.showMessage(f"Zobrazeno {len(filtered)} filmů", 4000)
self.status.showMessage(f"Zobrazeno {len(shown)} filmů", 4000)
@staticmethod
def _format_size(size_bytes: float) -> str:
@@ -322,6 +430,7 @@ class QtApp(QMainWindow):
def _show_table_menu(self, pos) -> None:
menu = QMenu(self)
menu.addAction("Otevřít", self.open_movies)
menu.addAction("Přejmenovat…", self.rename_movie)
menu.addAction("Přiřadit štítky…", self.assign_tags)
menu.addAction("Nastavit datum…", self.set_date)
menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd)
@@ -382,36 +491,51 @@ class QtApp(QMainWindow):
if not self.filehandler.movies_dir:
QMessageBox.warning(self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool).")
return
path, _ = QFileDialog.getOpenFileName(self, "Vyber video soubor")
if not path:
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
if not paths:
return
source = Path(path)
dialog = ImportMovieDialog(self, default_title=source.stem)
sources = [Path(p) for p in paths]
dialog = ImportMoviesDialog(self, sources)
if dialog.exec() != QDialog.Accepted:
return
try:
movie = self.filehandler.import_movie(source, dialog.title, dialog.csfd_link)
except Exception as exc: # noqa: BLE001 — surface any import failure to the user
QMessageBox.critical(self, "Chyba importu", str(exc))
entries = dialog.entries()
if not entries:
return
move = dialog.move_files
# If a ČSFD link was given, enrich the movie with tags right away
if movie.csfd_link:
self.status.showMessage("Načítám z ČSFD…")
imported: list[File] = []
errors: list[str] = []
for source, title, csfd_link in entries:
try:
movie = self.filehandler.import_movie(source, title, csfd_link or None, move=move)
imported.append(movie)
except Exception as exc: # noqa: BLE001 — surface per-file import failures
errors.append(f"{source.name}: {exc}")
# Enrich the freshly imported movies that carry a ČSFD link
with_links = [m for m in imported if m.csfd_link]
tags_total = 0
if with_links:
self.status.showMessage(f"Načítám z ČSFD ({len(with_links)})…")
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
_, tags_total, errors = self._fetch_csfd_for([movie])
_, tags_total, csfd_errors = self._fetch_csfd_for(with_links)
finally:
QApplication.restoreOverrideCursor()
if errors:
QMessageBox.warning(self, "ČSFD", "Tagy se nepodařilo načíst:\n" + errors[0])
else:
self.status.showMessage(
f"Importováno: {movie.title} (+{tags_total} tagů z ČSFD)", 5000
)
errors.extend(csfd_errors)
self.refresh_table()
self.status.showMessage(f"Importováno: {dialog.title}", 5000)
verb = "Přesunuto" if move else "Zkopírováno"
summary = f"{verb} {len(imported)}/{len(entries)} filmů (+{tags_total} tagů z ČSFD)."
if errors:
QMessageBox.warning(
self, "Import dokončen s chybami",
summary + "\n\nChyby:\n" + "\n".join(errors[:5]),
)
else:
QMessageBox.information(self, "Import", summary)
self.status.showMessage(summary, 5000)
def open_movies(self) -> None:
for f in self._selected_movies():
@@ -456,6 +580,27 @@ class QtApp(QMainWindow):
f.set_date(text.strip() or None)
self.refresh_table()
def rename_movie(self) -> None:
files = self._selected_movies()
if len(files) != 1:
QMessageBox.information(self, "Přejmenovat", "Vyberte právě jeden film.")
return
f = files[0]
current = f.file_path.stem # name without extension
text, ok = QInputDialog.getText(
self, "Přejmenovat film",
f"Nový název (bez přípony {f.file_path.suffix}):", text=current,
)
if not ok:
return
try:
self.filehandler.rename_movie(f, text)
except (ValueError, FileExistsError, OSError) as exc:
QMessageBox.warning(self, "Přejmenování selhalo", str(exc))
return
self.refresh_table()
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
def edit_csfd(self) -> None:
files = self._selected_movies()
if len(files) != 1:
@@ -536,7 +681,9 @@ class QtApp(QMainWindow):
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
return
manager = HardlinkManager(out)
created, create_fail, removed, remove_fail = manager.sync_structure(files, FILMOTEKA_CATEGORIES)
created, create_fail, removed, remove_fail = manager.sync_structure(
files, category_roots=FILMOTEKA_CATEGORY_ROOTS
)
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
pool = self.filehandler.pool_dir