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
+23
View File
@@ -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
}
-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} %"
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.
+81 -30
View File
@@ -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:
+83 -7
View File
@@ -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))
+88 -17
View File
@@ -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