Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
+116
-14
@@ -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 0–100 ČSFD rating into a ten-point band label (e.g. "80–89 %").
|
||||
|
||||
The top bucket spans 90–100 % 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
@@ -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ř. ``80–89 %``). 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:
|
||||
|
||||
@@ -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
@@ -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
@@ -12,15 +12,15 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox,
|
||||
QFormLayout, QHeaderView, QMenu, QAbstractItemView,
|
||||
QHeaderView, QMenu, QAbstractItemView, QCheckBox,
|
||||
)
|
||||
|
||||
from src.core.file_manager import FileManager
|
||||
@@ -30,39 +30,125 @@ from src.core.tag import Tag
|
||||
from src.core.constants import APP_NAME, VERSION
|
||||
from src.core.hardlink_manager import HardlinkManager
|
||||
|
||||
# Categories that drive the generated Filmotéka tree (see PROJECT.md)
|
||||
FILMOTEKA_CATEGORIES = ["Rok", "Žánr", "Země původu", "Hodnocení"]
|
||||
# Layout of the generated Filmotéka tree: category → root folder under the
|
||||
# output (see PROJECT.md). Genres sit directly at the output root (next to the
|
||||
# copy-as-is Seriály mirror); Rok and Země původu get their own grouping folder.
|
||||
FILMOTEKA_CATEGORY_ROOTS = {
|
||||
"Žánr": "",
|
||||
"Rok": "Dle roku",
|
||||
"Země původu": "Dle země původu",
|
||||
"Hodnocení": "Dle hodnocení",
|
||||
}
|
||||
|
||||
|
||||
class ImportMovieDialog(QDialog):
|
||||
"""Collect the Title and ČSFD link for a movie being imported into the pool."""
|
||||
class ImportMoviesDialog(QDialog):
|
||||
"""Collect a Title + ČSFD link per file for a batch import into the pool.
|
||||
|
||||
def __init__(self, parent: QWidget, default_title: str) -> None:
|
||||
One row per source file (filename shown, Title and ČSFD link editable). More
|
||||
files can be added from inside the dialog. A single toggle decides whether
|
||||
the files are copied (default, non-destructive) or moved into the pool.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget, sources: List[Path]) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Importovat film do poolu")
|
||||
self.setMinimumWidth(420)
|
||||
self.setWindowTitle("Importovat filmy do poolu")
|
||||
self.setMinimumSize(680, 360)
|
||||
|
||||
# (source path, title field, ČSFD field) per row
|
||||
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
self.title_edit = QLineEdit(default_title)
|
||||
self.csfd_edit = QLineEdit()
|
||||
self.csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
|
||||
form.addRow("Název:", self.title_edit)
|
||||
form.addRow("ČSFD odkaz:", self.csfd_edit)
|
||||
layout.addLayout(form)
|
||||
|
||||
self.table = QTableWidget(0, 3)
|
||||
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz"])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
add_btn = QPushButton("➕ Přidat soubory…")
|
||||
add_btn.clicked.connect(self._add_files)
|
||||
add_row.addWidget(add_btn)
|
||||
find_btn = QPushButton("🔎 Najít ČSFD odkazy")
|
||||
find_btn.setToolTip("Vyhledá na ČSFD podle názvu a vyplní prázdné odkazy")
|
||||
find_btn.clicked.connect(self._autofill_csfd)
|
||||
add_row.addWidget(find_btn)
|
||||
add_row.addStretch(1)
|
||||
layout.addLayout(add_row)
|
||||
|
||||
self.move_check = QCheckBox("Přesunout soubory do poolu (jinak zkopírovat)")
|
||||
layout.addWidget(self.move_check)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.title_edit.text().strip()
|
||||
for source in sources:
|
||||
self._append_row(source)
|
||||
|
||||
def _append_row(self, source: Path) -> None:
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
name_item = QTableWidgetItem(source.name)
|
||||
name_item.setFlags(Qt.ItemIsEnabled)
|
||||
self.table.setItem(row, 0, name_item)
|
||||
title_edit = QLineEdit(source.stem)
|
||||
csfd_edit = QLineEdit()
|
||||
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
|
||||
self.table.setCellWidget(row, 1, title_edit)
|
||||
self.table.setCellWidget(row, 2, csfd_edit)
|
||||
self._rows.append((source, title_edit, csfd_edit))
|
||||
|
||||
def _add_files(self) -> None:
|
||||
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
|
||||
for path in paths:
|
||||
self._append_row(Path(path))
|
||||
|
||||
def _autofill_csfd(self) -> None:
|
||||
"""Fill empty ČSFD fields by searching ČSFD for each file's cleaned name."""
|
||||
import requests
|
||||
from src.core import csfd
|
||||
|
||||
targets = [(t, c) for _, t, c in self._rows if not c.text().strip()]
|
||||
if not targets:
|
||||
QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.")
|
||||
return
|
||||
|
||||
found = 0
|
||||
QApplication.setOverrideCursor(Qt.WaitCursor)
|
||||
try:
|
||||
with requests.Session() as session:
|
||||
for title_edit, csfd_edit in targets:
|
||||
query = csfd.clean_filename_to_query(title_edit.text())
|
||||
try:
|
||||
url = csfd.find_csfd_url(query, session=session)
|
||||
except Exception: # noqa: BLE001 — network/parse failure for one row
|
||||
url = None
|
||||
if url:
|
||||
csfd_edit.setText(url)
|
||||
found += 1
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
|
||||
QMessageBox.information(
|
||||
self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů."
|
||||
)
|
||||
|
||||
@property
|
||||
def csfd_link(self) -> str:
|
||||
return self.csfd_edit.text().strip()
|
||||
def move_files(self) -> bool:
|
||||
return self.move_check.isChecked()
|
||||
|
||||
def entries(self) -> list[tuple[Path, str, str]]:
|
||||
"""Return (source, title, csfd_link) per row; title falls back to stem."""
|
||||
result: list[tuple[Path, str, str]] = []
|
||||
for source, title_edit, csfd_edit in self._rows:
|
||||
title = title_edit.text().strip() or source.stem
|
||||
result.append((source, title, csfd_edit.text().strip()))
|
||||
return result
|
||||
|
||||
|
||||
class AssignTagsDialog(QDialog):
|
||||
@@ -127,6 +213,9 @@ class QtApp(QMainWindow):
|
||||
self.filehandler = filehandler
|
||||
self.tagmanager = tagmanager
|
||||
self.file_rows: dict[int, File] = {} # table row -> File
|
||||
# Active AND-filter as the source of truth (survives sidebar rebuilds);
|
||||
# holds tag full_paths ("Category/Name").
|
||||
self._active_filter: set[str] = set()
|
||||
self.filehandler.on_files_changed = lambda _=None: self.refresh_table()
|
||||
|
||||
self.setWindowTitle(f"{APP_NAME} {VERSION} — Filmotéka")
|
||||
@@ -163,7 +252,8 @@ class QtApp(QMainWindow):
|
||||
self._add_action(pool_menu, "Konec", self.close, "Ctrl+Q")
|
||||
|
||||
movie_menu = bar.addMenu("&Filmy")
|
||||
self._add_action(movie_menu, "Importovat film…", self.import_movie, "Ctrl+I")
|
||||
self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I")
|
||||
self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2")
|
||||
self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
|
||||
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
|
||||
self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd)
|
||||
@@ -208,7 +298,7 @@ class QtApp(QMainWindow):
|
||||
self.search_edit.setPlaceholderText("Hledat film…")
|
||||
self.search_edit.textChanged.connect(self.refresh_table)
|
||||
search_row.addWidget(self.search_edit)
|
||||
import_btn = QPushButton("➕ Importovat film")
|
||||
import_btn = QPushButton("➕ Importovat filmy")
|
||||
import_btn.clicked.connect(self.import_movie)
|
||||
search_row.addWidget(import_btn)
|
||||
main_layout.addLayout(search_row)
|
||||
@@ -241,14 +331,24 @@ class QtApp(QMainWindow):
|
||||
# Sidebar (tag filter)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def refresh_sidebar(self) -> None:
|
||||
self.tag_tree.blockSignals(True)
|
||||
self.tag_tree.clear()
|
||||
def refresh_sidebar(self, filtered: Optional[List[File]] = None) -> None:
|
||||
"""Rebuild the filter tree, preserving the active filter and updating counts.
|
||||
|
||||
The count after each tag is how many of ``filtered`` (the movies matching
|
||||
the current filter; all movies when nothing is checked) also carry that
|
||||
tag — i.e. how many would remain if that tag were checked. Check state is
|
||||
restored from ``self._active_filter`` so it survives the rebuild.
|
||||
"""
|
||||
if filtered is None:
|
||||
filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags())
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for f in self.filehandler.filelist:
|
||||
for f in filtered:
|
||||
for t in f.tags:
|
||||
counts[t.full_path] = counts.get(t.full_path, 0) + 1
|
||||
|
||||
self.tag_tree.blockSignals(True)
|
||||
self.tag_tree.clear()
|
||||
for category in self.tagmanager.get_categories():
|
||||
cat_item = QTreeWidgetItem([category])
|
||||
cat_item.setFlags(Qt.ItemIsEnabled)
|
||||
@@ -256,27 +356,32 @@ class QtApp(QMainWindow):
|
||||
cat_item.setExpanded(True)
|
||||
for tag in self.tagmanager.get_tags_in_category(category):
|
||||
count = counts.get(tag.full_path, 0)
|
||||
label = f"{tag.name} ({count})" if count else tag.name
|
||||
item = QTreeWidgetItem([label])
|
||||
item = QTreeWidgetItem([f"{tag.name} ({count})"])
|
||||
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
item.setCheckState(0, Qt.Unchecked)
|
||||
checked = tag.full_path in self._active_filter
|
||||
item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked)
|
||||
item.setData(0, Qt.UserRole, tag.full_path)
|
||||
cat_item.addChild(item)
|
||||
self.tag_tree.blockSignals(False)
|
||||
|
||||
def _on_tag_filter_changed(self, _item, _col) -> None:
|
||||
self.refresh_table()
|
||||
def _on_tag_filter_changed(self, item, _col) -> None:
|
||||
full_path = item.data(0, Qt.UserRole)
|
||||
if full_path is None:
|
||||
return # category header row, not a tag
|
||||
if item.checkState(0) == Qt.Checked:
|
||||
self._active_filter.add(full_path)
|
||||
else:
|
||||
self._active_filter.discard(full_path)
|
||||
# Defer the refresh: rebuilding the tree (clear()) *inside* its own
|
||||
# itemChanged signal deletes the item Qt is still processing → SIGSEGV.
|
||||
# Running it on the next event-loop tick lets Qt finish first.
|
||||
QTimer.singleShot(0, self.refresh_table)
|
||||
|
||||
def _checked_filter_tags(self) -> List[Tag]:
|
||||
def _active_filter_tags(self) -> List[Tag]:
|
||||
tags: List[Tag] = []
|
||||
for i in range(self.tag_tree.topLevelItemCount()):
|
||||
cat = self.tag_tree.topLevelItem(i)
|
||||
for j in range(cat.childCount()):
|
||||
child = cat.child(j)
|
||||
if child.checkState(0) == Qt.Checked:
|
||||
full_path = child.data(0, Qt.UserRole)
|
||||
category, name = full_path.split("/", 1)
|
||||
tags.append(Tag(category, name))
|
||||
for full_path in self._active_filter:
|
||||
category, name = full_path.split("/", 1)
|
||||
tags.append(Tag(category, name))
|
||||
return tags
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -284,15 +389,18 @@ class QtApp(QMainWindow):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def refresh_table(self, *_args) -> None:
|
||||
filtered = self.filehandler.filter_files_by_tags(self._checked_filter_tags())
|
||||
# Tag filter (AND) drives both the table and the sidebar counts; the
|
||||
# search box further narrows only the table.
|
||||
tag_filtered = self.filehandler.filter_files_by_tags(self._active_filter_tags())
|
||||
shown = tag_filtered
|
||||
search = self.search_edit.text().lower() if hasattr(self, "search_edit") else ""
|
||||
if search:
|
||||
filtered = [f for f in filtered if search in (f.title or f.filename).lower()]
|
||||
filtered.sort(key=lambda f: (f.title or f.filename).lower())
|
||||
shown = [f for f in shown if search in (f.title or f.filename).lower()]
|
||||
shown = sorted(shown, key=lambda f: (f.title or f.filename).lower())
|
||||
|
||||
self.table.setRowCount(len(filtered))
|
||||
self.table.setRowCount(len(shown))
|
||||
self.file_rows.clear()
|
||||
for row, f in enumerate(filtered):
|
||||
for row, f in enumerate(shown):
|
||||
self.file_rows[row] = f
|
||||
name = f.title or f.filename
|
||||
tags = ", ".join(t.name for t in f.tags)
|
||||
@@ -303,9 +411,9 @@ class QtApp(QMainWindow):
|
||||
for col, value in enumerate([name, tags, size]):
|
||||
self.table.setItem(row, col, QTableWidgetItem(value))
|
||||
|
||||
self.refresh_sidebar()
|
||||
self.refresh_sidebar(tag_filtered)
|
||||
self._update_selection_status()
|
||||
self.status.showMessage(f"Zobrazeno {len(filtered)} filmů", 4000)
|
||||
self.status.showMessage(f"Zobrazeno {len(shown)} filmů", 4000)
|
||||
|
||||
@staticmethod
|
||||
def _format_size(size_bytes: float) -> str:
|
||||
@@ -322,6 +430,7 @@ class QtApp(QMainWindow):
|
||||
def _show_table_menu(self, pos) -> None:
|
||||
menu = QMenu(self)
|
||||
menu.addAction("Otevřít", self.open_movies)
|
||||
menu.addAction("Přejmenovat…", self.rename_movie)
|
||||
menu.addAction("Přiřadit štítky…", self.assign_tags)
|
||||
menu.addAction("Nastavit datum…", self.set_date)
|
||||
menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd)
|
||||
@@ -382,36 +491,51 @@ class QtApp(QMainWindow):
|
||||
if not self.filehandler.movies_dir:
|
||||
QMessageBox.warning(self, "Pool", "Nejprve nastavte pool (menu Pool → Nastavit pool).")
|
||||
return
|
||||
path, _ = QFileDialog.getOpenFileName(self, "Vyber video soubor")
|
||||
if not path:
|
||||
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory")
|
||||
if not paths:
|
||||
return
|
||||
source = Path(path)
|
||||
dialog = ImportMovieDialog(self, default_title=source.stem)
|
||||
sources = [Path(p) for p in paths]
|
||||
dialog = ImportMoviesDialog(self, sources)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
movie = self.filehandler.import_movie(source, dialog.title, dialog.csfd_link)
|
||||
except Exception as exc: # noqa: BLE001 — surface any import failure to the user
|
||||
QMessageBox.critical(self, "Chyba importu", str(exc))
|
||||
entries = dialog.entries()
|
||||
if not entries:
|
||||
return
|
||||
move = dialog.move_files
|
||||
|
||||
# If a ČSFD link was given, enrich the movie with tags right away
|
||||
if movie.csfd_link:
|
||||
self.status.showMessage("Načítám z ČSFD…")
|
||||
imported: list[File] = []
|
||||
errors: list[str] = []
|
||||
for source, title, csfd_link in entries:
|
||||
try:
|
||||
movie = self.filehandler.import_movie(source, title, csfd_link or None, move=move)
|
||||
imported.append(movie)
|
||||
except Exception as exc: # noqa: BLE001 — surface per-file import failures
|
||||
errors.append(f"{source.name}: {exc}")
|
||||
|
||||
# Enrich the freshly imported movies that carry a ČSFD link
|
||||
with_links = [m for m in imported if m.csfd_link]
|
||||
tags_total = 0
|
||||
if with_links:
|
||||
self.status.showMessage(f"Načítám z ČSFD ({len(with_links)})…")
|
||||
QApplication.setOverrideCursor(Qt.WaitCursor)
|
||||
try:
|
||||
_, tags_total, errors = self._fetch_csfd_for([movie])
|
||||
_, tags_total, csfd_errors = self._fetch_csfd_for(with_links)
|
||||
finally:
|
||||
QApplication.restoreOverrideCursor()
|
||||
if errors:
|
||||
QMessageBox.warning(self, "ČSFD", "Tagy se nepodařilo načíst:\n" + errors[0])
|
||||
else:
|
||||
self.status.showMessage(
|
||||
f"Importováno: {movie.title} (+{tags_total} tagů z ČSFD)", 5000
|
||||
)
|
||||
errors.extend(csfd_errors)
|
||||
|
||||
self.refresh_table()
|
||||
self.status.showMessage(f"Importováno: {dialog.title}", 5000)
|
||||
|
||||
verb = "Přesunuto" if move else "Zkopírováno"
|
||||
summary = f"{verb} {len(imported)}/{len(entries)} filmů (+{tags_total} tagů z ČSFD)."
|
||||
if errors:
|
||||
QMessageBox.warning(
|
||||
self, "Import dokončen s chybami",
|
||||
summary + "\n\nChyby:\n" + "\n".join(errors[:5]),
|
||||
)
|
||||
else:
|
||||
QMessageBox.information(self, "Import", summary)
|
||||
self.status.showMessage(summary, 5000)
|
||||
|
||||
def open_movies(self) -> None:
|
||||
for f in self._selected_movies():
|
||||
@@ -456,6 +580,27 @@ class QtApp(QMainWindow):
|
||||
f.set_date(text.strip() or None)
|
||||
self.refresh_table()
|
||||
|
||||
def rename_movie(self) -> None:
|
||||
files = self._selected_movies()
|
||||
if len(files) != 1:
|
||||
QMessageBox.information(self, "Přejmenovat", "Vyberte právě jeden film.")
|
||||
return
|
||||
f = files[0]
|
||||
current = f.file_path.stem # name without extension
|
||||
text, ok = QInputDialog.getText(
|
||||
self, "Přejmenovat film",
|
||||
f"Nový název (bez přípony {f.file_path.suffix}):", text=current,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
try:
|
||||
self.filehandler.rename_movie(f, text)
|
||||
except (ValueError, FileExistsError, OSError) as exc:
|
||||
QMessageBox.warning(self, "Přejmenování selhalo", str(exc))
|
||||
return
|
||||
self.refresh_table()
|
||||
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000)
|
||||
|
||||
def edit_csfd(self) -> None:
|
||||
files = self._selected_movies()
|
||||
if len(files) != 1:
|
||||
@@ -536,7 +681,9 @@ class QtApp(QMainWindow):
|
||||
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
|
||||
return
|
||||
manager = HardlinkManager(out)
|
||||
created, create_fail, removed, remove_fail = manager.sync_structure(files, FILMOTEKA_CATEGORIES)
|
||||
created, create_fail, removed, remove_fail = manager.sync_structure(
|
||||
files, category_roots=FILMOTEKA_CATEGORY_ROOTS
|
||||
)
|
||||
|
||||
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
|
||||
pool = self.filehandler.pool_dir
|
||||
|
||||
Reference in New Issue
Block a user