Add per-movie attributes and per-category filename templates

This commit is contained in:
2026-06-16 17:39:39 +02:00
parent b3a61f9e86
commit a71b209539
19 changed files with 1064 additions and 111 deletions
+64
View File
@@ -21,6 +21,58 @@ Each version entry uses these sections (include only those that apply):
## Unreleased ## 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í/
90100 %`). `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 ### Added
- Fork of the former **Tagger** project as **Curator**, a movie-library manager. - Fork of the former **Tagger** project as **Curator**, a movie-library manager.
- **Pool** concept (single source of truth) with `Filmy` / `Seriály` folders and - **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` year) and fills in the first ČSFD search hit (`find_csfd_url`
`search_movies`, reusing one Anubis session). Existing links are never `search_movies`, reusing one Anubis session). Existing links are never
overwritten; results are a suggestion the user can review before importing. 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`. - `File` now stores `title` and `csfd_link`.
- New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import, - New **PySide6** GUI reframed around the Filmotéka workflow (pool setup, import,
tag filter sidebar, movie table, one-click Filmotéka generation), replacing the 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). - `requires-python` narrowed to `>=3.14,<3.15` (PySide6 compatibility).
### Removed ### 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) - Legacy Tagger predefined tags: the always-available **Hodnocení** (⭐ rating)
and **Barva** (color) categories in `TagManager`, and the automatic and **Barva** (color) categories in `TagManager`, and the automatic
**Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now **Stav/Nové** tag assigned to every newly imported file. `DEFAULT_TAGS` is now
+22
View File
@@ -102,6 +102,28 @@ movie table, and one-click Filmotéka generation.
`HardlinkManager` supports an empty root (tag folders placed directly at the `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 output root) and restricts obsolete cleanup to the tag-tree's own top-level
folders so mirrors are never touched. 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í/90100 %`);
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 ## Tasks
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "curator" name = "curator"
version = "1.0.0" version = "1.5.0"
description = "" description = ""
authors = [ authors = [
{name = "jan.doubravsky@gmail.com"} {name = "jan.doubravsky@gmail.com"}
+109
View File
@@ -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í/90100 %``). Now
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
This removes the legacy band tags (``Hodnocení/<x><y> %``) 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 [<pool_dir>] [--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í/90100 %" — 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()
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """Auto-generated — do not edit manually."""
__version__ = "1.0.0" __version__ = "1.5.0"
+5 -3
View File
@@ -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 `pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`). frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
Note: per-feature constants (window size, tag colors, …) live in It is the single source of truth for app metadata used across the GUI and build
`src/core/constants.py`; this module is only the version/debug surface used by tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
the build tooling and frozen builds.
""" """
import os 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 # Backwards-compatible alias used by prebuild.py
VERSION: str = APP_VERSION VERSION: str = APP_VERSION
# Default main-window geometry (used when no saved geometry exists)
APP_VIEWPORT: str = "1000x700"
+23
View File
@@ -20,6 +20,28 @@ FOLDER_CONFIG_NAME = ".Curator.!ftag"
# GLOBAL CONFIG - Application settings # 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 = { DEFAULT_GLOBAL_CONFIG = {
"window_geometry": "1200x800", "window_geometry": "1200x800",
"window_maximized": False, "window_maximized": False,
@@ -29,6 +51,7 @@ DEFAULT_GLOBAL_CONFIG = {
"pool_dir": None, # managed pool root (single source of truth) "pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree) "filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
"copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is) "copyasis_folders": ["Seriály"], # pool subfolders mirrored 1:1 (copy-as-is)
"tag_schema": DEFAULT_TAG_SCHEMA, # tag categories + ČSFD/Filmotéka rules
} }
-4
View File
@@ -1,4 +0,0 @@
# src/core/constants.py
VERSION = "v1.0.3"
APP_NAME = "Curator"
APP_VIEWPORT = "1000x700"
+40
View File
@@ -137,6 +137,46 @@ def rating_band(rating: int) -> str:
return f"{low}{high} %" 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 "…/90100 %").
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]: def _split_countries(text: Optional[str]) -> list[str]:
"""Split a ČSFD origin country string into individual countries. """Split a ČSFD origin country string into individual countries.
+79 -28
View File
@@ -26,6 +26,12 @@ class File:
self.csfd_link: str | None = None self.csfd_link: str | None = None
# Cached CSFD data — avoids re-fetching on every open # Cached CSFD data — avoids re-fetching on every open
self.csfd_cache: dict | None = None 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() self.get_metadata()
def get_metadata(self) -> None: def get_metadata(self) -> None:
@@ -52,6 +58,8 @@ class File:
self.title = None self.title = None
self.csfd_link = None self.csfd_link = None
self.csfd_cache = None self.csfd_cache = None
self.csfd_tag_paths = set()
self.attributes = {}
def _build_record(self) -> dict: def _build_record(self) -> dict:
data = { data = {
@@ -59,10 +67,13 @@ class File:
"ignored": self.ignored, "ignored": self.ignored,
# ukládáme full_path tagů # ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags], "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 může být None
"date": self.date, "date": self.date,
"title": self.title, "title": self.title,
"csfd_link": self.csfd_link, "csfd_link": self.csfd_link,
"attributes": self.attributes,
} }
if self.csfd_cache is not None: if self.csfd_cache is not None:
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache} data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
@@ -75,6 +86,9 @@ class File:
self.date = data.get("date", None) self.date = data.get("date", None)
self.title = data.get("title", None) self.title = data.get("title", None)
self.csfd_link = data.get("csfd_link", 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") raw_cache = data.get("csfd_cache")
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION: 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"} 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) data = json.load(f)
self._apply_record(data) 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: def delete_metadata(self) -> None:
"""Remove this file's metadata (from the index, or its sidecar file).""" """Remove this file's metadata (from the index, or its sidecar file)."""
if self.index is not None: if self.index is not None:
@@ -153,19 +205,14 @@ class File:
except Exception: except Exception:
return None return None
def apply_csfd_tags( def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
self, """Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
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.
Tagy: Žánr, Rok, Země původu a Hodnocení (procenta zařazená do desítkového ``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
pásma, např. ``8089 %``). Režie a herci se z ČSFD **stahují a cachují** každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
(``csfd_cache``), ale záměrně se z nich netvoří tagy ani složky — bylo by (s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
jich příliš mnoho. Č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: Returns:
dict s klíči 'success', 'movie'/'error', 'tags_added' dict s klíči 'success', 'movie'/'error', 'tags_added'
@@ -173,6 +220,10 @@ class File:
if not self.csfd_link: if not self.csfd_link:
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []} 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: try:
from .csfd import fetch_movie from .csfd import fetch_movie
movie = fetch_movie(self.csfd_link) movie = fetch_movie(self.csfd_link)
@@ -182,25 +233,25 @@ class File:
except Exception as e: except Exception as e:
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []} return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
tags_added: list[str] = [] from .csfd import csfd_field_values
def _add(category: str, name: str) -> None: # Drop previous ČSFD-sourced tags; keep user-added ones.
tag_obj = self.tagmanager.add_tag(category, name) if self.tagmanager else Tag(category, name) 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] = []
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: if tag_obj not in self.tags:
self.tags.append(tag_obj) self.tags.append(tag_obj)
tags_added.append(f"{category}/{name}") tags_added.append(tag_obj.full_path)
self.csfd_tag_paths.add(tag_obj.full_path)
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))
# Use the CSFD title if we don't have one yet # Use the CSFD title if we don't have one yet
if movie.title and not self.title: if movie.title and not self.title:
+79 -3
View File
@@ -60,6 +60,49 @@ class FileManager:
self.global_config["copyasis_folders"] = cleaned self.global_config["copyasis_folders"] = cleaned
save_global_config(self.global_config) 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 @property
def filmoteka_dir(self) -> Path | None: def filmoteka_dir(self) -> Path | None:
value = self.global_config.get("filmoteka_dir") value = self.global_config.get("filmoteka_dir")
@@ -93,13 +136,35 @@ class FileManager:
file_obj = File(each, self.tagmanager, index=self.index) file_obj = File(each, self.tagmanager, index=self.index)
self.filelist.append(file_obj) 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( def import_movie(
self, source: Path, title: str, csfd_link: str | None = None, move: bool = False self, source: Path, title: str, csfd_link: str | None = None,
) -> File: move: bool = False, on_conflict: str = "suffix",
) -> File | None:
"""Bring a video file into pool/Filmy as 'Title.ext' and index its metadata. """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`` By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind. 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 movies = self.movies_dir
pool = self.pool_dir pool = self.pool_dir
@@ -114,7 +179,18 @@ class FileManager:
safe_title = title.strip() or source.stem safe_title = title.strip() or source.stem
target = movies / f"{safe_title}{source.suffix}" target = movies / f"{safe_title}{source.suffix}"
# Avoid clobbering an existing movie of the same name 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 counter = 1
while target.exists(): while target.exists():
target = movies / f"{safe_title}_{counter}{source.suffix}" target = movies / f"{safe_title}_{counter}{source.suffix}"
+88 -17
View File
@@ -23,6 +23,13 @@ from typing import List, Tuple, Optional, Dict, Set
from .file import File 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: class HardlinkManager:
"""Manager for creating hardlink-based directory structures from tagged files. """Manager for creating hardlink-based directory structures from tagged files.
@@ -61,7 +68,40 @@ class HardlinkManager:
return {cat: cat for cat in categories} return {cat: cat for cat in categories}
return None 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.""" """Output directory for a tag, or None if its category is excluded."""
if roots is None: if roots is None:
folder = tag.category folder = tag.category
@@ -70,17 +110,18 @@ class HardlinkManager:
else: else:
return None return None
base = self.output_dir / folder if folder else self.output_dir 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( 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]]: ) -> Optional[Set[str]]:
"""Top-level output folders owned by the tag tree (None = all of them). """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 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 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 (transformed) tag values is its own top-level folder. This lets cleanup
root entries such as the copy-as-is mirror (Seriály). skip unrelated root entries such as the copy-as-is mirror (Seriály).
""" """
if roots is None: if roots is None:
return None return None
@@ -92,7 +133,7 @@ class HardlinkManager:
for file_obj in files: for file_obj in files:
for tag in file_obj.tags: for tag in file_obj.tags:
if tag.category == cat: if tag.category == cat:
tops.add(tag.name) tops.add(self._folder_value(tag, transforms))
return tops return tops
def create_structure_for_files( def create_structure_for_files(
@@ -101,6 +142,8 @@ class HardlinkManager:
categories: Optional[List[str]] = None, categories: Optional[List[str]] = None,
dry_run: bool = False, dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None, 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]: ) -> Tuple[int, int]:
""" """
Create hardlink structure for given files based on their tags. 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 dry_run: If True, only simulate without creating actual links
category_roots: Optional category → root-folder map (see class doc); category_roots: Optional category → root-folder map (see class doc);
overrides ``categories`` when given. 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: Returns:
Tuple of (successful_links, failed_links) Tuple of (successful_links, failed_links)
@@ -128,10 +175,11 @@ class HardlinkManager:
for tag in file_obj.tags: for tag in file_obj.tags:
# Resolve the target dir; None means this category is excluded # 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: if target_dir is None:
continue continue
target_file = target_dir / file_obj.filename target_file = target_dir / self._link_name(
file_obj, tag, category_filename_templates)
try: try:
if not dry_run: if not dry_run:
@@ -269,6 +317,8 @@ class HardlinkManager:
files: List[File], files: List[File],
categories: Optional[List[str]] = None, categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, 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]]: ) -> List[Tuple[Path, Path]]:
""" """
Get a preview of what links would be created. Get a preview of what links would be created.
@@ -278,6 +328,8 @@ class HardlinkManager:
categories: Optional list of categories to include categories: Optional list of categories to include
category_roots: Optional category → root-folder map (overrides category_roots: Optional category → root-folder map (overrides
``categories`` when given). ``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
Returns: Returns:
List of tuples (source_path, target_path) List of tuples (source_path, target_path)
@@ -290,10 +342,11 @@ class HardlinkManager:
continue continue
for tag in file_obj.tags: 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: if target_dir is None:
continue 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)) preview.append((file_obj.file_path, target_file))
@@ -304,6 +357,8 @@ class HardlinkManager:
files: List[File], files: List[File],
categories: Optional[List[str]] = None, categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, 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]]: ) -> List[Tuple[Path, Path]]:
""" """
Find hardlinks in the output directory that no longer match file tags. 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) categories: Optional list of categories to check (None = all)
category_roots: Optional category → root-folder map (overrides category_roots: Optional category → root-folder map (overrides
``categories`` when given). ``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
Returns: Returns:
List of tuples (link_path, source_path) for obsolete links List of tuples (link_path, source_path) for obsolete links
@@ -346,15 +402,16 @@ class HardlinkManager:
expected_paths[inode] = set() expected_paths[inode] = set()
for tag in file_obj.tags: 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: if target_dir is None:
continue 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: except OSError:
continue continue
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors) # 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(): for top in self.output_dir.iterdir():
if not top.is_dir(): if not top.is_dir():
continue continue
@@ -382,6 +439,8 @@ class HardlinkManager:
categories: Optional[List[str]] = None, categories: Optional[List[str]] = None,
dry_run: bool = False, dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None, 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]]: ) -> Tuple[int, List[Path]]:
""" """
Remove hardlinks that no longer match file tags. Remove hardlinks that no longer match file tags.
@@ -392,11 +451,15 @@ class HardlinkManager:
dry_run: If True, only return what would be removed dry_run: If True, only return what would be removed
category_roots: Optional category → root-folder map (overrides category_roots: Optional category → root-folder map (overrides
``categories`` when given). ``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
Returns: Returns:
Tuple of (removed_count, list_of_removed_paths) 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 = [] removed_paths = []
if dry_run: if dry_run:
@@ -420,6 +483,8 @@ class HardlinkManager:
categories: Optional[List[str]] = None, categories: Optional[List[str]] = None,
dry_run: bool = False, dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None, 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]: ) -> Tuple[int, int, int, int]:
""" """
Synchronize hardlink structure with current file tags. Synchronize hardlink structure with current file tags.
@@ -434,22 +499,28 @@ class HardlinkManager:
dry_run: If True, only simulate dry_run: If True, only simulate
category_roots: Optional category → root-folder map (overrides category_roots: Optional category → root-folder map (overrides
``categories`` when given). ``categories`` when given).
category_transforms: Optional category → transform-name map for folders.
category_filename_templates: Optional category → hardlink-name template.
Returns: Returns:
Tuple of (created, create_failed, removed, remove_failed) Tuple of (created, create_failed, removed, remove_failed)
""" """
# First find how many obsolete links there are # 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 # Remove obsolete links
removed, removed_paths = self.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 remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links # Then create new links
created, create_failed = self.create_structure_for_files( 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 return created, create_failed, removed, remove_failed
+1 -1
View File
@@ -15,7 +15,7 @@ from src.core.tag_manager import TagManager, DEFAULT_TAG_ORDER
from src.core.file import File from src.core.file import File
from src.core.tag import Tag from src.core.tag import Tag
from src.core.list_manager import ListManager 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.config import save_global_config
from src.core.hardlink_manager import HardlinkManager from src.core.hardlink_manager import HardlinkManager
+335 -43
View File
@@ -20,25 +20,16 @@ from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QFileDialog, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, 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.file_manager import FileManager
from src.core.tag_manager import TagManager from src.core.tag_manager import TagManager
from src.core.file import File from src.core.file import File
from src.core.tag import Tag 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 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): class ImportMoviesDialog(QDialog):
@@ -49,23 +40,29 @@ class ImportMoviesDialog(QDialog):
the files are copied (default, non-destructive) or moved into the pool. 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) super().__init__(parent)
self.setWindowTitle("Importovat filmy do poolu") 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 # (source path, title field, ČSFD field) per row
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = [] self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = []
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.table = QTableWidget(0, 3) self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz"]) self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""])
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
layout.addWidget(self.table) layout.addWidget(self.table)
add_row = QHBoxLayout() add_row = QHBoxLayout()
@@ -97,11 +94,35 @@ class ImportMoviesDialog(QDialog):
name_item.setFlags(Qt.ItemIsEnabled) name_item.setFlags(Qt.ItemIsEnabled)
self.table.setItem(row, 0, name_item) self.table.setItem(row, 0, name_item)
title_edit = QLineEdit(source.stem) title_edit = QLineEdit(source.stem)
title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e))
csfd_edit = QLineEdit() csfd_edit = QLineEdit()
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") 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, 1, title_edit)
self.table.setCellWidget(row, 2, csfd_edit) self.table.setCellWidget(row, 2, csfd_edit)
self.table.setCellWidget(row, 3, remove_btn)
self._rows.append((source, title_edit, csfd_edit)) 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: def _add_files(self) -> None:
paths, _ = QFileDialog.getOpenFileNames(self, "Vyber video soubory") 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). 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) super().__init__(parent)
self.setWindowTitle("Přiřadit štítky") self.setWindowTitle("Přiřadit štítky")
self.setMinimumSize(380, 480) self.setMinimumSize(380, 480)
self.result_map: dict[str, int] = {} self.result_map: dict[str, int] = {}
self.tagmanager = tagmanager
file_tag_sets = [{t.full_path for t in f.tags} for f in files] 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 = QVBoxLayout(self)
layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}")) layout.addWidget(QLabel(f"Vybráno filmů: {len(files)}"))
@@ -172,25 +199,17 @@ class AssignTagsDialog(QDialog):
self.tree.setHeaderHidden(True) self.tree.setHeaderHidden(True)
layout.addWidget(self.tree) layout.addWidget(self.tree)
self._items: list[tuple[str, QTreeWidgetItem]] = [] # Show schema categories (incl. empty ones) categories that have tags
for category in self.tagmanager_categories(tagmanager): all_categories = list(dict.fromkeys(
cat_item = QTreeWidgetItem([category]) (categories or []) + self.tagmanager_categories(tagmanager)))
cat_item.setFlags(Qt.ItemIsEnabled) for category in all_categories:
self.tree.addTopLevelItem(cat_item) self._category_item(category)
cat_item.setExpanded(True)
for tag in tagmanager.get_tags_in_category(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) self._add_tag_item(category, tag.name)
if have == 0:
state = Qt.Unchecked add_btn = QPushButton(" Nový štítek…")
elif have == len(files): add_btn.clicked.connect(self._add_new_tag)
state = Qt.Checked layout.addWidget(add_btn)
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))
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
@@ -201,12 +220,194 @@ class AssignTagsDialog(QDialog):
def tagmanager_categories(tagmanager: TagManager) -> List[str]: def tagmanager_categories(tagmanager: TagManager) -> List[str]:
return sorted(tagmanager.get_categories()) 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: def accept(self) -> None:
mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2} mapping = {Qt.Checked: 1, Qt.Unchecked: 0, Qt.PartiallyChecked: 2}
self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items} self.result_map = {fp: mapping[item.checkState(0)] for fp, item in self._items}
super().accept() 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): class QtApp(QMainWindow):
def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None: def __init__(self, filehandler: FileManager, tagmanager: TagManager) -> None:
super().__init__() super().__init__()
@@ -218,7 +419,7 @@ class QtApp(QMainWindow):
self._active_filter: set[str] = set() self._active_filter: set[str] = set()
self.filehandler.on_files_changed = lambda _=None: self.refresh_table() 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") geometry = self.filehandler.global_config.get("window_geometry", "1200x800")
try: try:
w, h = (int(x) for x in geometry.lower().split("x")) 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ř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, "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 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, "Upravit ČSFD odkaz…", self.edit_csfd)
self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) self._add_action(movie_menu, "Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected)
movie_menu.addSeparator() 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, "Vygenerovat Filmotéku", self.generate_filmoteka, "Ctrl+G")
self._add_action(film_menu, "Copy-as-is složky…", self.edit_copyasis_folders) 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: def _add_action(self, menu: QMenu, text: str, slot, shortcut: str | None = None) -> QAction:
action = QAction(text, self) action = QAction(text, self)
if shortcut: if shortcut:
@@ -347,13 +552,20 @@ class QtApp(QMainWindow):
for t in f.tags: for t in f.tags:
counts[t.full_path] = counts.get(t.full_path, 0) + 1 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.blockSignals(True)
self.tag_tree.clear() self.tag_tree.clear()
for category in self.tagmanager.get_categories(): for category in self.tagmanager.get_categories():
cat_item = QTreeWidgetItem([category]) cat_item = QTreeWidgetItem([category])
cat_item.setFlags(Qt.ItemIsEnabled) cat_item.setFlags(Qt.ItemIsEnabled)
self.tag_tree.addTopLevelItem(cat_item) 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): for tag in self.tagmanager.get_tags_in_category(category):
count = counts.get(tag.full_path, 0) count = counts.get(tag.full_path, 0)
item = QTreeWidgetItem([f"{tag.name} ({count})"]) item = QTreeWidgetItem([f"{tag.name} ({count})"])
@@ -433,6 +645,7 @@ class QtApp(QMainWindow):
menu.addAction("Přejmenovat…", self.rename_movie) menu.addAction("Přejmenovat…", self.rename_movie)
menu.addAction("Přiřadit štítky…", self.assign_tags) menu.addAction("Přiřadit štítky…", self.assign_tags)
menu.addAction("Nastavit datum…", self.set_date) menu.addAction("Nastavit datum…", self.set_date)
menu.addAction("Nastavit atribut…", self.set_attribute)
menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd) menu.addAction("Upravit ČSFD odkaz…", self.edit_csfd)
menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected) menu.addAction("Načíst tagy z ČSFD", self.apply_csfd_tags_for_selected)
menu.addSeparator() menu.addSeparator()
@@ -495,7 +708,8 @@ class QtApp(QMainWindow):
if not paths: if not paths:
return return
sources = [Path(p) for p in paths] 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: if dialog.exec() != QDialog.Accepted:
return return
entries = dialog.entries() entries = dialog.entries()
@@ -503,11 +717,24 @@ class QtApp(QMainWindow):
return return
move = dialog.move_files 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] = [] imported: list[File] = []
skipped = 0
errors: list[str] = [] errors: list[str] = []
for source, title, csfd_link in entries: for source, title, csfd_link in entries:
try: try:
movie = self.filehandler.import_movie(source, title, csfd_link or None, move=move) 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) imported.append(movie)
except Exception as exc: # noqa: BLE001 — surface per-file import failures except Exception as exc: # noqa: BLE001 — surface per-file import failures
errors.append(f"{source.name}: {exc}") errors.append(f"{source.name}: {exc}")
@@ -537,6 +764,29 @@ class QtApp(QMainWindow):
QMessageBox.information(self, "Import", summary) QMessageBox.information(self, "Import", summary)
self.status.showMessage(summary, 5000) 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: def open_movies(self) -> None:
for f in self._selected_movies(): for f in self._selected_movies():
self._open_path(f.file_path) self._open_path(f.file_path)
@@ -557,7 +807,8 @@ class QtApp(QMainWindow):
if not files: if not files:
QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.") QMessageBox.information(self, "Štítky", "Nejprve vyberte filmy.")
return 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: if dialog.exec() != QDialog.Accepted:
return return
for full_path, state in dialog.result_map.items(): for full_path, state in dialog.result_map.items():
@@ -580,6 +831,30 @@ class QtApp(QMainWindow):
f.set_date(text.strip() or None) f.set_date(text.strip() or None)
self.refresh_table() 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: def rename_movie(self) -> None:
files = self._selected_movies() files = self._selected_movies()
if len(files) != 1: if len(files) != 1:
@@ -641,8 +916,9 @@ class QtApp(QMainWindow):
ok_count = 0 ok_count = 0
tags_total = 0 tags_total = 0
errors: list[str] = [] errors: list[str] = []
schema = self.filehandler.tag_schema
for f in files: for f in files:
result = f.apply_csfd_tags() result = f.apply_csfd_tags(schema)
if result["success"]: if result["success"]:
ok_count += 1 ok_count += 1
tags_total += len(result["tags_added"]) tags_total += len(result["tags_added"])
@@ -682,7 +958,11 @@ class QtApp(QMainWindow):
return return
manager = HardlinkManager(out) manager = HardlinkManager(out)
created, create_fail, removed, remove_fail = manager.sync_structure( 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) # 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 "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 def closeEvent(self, event) -> None: # noqa: N802 — Qt override
self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}" self.filehandler.global_config["window_geometry"] = f"{self.width()}x{self.height()}"
from src.core.config import save_global_config from src.core.config import save_global_config
+1
View File
@@ -63,6 +63,7 @@ class TestGlobalConfig:
"pool_dir": None, "pool_dir": None,
"filmoteka_dir": None, "filmoteka_dir": None,
"copyasis_folders": ["Seriály"], "copyasis_folders": ["Seriály"],
"tag_schema": DEFAULT_GLOBAL_CONFIG["tag_schema"],
} }
save_global_config(test_config) save_global_config(test_config)
+20
View File
@@ -182,6 +182,26 @@ class TestHelperFunctions:
assert rating_band(90) == "90100 %" assert rating_band(90) == "90100 %"
assert rating_band(95) == "90100 %" assert rating_band(95) == "90100 %"
assert rating_band(100) == "90100 %" assert rating_band(100) == "90100 %"
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") == "8089 %"
assert apply_transform("90", "decade_band") == "90100 %"
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
assert apply_transform("USA", "identity") == "USA"
assert _parse_duration("PT") is None assert _parse_duration("PT") is None
+64 -3
View File
@@ -297,7 +297,7 @@ class TestApplyCsfdTags:
assert "Žánr/Sci-Fi" in paths assert "Žánr/Sci-Fi" in paths
assert "Rok/1999" in paths assert "Rok/1999" in paths
assert "Země původu/USA" in paths assert "Země původu/USA" in paths
assert "Hodnocení/90100 %" 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): 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).""" """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.directors == ["Lana Wachowski"]
assert cached.actors == ["Keanu Reeves", "Laurence Fishburne"] 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 unittest.mock import patch
from src.core.csfd import CSFDMovie 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"]) movie = CSFDMovie(title="Matrix", url="u", rating=90, genres=["Drama"])
with patch("src.core.csfd.fetch_movie", return_value=movie): 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} paths = {t.full_path for t in movie_file.tags}
assert "Žánr/Drama" in paths assert "Žánr/Drama" in paths
assert not any(p.startswith("Hodnocení/") for p 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
+64
View File
@@ -603,6 +603,70 @@ class TestPoolManagement:
assert movie.file_path.exists() assert movie.file_path.exists()
assert not source.exists() # moved, not copied 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): def test_rename_movie_renames_file_and_reindexes(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
source = tmp_path / "raw.mkv" source = tmp_path / "raw.mkv"
+61
View File
@@ -141,6 +141,67 @@ class TestHardlinkManager:
# The mirror (not a managed tag folder) is left alone # The mirror (not a managed tag folder) is left alone
assert mirror_link.exists() 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í" / "90100 %" / "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): def test_dry_run(self, files_with_tags, temp_output_dir):
"""Test dry run (bez skutečného vytváření)""" """Test dry run (bez skutečného vytváření)"""
manager = HardlinkManager(temp_output_dir) manager = HardlinkManager(temp_output_dir)