diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b7291a..a6925de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,58 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +## 1.5.0 — 2026-06-16 + +### Added +- **Free-form per-movie attributes** (`attributes` in the index, set via *Filmy → + Nastavit atribut…* / context menu): arbitrary `key → value` pairs (e.g. + `collection_sort`) that are exposed to Filmotéka filename templates through + `File.name_context`. So a Kolekce template `"{collection_sort} - {title}{ext}"` + orders the folder as `01 - Dr. No.mkv`, `02 - …`. Empty value removes the attr. +- **Per-category filename template** in the tag schema (`filename_template`, + editable in *Nastavení → Tag schéma…*): the hardlink inside that category's + folders is named from the template (fields `title`/`year`/`rating`/`ext`/ + `stem`/`filename`), e.g. a *Kolekce* with `"{year} - {title}{ext}"` yields + `Dle kolekce/James Bond/1962 - Dr. No.mkv` while every other folder (and the + pool) keeps the plain filename. `HardlinkManager` takes + `category_filename_templates`; `File.name_context()` supplies the fields. +- The "Přiřadit štítky" dialog now lists **all tag-schema categories** (including + empty user ones like *Kolekce*) and has a **"➕ Nový štítek…"** button to create + a tag value (e.g. *Kolekce/James Bond*) on the spot and assign it. Tag values + are created on first assignment — the schema editor defines categories + rules, + this dialog defines/assigns the individual values. + +### Changed +- Tag schema `transform` now shapes only the **Filmotéka folder name**, not the + tag value. ČSFD tags keep the **exact value** (rating → `Hodnocení/90`) while + the grouping transform is applied when building folders (→ `Dle hodnocení/ + 90–100 %`). `csfd_field_values` no longer transforms; `HardlinkManager` takes a + `category_transforms` map (`FileManager.filmoteka_category_transforms`) and + `apply_transform`/`decade_band` run at folder-generation time. + +### Fixed +- Sidebar filter tree no longer re-expands every category on each check/uncheck: + the expanded/collapsed state is preserved across the rebuild (new categories + still default to expanded). + +## 1.3.0 — 2026-06-16 + +### Added +- **Configurable tag schema** (`tag_schema` in the global config, edited via + *Nastavení → Tag schéma…*): a single source of truth for which tag categories + exist, which ČSFD field feeds each (with an optional value transform, e.g. + `decade_band` for the rating), and how each maps into the Filmotéka tree + (`""` = output root, `"Dle …"` = grouping folder, none = filter-only, no + folders). Replaces the hard-coded `FILMOTEKA_CATEGORY_ROOTS` and the fixed + category list in `apply_csfd_tags`; `HardlinkManager` roots are now derived + from the schema (`FileManager.filmoteka_category_roots`). +- **Tag provenance (ČSFD vs user)**: each file tracks which tags came from ČSFD + (`csfd_tags` in the index). `apply_csfd_tags` now **regenerates only the ČSFD + tags** from the schema and leaves user-added tags untouched — so changing a + movie's ČSFD link refreshes its ČSFD tags without wiping your own. + +## 1.2.0 — 2026-06-16 + ### Added - Fork of the former **Tagger** project as **Curator**, a movie-library manager. - **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and @@ -36,6 +88,14 @@ Each version entry uses these sections (include only those that apply): year) and fills in the first ČSFD search hit (`find_csfd_url` → `search_movies`, reusing one Anubis session). Existing links are never overwritten; results are a suggestion the user can review before importing. +- Import dialog now **highlights in red any title that already exists in the + pool** (live as you type) and lets you **remove a single row** (✕ per row), so + you can drop one file without discarding the whole batch and re-picking. +- **Conflict resolution on import**: when a row is left red (name already in the + pool), import asks whether to **replace** the existing movie(s) with the new + file, **keep both** (numeric suffix), or **cancel**. `FileManager.import_movie` + gained an `on_conflict` policy (`suffix` / `replace` / `skip`); `replace` + evicts the existing same-named movie (file + index metadata) first. - `File` now stores `title` and `csfd_link`. - New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import, tag filter sidebar, movie table, one-click Filmotéka generation), replacing the @@ -115,6 +175,10 @@ Each version entry uses these sections (include only those that apply): - `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility). ### Removed +- Duplicate `src/core/constants.py` (hard-coded stale `VERSION = "v1.0.3"`). The + GUI window title imported from it, so it showed the wrong version. All imports + now use `src/constants.py` (version derived from `pyproject.toml`), which also + absorbed `APP_VIEWPORT`; the window title uses `APP_TITLE` → "Curator vX.Y.Z". - Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating) and **Barva** (color) categories in `TagManager`, and the automatic **Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now diff --git a/PROJECT.md b/PROJECT.md index 0ebc9b7..4a6a8bf 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -102,6 +102,28 @@ movie table, and one-click Filmotéka generation. `HardlinkManager` supports an empty root (tag folders placed directly at the output root) and restricts obsolete cleanup to the tag-tree's own top-level folders so mirrors are never touched. +- **Tag schema (config-driven, not hard-coded):** the categories, their ČSFD + source field + transform, and their Filmotéka folder mapping all live in + `tag_schema` in the global config (default `config.DEFAULT_TAG_SCHEMA`, edited + via *Nastavení → Tag schéma…*). Both `apply_csfd_tags` (which fields → tags) + and the Filmotéka layout (`FileManager.filmoteka_category_roots`) read from it, + so adding a category or changing a folder rule needs no code change. A category + can be made filter-only (no folders) by setting its `filmoteka_root` to null. + The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep + the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`); + it is applied at Filmotéka generation via `filmoteka_category_transforms`. +- **Per-category filename template** (`filename_template` in a schema entry): the + hardlink name **inside that category's folders only** is rendered from the + movie's metadata (`File.name_context`: title/year/rating/ext/stem/filename plus + any free-form attributes), e.g. a Kolekce with `"{collection_sort} - {title}{ext}"`. + Other folders and the pool file keep the plain name; applied via + `filmoteka_category_filename_templates`. +- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary + `key → value` metadata stored in the index and merged into `name_context`, so + custom fields like `collection_sort` can drive filename templates. +- **Tag provenance (ČSFD vs user):** each file records which tags came from ČSFD + (`csfd_tags`). Re-fetching regenerates only those; user-added tags are kept, so + changing a movie's ČSFD link refreshes ČSFD tags without losing manual ones. ## Tasks diff --git a/pyproject.toml b/pyproject.toml index e6423f2..59d50ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "1.0.0" +version = "1.5.0" description = "" authors = [ {name = "jan.doubravsky@gmail.com"} diff --git a/scripts/strip_rating_bands.py b/scripts/strip_rating_bands.py new file mode 100644 index 0000000..90717c1 --- /dev/null +++ b/scripts/strip_rating_bands.py @@ -0,0 +1,109 @@ +"""One-off migration: drop the old decade-band rating tags from a pool index. + +Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now +the tag carries the exact value (``Hodnocení/90``) and the band is only a folder. +This removes the legacy band tags (``Hodnocení/ %``) so re-fetching from +ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped +backup of the index is written first. + +Usage: + poetry run python scripts/strip_rating_bands.py [] [--category "Hodnocení"] +""" + +from __future__ import annotations + +import re +import sys +import json +import shutil +import argparse +from pathlib import Path +from datetime import datetime + +from loguru import logger + +# Allow running as a plain script (``python scripts/...``) by exposing the repo root. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from src.core.config import load_global_config # noqa: E402 +from src.core.pool_index import INDEX_FILENAME # noqa: E402 + +# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%". +_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%") + + +def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]: + """Return (kept tags, removed count), dropping ``category`` band tags.""" + prefix = f"{category}/" + kept = [ + t for t in tags + if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t)) + ] + return kept, len(tags) - len(kept) + + +def migrate(index_path: Path, category: str) -> int: + """Remove band rating tags in place; return number of tags removed.""" + with open(index_path, "r", encoding="utf-8") as f: + data = json.load(f) + + movies: dict[str, dict] = data.get("movies", {}) + total_removed = 0 + affected = 0 + for key, record in movies.items(): + tags = record.get("tags", []) + kept, removed = _strip_bands(tags, category) + if removed: + record["tags"] = kept + # also drop them from the ČSFD provenance set, if present + if isinstance(record.get("csfd_tags"), list): + record["csfd_tags"] = [ + t for t in record["csfd_tags"] + if not (t.startswith(f"{category}/") and _BAND_RE.search(t)) + ] + total_removed += removed + affected += 1 + logger.debug(f"{key}: removed {removed} band tag(s)") + + if total_removed == 0: + logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate") + return 0 + + backup = index_path.with_suffix( + index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}" + ) + shutil.copy2(index_path, backup) + logger.info(f"Backup written: {backup}") + + with open(index_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + logger.info( + f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)" + ) + return total_removed + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "pool_dir", + nargs="?", + help="Pool root (default: pool_dir from the global config)", + ) + parser.add_argument("--category", default="Hodnocení", help="Rating category") + args = parser.parse_args() + + pool_dir = args.pool_dir or load_global_config().get("pool_dir") + if not pool_dir: + parser.error("No pool_dir given and none configured in the global config") + + index_path = Path(pool_dir) / INDEX_FILENAME + if not index_path.exists(): + parser.error(f"No index found at {index_path}") + + migrate(index_path, args.category) + + +if __name__ == "__main__": + main() diff --git a/src/_version.py b/src/_version.py index e922c7e..5623154 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.0.0" +__version__ = "1.5.0" diff --git a/src/constants.py b/src/constants.py index 49c324b..8189b80 100644 --- a/src/constants.py +++ b/src/constants.py @@ -5,9 +5,8 @@ This module derives the app version and debug flag. The version is loaded from `pyproject.toml` (preferred) with a generated `src/_version.py` fallback for frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`). -Note: per-feature constants (window size, tag colors, …) live in -`src/core/constants.py`; this module is only the version/debug surface used by -the build tooling and frozen builds. +It is the single source of truth for app metadata used across the GUI and build +tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`). """ import os @@ -64,3 +63,6 @@ APP_TITLE: str = f"{APP_NAME} v{APP_VERSION}" + ("-DEV" if ENV_DEBUG else "") # Backwards-compatible alias used by prebuild.py VERSION: str = APP_VERSION + +# Default main-window geometry (used when no saved geometry exists) +APP_VIEWPORT: str = "1000x700" diff --git a/src/core/config.py b/src/core/config.py index 540a939..6d7d04b 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -20,6 +20,28 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag" # GLOBAL CONFIG - Application settings # ============================================================================= +# Tag schema: the single source of truth for which tag categories exist, how +# they are derived from ČSFD, and how they map to the Filmotéka folder tree. +# Each entry: +# category — tag category name (e.g. "Žánr") +# csfd_field — CSFDMovie attribute feeding it (genres / year / countries / +# rating / directors / actors), or None for a user-only category +# transform — named value transform: None (str) or "decade_band" (rating) +# filmoteka_root — folder under the output: "" = at the root (e.g. genres), +# "Dle X" = grouping folder, None = filterable but no folders +# filename_template — optional per-category hardlink name used only inside this +# category's folders (e.g. "{year} - {title}{ext}"); fields: +# title / year / rating / ext / stem / filename. Absent/None = +# keep the pool filename. Pool files are never renamed by this. +DEFAULT_TAG_SCHEMA = [ + {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, + {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"}, + {"category": "Země původu", "csfd_field": "countries", "transform": None, + "filmoteka_root": "Dle země původu"}, + {"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band", + "filmoteka_root": "Dle hodnocení"}, +] + DEFAULT_GLOBAL_CONFIG = { "window_geometry": "1200x800", "window_maximized": False, @@ -29,6 +51,7 @@ DEFAULT_GLOBAL_CONFIG = { "pool_dir": None, # managed pool root (single source of truth) "filmoteka_dir": None, # generated Filmotéka output (hardlink tree) "copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is) + "tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules } diff --git a/src/core/constants.py b/src/core/constants.py deleted file mode 100644 index 4256897..0000000 --- a/src/core/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -# src/core/constants.py -VERSION = "v1.0.3" -APP_NAME = "Curator" -APP_VIEWPORT = "1000x700" \ No newline at end of file diff --git a/src/core/csfd.py b/src/core/csfd.py index 6a19827..db39e4c 100644 --- a/src/core/csfd.py +++ b/src/core/csfd.py @@ -137,6 +137,46 @@ def rating_band(rating: int) -> str: return f"{low}–{high} %" +def _decade_band(value) -> str: + """``decade_band`` transform: pull the leading integer and bucket it.""" + match = re.search(r"\d+", str(value)) + return rating_band(int(match.group())) if match else str(value) + + +# Named value transforms usable from the tag schema (config-driven, extensible). +# These are applied when building Filmotéka FOLDER names — the tag itself keeps +# the exact value (e.g. rating 90 → tag "Hodnocení/90", folder "…/90–100 %"). +TAG_TRANSFORMS = { + None: str, + "identity": str, + "decade_band": _decade_band, +} + + +def apply_transform(value: str, transform: Optional[str]) -> str: + """Apply a named tag-schema transform to a value, returning a string.""" + fn = TAG_TRANSFORMS.get(transform, str) + try: + return str(fn(value)) + except (ValueError, TypeError, AttributeError): + return str(value) + + +def csfd_field_values(movie: "CSFDMovie", field: str) -> list[str]: + """Exact tag values for a CSFDMovie attribute (scalar or list), as strings. + + No transform is applied here — tags carry the precise value; any grouping + transform happens later, when the Filmotéka folder names are built. + Unknown field / missing value yields ``[]``. + """ + raw = getattr(movie, field, None) + if raw is None or raw == "": + return [] + items = list(raw) if isinstance(raw, (list, tuple)) else [raw] + values = [str(item).strip() for item in items if item is not None and item != ""] + return [v for v in values if v] + + def _split_countries(text: Optional[str]) -> list[str]: """Split a ČSFD origin country string into individual countries. diff --git a/src/core/file.py b/src/core/file.py index 5e11342..5ac2c76 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -26,6 +26,12 @@ class File: self.csfd_link: str | None = None # Cached CSFD data — avoids re-fetching on every open self.csfd_cache: dict | None = None + # full_paths of tags that came from ČSFD (vs. user-added); only these are + # regenerated when the ČSFD link changes — user tags are preserved. + self.csfd_tag_paths: set[str] = set() + # free-form per-movie attributes (e.g. "collection_sort"); usable in + # Filmotéka filename templates via name_context. + self.attributes: dict[str, str] = {} self.get_metadata() def get_metadata(self) -> None: @@ -52,6 +58,8 @@ class File: self.title = None self.csfd_link = None self.csfd_cache = None + self.csfd_tag_paths = set() + self.attributes = {} def _build_record(self) -> dict: data = { @@ -59,10 +67,13 @@ class File: "ignored": self.ignored, # ukládáme full_path tagů "tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], + # which of the tags came from ČSFD (provenance) + "csfd_tags": sorted(self.csfd_tag_paths), # date může být None "date": self.date, "title": self.title, "csfd_link": self.csfd_link, + "attributes": self.attributes, } if self.csfd_cache is not None: data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} @@ -75,6 +86,9 @@ class File: self.date = data.get("date", None) self.title = data.get("title", None) self.csfd_link = data.get("csfd_link", None) + # Legacy records have no provenance → treated as all-user (empty set) + self.csfd_tag_paths = set(data.get("csfd_tags", [])) + self.attributes = dict(data.get("attributes", {})) raw_cache = data.get("csfd_cache") if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"} @@ -103,6 +117,44 @@ class File: data = json.load(f) self._apply_record(data) + def name_context(self) -> dict: + """Fields for a Filmotéka filename template (see config tag schema). + + Provides title / year / rating / ext / stem / filename from the movie's + metadata (year & rating come from the ČSFD cache, year falling back to a + ``Rok`` tag). Missing values render as an empty string. + """ + cache = self.csfd_cache or {} + year = cache.get("year") + if year is None: + for t in self.tags: + if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit(): + year = t.name + break + rating = cache.get("rating") + # user attributes first; core fields take precedence over same-named keys + context = dict(self.attributes) + context.update({ + "title": self.title or self.file_path.stem, + "year": "" if year is None else year, + "rating": "" if rating is None else rating, + "ext": self.file_path.suffix, + "stem": self.file_path.stem, + "filename": self.filename, + }) + return context + + def set_attribute(self, key: str, value: str | None) -> None: + """Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.""" + key = key.strip() + if not key: + return + if value: + self.attributes[key] = value + else: + self.attributes.pop(key, None) + self.save_metadata() + def delete_metadata(self) -> None: """Remove this file's metadata (from the index, or its sidecar file).""" if self.index is not None: @@ -153,19 +205,14 @@ class File: except Exception: return None - def apply_csfd_tags( - 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; cachuje data. + def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict: + """Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu. - 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. + ``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro + každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu + (s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance + ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy. + Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu. Returns: dict s klíči 'success', 'movie'/'error', 'tags_added' @@ -173,6 +220,10 @@ class File: if not self.csfd_link: return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []} + if schema is None: + from .config import DEFAULT_TAG_SCHEMA + schema = DEFAULT_TAG_SCHEMA + try: from .csfd import fetch_movie movie = fetch_movie(self.csfd_link) @@ -182,25 +233,25 @@ class File: except Exception as e: return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []} + from .csfd import csfd_field_values + + # Drop previous ČSFD-sourced tags; keep user-added ones. + self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths] + self.csfd_tag_paths = set() + tags_added: list[str] = [] - - def _add(category: str, name: str) -> None: - tag_obj = self.tagmanager.add_tag(category, name) if self.tagmanager else Tag(category, name) - if tag_obj not in self.tags: - self.tags.append(tag_obj) - tags_added.append(f"{category}/{name}") - - if add_genres and movie.genres: - for genre in movie.genres: - _add("Žánr", genre) - if add_year and movie.year: - _add("Rok", str(movie.year)) - 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)) + for entry in schema: + field = entry.get("csfd_field") + if not field: + continue # user-only category + category = entry["category"] + for value in csfd_field_values(movie, field): + tag_obj = (self.tagmanager.add_tag(category, value) + if self.tagmanager else Tag(category, value)) + if tag_obj not in self.tags: + self.tags.append(tag_obj) + tags_added.append(tag_obj.full_path) + self.csfd_tag_paths.add(tag_obj.full_path) # Use the CSFD title if we don't have one yet if movie.title and not self.title: diff --git a/src/core/file_manager.py b/src/core/file_manager.py index fa31a58..ea05414 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -60,6 +60,49 @@ class FileManager: self.global_config["copyasis_folders"] = cleaned save_global_config(self.global_config) + @property + def tag_schema(self) -> list[dict]: + """Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA).""" + from src.core.config import DEFAULT_TAG_SCHEMA + return self.global_config.get("tag_schema", DEFAULT_TAG_SCHEMA) + + def set_tag_schema(self, schema: list[dict]) -> None: + """Set the tag schema and persist it.""" + self.global_config["tag_schema"] = schema + save_global_config(self.global_config) + + def filmoteka_category_roots(self) -> dict[str, str]: + """Category → output root-folder map derived from the tag schema. + + Categories with ``filmoteka_root`` set to None are filterable but get no + folders in the generated tree. + """ + return { + e["category"]: e["filmoteka_root"] + for e in self.tag_schema + if e.get("filmoteka_root") is not None + } + + def filmoteka_category_transforms(self) -> dict[str, str]: + """Category → folder transform-name map (for grouping, e.g. rating bands). + + Tags keep their exact value; this transform only shapes the folder name + in the generated tree. Categories without a transform are omitted. + """ + return { + e["category"]: e["transform"] + for e in self.tag_schema + if e.get("filmoteka_root") is not None and e.get("transform") + } + + def filmoteka_category_filename_templates(self) -> dict[str, str]: + """Category → hardlink-name template map (applied inside that category).""" + return { + e["category"]: e["filename_template"] + for e in self.tag_schema + if e.get("filmoteka_root") is not None and e.get("filename_template") + } + @property def filmoteka_dir(self) -> Path | None: value = self.global_config.get("filmoteka_dir") @@ -93,13 +136,35 @@ class FileManager: file_obj = File(each, self.tagmanager, index=self.index) self.filelist.append(file_obj) + def pooled_with_stem(self, title: str) -> list[File]: + """Pooled movies whose filename stem matches ``title`` (case-insensitive).""" + stem = title.strip().lower() + return [f for f in self.filelist if Path(f.filename).stem.lower() == stem] + + def _evict(self, file_obj: File) -> None: + """Delete a pooled movie: its metadata, the file, and the list entry.""" + file_obj.delete_metadata() + if file_obj.file_path.exists(): + file_obj.file_path.unlink() + if file_obj in self.filelist: + self.filelist.remove(file_obj) + def import_movie( - self, source: Path, title: str, csfd_link: str | None = None, move: bool = False - ) -> File: + self, source: Path, title: str, csfd_link: str | None = None, + move: bool = False, on_conflict: str = "suffix", + ) -> File | None: """Bring a video file into pool/Filmy as 'Title.ext' and index its metadata. By default the original is **copied** (non-destructive). With ``move=True`` the source file is moved into the pool instead, leaving nothing behind. + + ``on_conflict`` decides what happens when a pooled movie of the same name + already exists: + + - ``"suffix"`` (default): keep both, the new file gets a ``_N`` suffix. + - ``"replace"``: evict the existing same-named movie(s) (file + metadata) + and import the new one under the plain name. + - ``"skip"``: do not import; return ``None``. """ movies = self.movies_dir pool = self.pool_dir @@ -114,11 +179,22 @@ class FileManager: safe_title = title.strip() or source.stem target = movies / f"{safe_title}{source.suffix}" - # Avoid clobbering an existing movie of the same name - counter = 1 - while target.exists(): - target = movies / f"{safe_title}_{counter}{source.suffix}" - counter += 1 + existing = self.pooled_with_stem(safe_title) + conflict = bool(existing) or target.exists() + + if conflict and on_conflict == "skip": + return None + if conflict and on_conflict == "replace": + for f in existing: + self._evict(f) + if target.exists(): + target.unlink() + else: + # "suffix": never clobber an existing exact filename + counter = 1 + while target.exists(): + target = movies / f"{safe_title}_{counter}{source.suffix}" + counter += 1 if move: shutil.move(str(source), str(target)) diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py index 047a838..c8a8d89 100644 --- a/src/core/hardlink_manager.py +++ b/src/core/hardlink_manager.py @@ -23,6 +23,13 @@ from typing import List, Tuple, Optional, Dict, Set from .file import File +class _SafeDict(dict): + """dict for str.format_map that leaves unknown fields as an empty string.""" + + def __missing__(self, key): # noqa: ANN001 + return "" + + class HardlinkManager: """Manager for creating hardlink-based directory structures from tagged files. @@ -61,7 +68,40 @@ class HardlinkManager: return {cat: cat for cat in categories} return None - def _target_dir(self, tag, roots: Optional[Dict[str, str]]) -> Optional[Path]: + def _link_name( + self, file_obj: File, tag, templates: Optional[Dict[str, str]] + ) -> str: + """Hardlink filename for a tag — a per-category template or the pool name. + + Applies ``templates[tag.category]`` (e.g. ``"{year} - {title}{ext}"``) to + the file's ``name_context``; path separators are flattened. Any failure or + empty result falls back to the pool filename. + """ + template = templates.get(tag.category) if templates else None + if not template: + return file_obj.filename + try: + rendered = template.format_map(_SafeDict(file_obj.name_context())) + except (ValueError, KeyError, IndexError, AttributeError): + return file_obj.filename + rendered = rendered.replace("/", "-").replace("\\", "-").strip() + return rendered or file_obj.filename + + def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str: + """Folder name for a tag — its value run through the category transform. + + The tag keeps its exact value; the grouping (e.g. rating → ten-point + band) only happens here, when naming the folder. + """ + if transforms and tag.category in transforms: + from .csfd import apply_transform + return apply_transform(tag.name, transforms[tag.category]) + return tag.name + + def _target_dir( + self, tag, roots: Optional[Dict[str, str]], + transforms: Optional[Dict[str, str]] = None, + ) -> Optional[Path]: """Output directory for a tag, or None if its category is excluded.""" if roots is None: folder = tag.category @@ -70,17 +110,18 @@ class HardlinkManager: else: return None base = self.output_dir / folder if folder else self.output_dir - return base / tag.name + return base / self._folder_value(tag, transforms) def _managed_top_dirs( - self, files: List[File], roots: Optional[Dict[str, str]] + self, files: List[File], roots: Optional[Dict[str, str]], + transforms: Optional[Dict[str, str]] = None, ) -> 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). + (transformed) tag values 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 @@ -92,7 +133,7 @@ class HardlinkManager: for file_obj in files: for tag in file_obj.tags: if tag.category == cat: - tops.add(tag.name) + tops.add(self._folder_value(tag, transforms)) return tops def create_structure_for_files( @@ -101,6 +142,8 @@ class HardlinkManager: categories: Optional[List[str]] = None, dry_run: bool = False, category_roots: Optional[Dict[str, str]] = None, + category_transforms: Optional[Dict[str, str]] = None, + category_filename_templates: Optional[Dict[str, str]] = None, ) -> Tuple[int, int]: """ Create hardlink structure for given files based on their tags. @@ -111,6 +154,10 @@ class HardlinkManager: dry_run: If True, only simulate without creating actual links category_roots: Optional category → root-folder map (see class doc); overrides ``categories`` when given. + category_transforms: Optional category → transform-name map applied to + the tag value when naming its folder (e.g. rating → decade band). + category_filename_templates: Optional category → hardlink-name template + applied only inside that category's folders. Returns: Tuple of (successful_links, failed_links) @@ -128,10 +175,11 @@ class HardlinkManager: for tag in file_obj.tags: # Resolve the target dir; None means this category is excluded - target_dir = self._target_dir(tag, roots) + target_dir = self._target_dir(tag, roots, category_transforms) if target_dir is None: continue - target_file = target_dir / file_obj.filename + target_file = target_dir / self._link_name( + file_obj, tag, category_filename_templates) try: if not dry_run: @@ -269,6 +317,8 @@ class HardlinkManager: files: List[File], categories: Optional[List[str]] = None, category_roots: Optional[Dict[str, str]] = None, + category_transforms: Optional[Dict[str, str]] = None, + category_filename_templates: Optional[Dict[str, str]] = None, ) -> List[Tuple[Path, Path]]: """ Get a preview of what links would be created. @@ -278,6 +328,8 @@ class HardlinkManager: categories: Optional list of categories to include category_roots: Optional category → root-folder map (overrides ``categories`` when given). + category_transforms: Optional category → transform-name map for folders. + category_filename_templates: Optional category → hardlink-name template. Returns: List of tuples (source_path, target_path) @@ -290,10 +342,11 @@ class HardlinkManager: continue for tag in file_obj.tags: - target_dir = self._target_dir(tag, roots) + target_dir = self._target_dir(tag, roots, category_transforms) if target_dir is None: continue - target_file = target_dir / file_obj.filename + target_file = target_dir / self._link_name( + file_obj, tag, category_filename_templates) preview.append((file_obj.file_path, target_file)) @@ -304,6 +357,8 @@ class HardlinkManager: files: List[File], categories: Optional[List[str]] = None, category_roots: Optional[Dict[str, str]] = None, + category_transforms: Optional[Dict[str, str]] = None, + category_filename_templates: Optional[Dict[str, str]] = None, ) -> List[Tuple[Path, Path]]: """ Find hardlinks in the output directory that no longer match file tags. @@ -318,6 +373,7 @@ class HardlinkManager: categories: Optional list of categories to check (None = all) category_roots: Optional category → root-folder map (overrides ``categories`` when given). + category_transforms: Optional category → transform-name map for folders. Returns: List of tuples (link_path, source_path) for obsolete links @@ -346,15 +402,16 @@ class HardlinkManager: expected_paths[inode] = set() for tag in file_obj.tags: - target_dir = self._target_dir(tag, roots) + target_dir = self._target_dir(tag, roots, category_transforms) if target_dir is None: continue - expected_paths[inode].add(target_dir / file_obj.filename) + expected_paths[inode].add(target_dir / self._link_name( + file_obj, tag, category_filename_templates)) except OSError: continue # Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors) - top_dirs = self._managed_top_dirs(files, roots) + top_dirs = self._managed_top_dirs(files, roots, category_transforms) for top in self.output_dir.iterdir(): if not top.is_dir(): continue @@ -382,6 +439,8 @@ class HardlinkManager: categories: Optional[List[str]] = None, dry_run: bool = False, category_roots: Optional[Dict[str, str]] = None, + category_transforms: Optional[Dict[str, str]] = None, + category_filename_templates: Optional[Dict[str, str]] = None, ) -> Tuple[int, List[Path]]: """ Remove hardlinks that no longer match file tags. @@ -392,11 +451,15 @@ class HardlinkManager: dry_run: If True, only return what would be removed category_roots: Optional category → root-folder map (overrides ``categories`` when given). + category_transforms: Optional category → transform-name map for folders. + category_filename_templates: Optional category → hardlink-name template. Returns: Tuple of (removed_count, list_of_removed_paths) """ - obsolete = self.find_obsolete_links(files, categories, category_roots) + obsolete = self.find_obsolete_links( + files, categories, category_roots, category_transforms, + category_filename_templates) removed_paths = [] if dry_run: @@ -420,6 +483,8 @@ class HardlinkManager: categories: Optional[List[str]] = None, dry_run: bool = False, category_roots: Optional[Dict[str, str]] = None, + category_transforms: Optional[Dict[str, str]] = None, + category_filename_templates: Optional[Dict[str, str]] = None, ) -> Tuple[int, int, int, int]: """ Synchronize hardlink structure with current file tags. @@ -434,22 +499,28 @@ class HardlinkManager: dry_run: If True, only simulate category_roots: Optional category → root-folder map (overrides ``categories`` when given). + category_transforms: Optional category → transform-name map for folders. + category_filename_templates: Optional category → hardlink-name template. 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, category_roots)) + obsolete_count = len(self.find_obsolete_links( + files, categories, category_roots, category_transforms, + category_filename_templates)) # Remove obsolete links removed, removed_paths = self.remove_obsolete_links( - files, categories, dry_run, category_roots + files, categories, dry_run, category_roots, category_transforms, + category_filename_templates ) 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, category_roots + files, categories, dry_run, category_roots, category_transforms, + category_filename_templates ) return created, create_failed, removed, remove_failed diff --git a/src/ui/gui.py b/src/ui/gui.py index a6287ff..996f172 100644 --- a/src/ui/gui.py +++ b/src/ui/gui.py @@ -15,7 +15,7 @@ from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER from src.core.file import File from src.core.tag import Tag from src.core.list_manager import ListManager -from src.core.constants import APP_NAME, VERSION, APP_VIEWPORT +from src.constants import APP_NAME, VERSION, APP_VIEWPORT from src.core.config import save_global_config from src.core.hardlink_manager import HardlinkManager diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index b8d085f..cc6aa6a 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -20,25 +20,16 @@ from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, - QHeaderView, QMenu, QAbstractItemView, QCheckBox, + QHeaderView, QMenu, QAbstractItemView, QCheckBox, QComboBox, ) from src.core.file_manager import FileManager from src.core.tag_manager import TagManager from src.core.file import File from src.core.tag import Tag -from src.core.constants import APP_NAME, VERSION +from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager -# 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 ImportMoviesDialog(QDialog): @@ -49,23 +40,29 @@ class ImportMoviesDialog(QDialog): the files are copied (default, non-destructive) or moved into the pool. """ - def __init__(self, parent: QWidget, sources: List[Path]) -> None: + def __init__( + self, parent: QWidget, sources: List[Path], + existing_names: set[str] | None = None, + ) -> None: super().__init__(parent) self.setWindowTitle("Importovat filmy do poolu") - self.setMinimumSize(680, 360) + self.setMinimumSize(720, 360) + # Lower-cased names already present in the pool (for collision highlight) + self.existing_names = {n.lower() for n in (existing_names or set())} # (source path, title field, ČSFD field) per row self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = [] layout = QVBoxLayout(self) - self.table = QTableWidget(0, 3) - self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz"]) + self.table = QTableWidget(0, 4) + 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) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() @@ -97,11 +94,35 @@ class ImportMoviesDialog(QDialog): name_item.setFlags(Qt.ItemIsEnabled) self.table.setItem(row, 0, name_item) title_edit = QLineEdit(source.stem) + title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) csfd_edit = QLineEdit() csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") + 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, csfd_edit) + self.table.setCellWidget(row, 3, remove_btn) self._rows.append((source, title_edit, csfd_edit)) + self._mark_collision(title_edit) + + def _mark_collision(self, title_edit: QLineEdit) -> None: + """Highlight the title in red when that name already exists in the pool.""" + clashes = title_edit.text().strip().lower() in self.existing_names + title_edit.setStyleSheet( + "QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "") + title_edit.setToolTip( + "V poolu už existuje film s tímto názvem — při importu dostane " + "číselnou příponu." if clashes else "") + + def _remove_row(self, title_edit: QLineEdit) -> None: + """Drop a single file from the import list without restarting the dialog.""" + for index, (_src, edit, _csfd) in enumerate(self._rows): + if edit is title_edit: + self.table.removeRow(index) + self._rows.pop(index) + return def _add_files(self) -> None: paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory") @@ -157,13 +178,19 @@ class AssignTagsDialog(QDialog): Result maps full_path -> 1 (assign), 0 (remove), 2 (leave mixed/unchanged). """ - def __init__(self, parent: QWidget, tagmanager: TagManager, files: List[File]) -> None: + 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] = {} - - file_tag_sets = [{t.full_path for t in f.tags} for f in files] + 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)}")) @@ -172,25 +199,17 @@ class AssignTagsDialog(QDialog): self.tree.setHeaderHidden(True) layout.addWidget(self.tree) - self._items: list[tuple[str, QTreeWidgetItem]] = [] - for category in self.tagmanager_categories(tagmanager): - cat_item = QTreeWidgetItem([category]) - cat_item.setFlags(Qt.ItemIsEnabled) - self.tree.addTopLevelItem(cat_item) - cat_item.setExpanded(True) + # 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): - have = sum(1 for s in file_tag_sets if tag.full_path in s) - if have == 0: - state = Qt.Unchecked - elif have == len(files): - state = Qt.Checked - else: - state = Qt.PartiallyChecked - item = QTreeWidgetItem([tag.name]) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsAutoTristate) - item.setCheckState(0, state) - cat_item.addChild(item) - self._items.append((tag.full_path, item)) + 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.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) @@ -201,12 +220,194 @@ class AssignTagsDialog(QDialog): 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.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.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.Checked + elif have == 0: + state = Qt.Unchecked + else: + state = Qt.PartiallyChecked + item = QTreeWidgetItem([name]) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.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.Checked: 1, Qt.Unchecked: 0, Qt.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 ČSFD source and folder rules. + + Each row defines a tag category, optionally which ČSFD field feeds it (with a + value transform), and how it maps into the Filmotéka tree (a grouping folder, + the output root, or no folders at all). + """ + + # (label, csfd_field) — the CSFDMovie attributes a category can draw from + FIELD_CHOICES = [ + ("(žádné — uživatelská)", None), + ("Žánry (genres)", "genres"), + ("Rok (year)", "year"), + ("Země (countries)", "countries"), + ("Hodnocení (rating)", "rating"), + ("Režie (directors)", "directors"), + ("Herci (actors)", "actors"), + ("Délka (duration)", "duration"), + ] + TRANSFORM_CHOICES = [ + ("(žá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 ČSFD 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", "ČSFD pole", "Transform", "Tvořit složky", "Složka", + "Šablona názvu"]) + hdr = self.table.horizontalHeader() + hdr.setSectionResizeMode(0, QHeaderView.Stretch) + hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(2, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(3, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(4, QHeaderView.Stretch) + hdr.setSectionResizeMode(5, QHeaderView.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.Ok | QDialogButtonBox.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 = self.table.cellWidget(row, 0).text().strip() + if not category: + continue + make_folders = self.table.cellWidget(row, 3).isChecked() + folder = self.table.cellWidget(row, 4).text().strip() + template = self.table.cellWidget(row, 5).text().strip() + schema.append({ + "category": category, + "csfd_field": self.table.cellWidget(row, 1).currentData(), + "transform": 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 QtApp(QMainWindow): def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None: super().__init__() @@ -218,7 +419,7 @@ class QtApp(QMainWindow): self._active_filter: set[str] = set() self.filehandler.on_files_changed = lambda _=None: self.refresh_table() - self.setWindowTitle(f"{APP_NAME} {VERSION} — Filmotéka") + self.setWindowTitle(f"{APP_TITLE} — Filmotéka") geometry = self.filehandler.global_config.get("window_geometry", "1200x800") try: w, h = (int(x) for x in geometry.lower().split("x")) @@ -256,6 +457,7 @@ class QtApp(QMainWindow): 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, "Nastavit atribut…", self.set_attribute) self._add_action(movie_menu, "Upravit ČSFD odkaz…", self.edit_csfd) self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) movie_menu.addSeparator() @@ -265,6 +467,9 @@ class QtApp(QMainWindow): self._add_action(film_menu, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G") self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) + settings_menu = bar.addMenu("&Nastavení") + self._add_action(settings_menu, "Tag schéma…", self.edit_tag_schema) + def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction: action = QAction(text, self) if shortcut: @@ -347,13 +552,20 @@ class QtApp(QMainWindow): 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) + 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.ItemIsEnabled) self.tag_tree.addTopLevelItem(cat_item) - cat_item.setExpanded(True) + 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})"]) @@ -433,6 +645,7 @@ class QtApp(QMainWindow): 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("Nastavit atribut…", self.set_attribute) menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd) menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) menu.addSeparator() @@ -495,7 +708,8 @@ class QtApp(QMainWindow): if not paths: return sources = [Path(p) for p in paths] - dialog = ImportMoviesDialog(self, sources) + existing_names = {Path(f.filename).stem for f in self.filehandler.filelist} + dialog = ImportMoviesDialog(self, sources, existing_names) if dialog.exec() != QDialog.Accepted: return entries = dialog.entries() @@ -503,12 +717,25 @@ class QtApp(QMainWindow): return move = dialog.move_files + # Files whose name already exists in the pool (the red-highlighted rows) + colliding = [t for s, t, _ in entries if self.filehandler.pooled_with_stem(t)] + on_conflict = "suffix" + if colliding: + on_conflict = self._resolve_import_conflicts(colliding) + if on_conflict is None: + return # user cancelled the whole import + imported: list[File] = [] + skipped = 0 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) + movie = self.filehandler.import_movie( + source, title, csfd_link or None, move=move, on_conflict=on_conflict) + if movie is None: + skipped += 1 + else: + imported.append(movie) except Exception as exc: # noqa: BLE001 — surface per-file import failures errors.append(f"{source.name}: {exc}") @@ -537,6 +764,29 @@ class QtApp(QMainWindow): QMessageBox.information(self, "Import", summary) self.status.showMessage(summary, 5000) + def _resolve_import_conflicts(self, names: list[str]) -> str | None: + """Ask how to handle names already in the pool. Returns the on_conflict + policy ("replace"/"suffix") or None if the user cancels the import.""" + box = QMessageBox(self) + box.setIcon(QMessageBox.Question) + box.setWindowTitle("Název už v poolu existuje") + 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"V poolu už existují filmy s těmito názvy:\n{listing}{more}\n\n" + "Co s nimi?" + ) + replace_btn = box.addButton("Nahradit novými", QMessageBox.AcceptRole) + keep_btn = box.addButton("Ponechat oba (přípona)", QMessageBox.ActionRole) + box.addButton("Zrušit import", QMessageBox.RejectRole) + box.exec() + clicked = box.clickedButton() + if clicked is replace_btn: + return "replace" + if clicked is keep_btn: + return "suffix" + return None + def open_movies(self) -> None: for f in self._selected_movies(): self._open_path(f.file_path) @@ -557,7 +807,8 @@ class QtApp(QMainWindow): if not files: QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.") return - dialog = AssignTagsDialog(self, self.tagmanager, files) + schema_categories = [e["category"] for e in self.filehandler.tag_schema] + dialog = AssignTagsDialog(self, self.tagmanager, files, schema_categories) if dialog.exec() != QDialog.Accepted: return for full_path, state in dialog.result_map.items(): @@ -580,6 +831,30 @@ class QtApp(QMainWindow): f.set_date(text.strip() or None) self.refresh_table() + def set_attribute(self) -> None: + files = self._selected_movies() + if not files: + QMessageBox.information(self, "Atribut", "Nejprve vyberte filmy.") + return + known = sorted({k for f in files for k in f.attributes}) + key, ok = QInputDialog.getItem( + self, "Nastavit atribut", + "Název atributu (např. collection_sort):", known, editable=True) + key = key.strip() + if not ok or not key: + return + # prefill with the current value when editing a single movie + current = files[0].attributes.get(key, "") if len(files) == 1 else "" + value, ok = QInputDialog.getText( + self, "Nastavit atribut", f"Hodnota „{key}\" (prázdné = smazat):", + text=current) + if not ok: + return + for f in files: + f.set_attribute(key, value.strip() or None) + self.refresh_table() + self.status.showMessage(f"Atribut „{key}\" nastaven u {len(files)} filmů.", 5000) + def rename_movie(self) -> None: files = self._selected_movies() if len(files) != 1: @@ -641,8 +916,9 @@ class QtApp(QMainWindow): ok_count = 0 tags_total = 0 errors: list[str] = [] + schema = self.filehandler.tag_schema for f in files: - result = f.apply_csfd_tags() + result = f.apply_csfd_tags(schema) if result["success"]: ok_count += 1 tags_total += len(result["tags_added"]) @@ -682,7 +958,11 @@ class QtApp(QMainWindow): return manager = HardlinkManager(out) created, create_fail, removed, remove_fail = manager.sync_structure( - files, category_roots=FILMOTEKA_CATEGORY_ROOTS + files, + category_roots=self.filehandler.filmoteka_category_roots(), + category_transforms=self.filehandler.filmoteka_category_transforms(), + category_filename_templates=( + self.filehandler.filmoteka_category_filename_templates()), ) # Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked) @@ -722,6 +1002,18 @@ class QtApp(QMainWindow): "Copy-as-is složky: " + ", ".join(self.filehandler.copyasis_folders), 5000 ) + def edit_tag_schema(self) -> None: + dialog = TagSchemaDialog(self, self.filehandler.tag_schema) + if dialog.exec() != QDialog.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 tagy z ČSFD\" a přegenerujte Filmotéku.", + 8000, + ) + def closeEvent(self, event) -> None: # noqa: N802 — Qt override self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" from src.core.config import save_global_config diff --git a/tests/test_config.py b/tests/test_config.py index 58d7999..3c7d1b6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -63,6 +63,7 @@ class TestGlobalConfig: "pool_dir": None, "filmoteka_dir": None, "copyasis_folders": ["Seriály"], + "tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"], } save_global_config(test_config) diff --git a/tests/test_csfd.py b/tests/test_csfd.py index b516fa3..117af21 100644 --- a/tests/test_csfd.py +++ b/tests/test_csfd.py @@ -182,6 +182,26 @@ class TestHelperFunctions: assert rating_band(90) == "90–100 %" assert rating_band(95) == "90–100 %" assert rating_band(100) == "90–100 %" + + def test_csfd_field_values_are_exact_no_transform(self): + from src.core.csfd import csfd_field_values + movie = CSFDMovie(title="X", url="u", year=1999, rating=86, + genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"]) + assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"] + assert csfd_field_values(movie, "countries") == ["USA", "Kanada"] + assert csfd_field_values(movie, "year") == ["1999"] + # rating tag carries the EXACT value (transform happens only for folders) + assert csfd_field_values(movie, "rating") == ["86"] + # missing field / value → empty + assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == [] + assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres") + + def test_apply_transform_decade_band(self): + from src.core.csfd import apply_transform + assert apply_transform("86", "decade_band") == "80–89 %" + assert apply_transform("90", "decade_band") == "90–100 %" + assert apply_transform("Akční", None) == "Akční" # identity for non-rating + assert apply_transform("USA", "identity") == "USA" assert _parse_duration("PT") is None diff --git a/tests/test_file.py b/tests/test_file.py index 7280559..bdc40dd 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -297,7 +297,7 @@ class TestApplyCsfdTags: assert "Žánr/Sci-Fi" in paths assert "Rok/1999" in paths assert "Země původu/USA" in paths - assert "Hodnocení/90–100 %" in paths + assert "Hodnocení/90" in paths # exact value; folder grouping happens later def test_apply_csfd_tags_does_not_tag_directors_or_actors(self, movie_file): """Režie/herci se jen cachují, netvoří se z nich tagy (bylo by jich moc).""" @@ -319,14 +319,75 @@ class TestApplyCsfdTags: assert cached.directors == ["Lana Wachowski"] assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"] - def test_apply_csfd_tags_can_skip_rating(self, movie_file): + def test_name_context_fields(self, movie_file): + movie_file.title = "Dr. No" + movie_file.csfd_cache = {"year": 1962, "rating": 75} + ctx = movie_file.name_context() + assert ctx["title"] == "Dr. No" + assert ctx["year"] == 1962 + assert ctx["rating"] == 75 + assert ctx["ext"] == movie_file.file_path.suffix + assert ctx["filename"] == movie_file.filename + assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}" + + def test_name_context_year_from_tag_when_no_cache(self, movie_file): + movie_file.csfd_cache = None + movie_file.add_tag("Rok/1999") + assert movie_file.name_context()["year"] == "1999" + + def test_set_attribute_persists_and_in_context(self, movie_file): + movie_file.set_attribute("collection_sort", "03") + assert movie_file.attributes["collection_sort"] == "03" + assert movie_file.name_context()["collection_sort"] == "03" + # reload (from index) keeps it + reloaded = File(movie_file.file_path, movie_file.tagmanager, + index=movie_file.index) + assert reloaded.attributes["collection_sort"] == "03" + + def test_set_attribute_empty_removes_it(self, movie_file): + movie_file.set_attribute("collection_sort", "03") + movie_file.set_attribute("collection_sort", None) + assert "collection_sort" not in movie_file.attributes + + def test_attribute_usable_in_filename_template(self, movie_file): + movie_file.title = "Dr. No" + movie_file.set_attribute("collection_sort", "01") + ctx = movie_file.name_context() + assert "{collection_sort} - {title}{ext}".format(**ctx) == \ + f"01 - Dr. No{ctx['ext']}" + + def test_apply_csfd_tags_honors_custom_schema(self, movie_file): + """A schema without the rating entry produces no Hodnocení tags.""" from unittest.mock import patch from src.core.csfd import CSFDMovie + schema = [{"category": "Žánr", "csfd_field": "genres", + "transform": None, "filmoteka_root": ""}] movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"]) with patch("src.core.csfd.fetch_movie", return_value=movie): - movie_file.apply_csfd_tags(add_rating=False) + movie_file.apply_csfd_tags(schema) paths = {t.full_path for t in movie_file.tags} assert "Žánr/Drama" in paths assert not any(p.startswith("Hodnocení/") for p in paths) + + def test_apply_csfd_tags_preserves_user_tags_on_refetch(self, movie_file): + """Re-fetching regenerates only ČSFD tags; user-added tags survive.""" + from unittest.mock import patch + from src.core.csfd import CSFDMovie + + movie_file.add_tag("Sbírka/Oblíbené") # user tag + first = CSFDMovie(title="A", url="u", year=1999, genres=["Akční"]) + with patch("src.core.csfd.fetch_movie", return_value=first): + movie_file.apply_csfd_tags() + # different movie on re-fetch + second = CSFDMovie(title="B", url="u", year=2009, genres=["Drama"]) + with patch("src.core.csfd.fetch_movie", return_value=second): + movie_file.apply_csfd_tags() + + paths = {t.full_path for t in movie_file.tags} + assert "Sbírka/Oblíbené" in paths # user tag kept + assert "Žánr/Drama" in paths # new ČSFD tag + assert "Rok/2009" in paths + assert "Žánr/Akční" not in paths # stale ČSFD tag dropped + assert "Rok/1999" not in paths diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 8572ef8..78357ad 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -603,6 +603,70 @@ class TestPoolManagement: assert movie.file_path.exists() assert not source.exists() # moved, not copied + def test_filmoteka_category_roots_from_schema(self, file_manager): + file_manager.set_tag_schema([ + {"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""}, + {"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "Dle roku"}, + {"category": "Herec", "csfd_field": "actors", "transform": None, "filmoteka_root": None}, + ]) + roots = file_manager.filmoteka_category_roots() + assert roots == {"Žánr": "", "Rok": "Dle roku"} # None-root excluded + assert "Herec" not in roots + + def test_import_movie_suffix_keeps_both(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + (tmp_path / "b.mkv").write_bytes(b"b") + first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + second = file_manager.import_movie(tmp_path / "b.mkv", "Matrix") # default suffix + + assert first.file_path.name == "Matrix.mkv" + assert second.file_path.name == "Matrix_1.mkv" + assert len(file_manager.filelist) == 2 + + def test_import_movie_replace_evicts_existing(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + (tmp_path / "b.mkv").write_bytes(b"bb") + first = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + first.add_tag("Žánr/Akční") + old_path = first.file_path + + second = file_manager.import_movie( + tmp_path / "b.mkv", "Matrix", csfd_link="x", on_conflict="replace") + + assert second.file_path.name == "Matrix.mkv" + assert second.file_path == old_path # same name reused + assert second.file_path.read_bytes() == b"bb" # new content in place + assert [f.file_path.name for f in file_manager.filelist] == ["Matrix.mkv"] + # index record reflects the new import (fresh metadata, old tags dropped) + record = file_manager.index.get(second.file_path) + assert record is not None + assert record["csfd_link"] == "x" + assert record["tags"] == [] + assert second.tags == [] + + def test_import_movie_replace_across_extensions(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + (tmp_path / "b.mp4").write_bytes(b"b") + file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + file_manager.import_movie(tmp_path / "b.mp4", "Matrix", on_conflict="replace") + + names = [f.file_path.name for f in file_manager.filelist] + assert names == ["Matrix.mp4"] + assert not (tmp_path / "pool" / "Filmy" / "Matrix.mkv").exists() + + def test_import_movie_skip_returns_none(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + (tmp_path / "b.mkv").write_bytes(b"b") + file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + result = file_manager.import_movie(tmp_path / "b.mkv", "Matrix", on_conflict="skip") + + assert result is None + assert len(file_manager.filelist) == 1 + def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py index f8bb7ed..030a9a2 100644 --- a/tests/test_hardlink_manager.py +++ b/tests/test_hardlink_manager.py @@ -141,6 +141,67 @@ class TestHardlinkManager: # The mirror (not a managed tag folder) is left alone assert mirror_link.exists() + def test_category_transform_groups_folder_by_band( + self, temp_source_dir, temp_output_dir, tag_manager + ): + """Exact tag value, but the folder name goes through the transform.""" + f = File(temp_source_dir / "file1.txt", tag_manager) + f.tags.clear() + f.add_tag(Tag("Hodnocení", "90")) # exact rating tag + manager = HardlinkManager(temp_output_dir) + manager.create_structure_for_files( + [f], + category_roots={"Hodnocení": "Dle hodnocení"}, + category_transforms={"Hodnocení": "decade_band"}, + ) + + # folder is the band; the file lands inside it + assert (temp_output_dir / "Dle hodnocení" / "90–100 %" / "file1.txt").exists() + # not a per-exact-value folder + assert not (temp_output_dir / "Dle hodnocení" / "90").exists() + + def test_filename_template_applies_only_in_that_category( + self, temp_source_dir, temp_output_dir, tag_manager + ): + """A per-category template renames the hardlink only inside its folder.""" + f = File(temp_source_dir / "file1.txt", tag_manager) + f.tags.clear() + f.title = "Dr. No" + f.csfd_cache = {"year": 1962} + f.add_tag(Tag("Kolekce", "James Bond")) + f.add_tag(Tag("žánr", "Akční")) + manager = HardlinkManager(temp_output_dir) + manager.create_structure_for_files( + [f], + category_roots={"Kolekce": "Dle kolekce", "žánr": ""}, + category_filename_templates={"Kolekce": "{year} - {title}{ext}"}, + ) + + # Templated name inside the collection folder + assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists() + # Other categories keep the pool filename + assert (temp_output_dir / "Akční" / "file1.txt").exists() + + def test_filename_template_cleanup_is_consistent( + self, temp_source_dir, temp_output_dir, tag_manager + ): + """sync twice with a template leaves no stale/duplicate templated link.""" + f = File(temp_source_dir / "file1.txt", tag_manager) + f.tags.clear() + f.title = "Dr. No" + f.csfd_cache = {"year": 1962} + f.add_tag(Tag("Kolekce", "James Bond")) + roots = {"Kolekce": "Dle kolekce"} + templates = {"Kolekce": "{year} - {title}{ext}"} + manager = HardlinkManager(temp_output_dir) + manager.sync_structure([f], category_roots=roots, category_filename_templates=templates) + created, _, removed, _ = manager.sync_structure( + [f], category_roots=roots, category_filename_templates=templates) + + folder = temp_output_dir / "Dle kolekce" / "James Bond" + assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"] + assert removed == 0 # nothing treated as obsolete on the second run + def test_dry_run(self, files_with_tags, temp_output_dir): """Test dry run (bez skutečného vytváření)""" manager = HardlinkManager(temp_output_dir)