diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b731f..86e04b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,41 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +## 1.11.0 — 2026-07-27 + +### Added +- **Pool naming convention `Title (YYYY).ext`** (`src/core/naming.py`). Pooled + movies now carry a four-digit year in parentheses, so two same-named films + coexist and the filename is self-describing. + - **Import requires a year**: the import dialog has a per-row **Rok** field + (pre-filled from the source filename), and *"Najít ČSFD odkazy"* now also + fills the year from the top ČSFD hit — a **suggested** year is tinted **blue** + (verify it), a name **colliding** with the pool stays **red**. The import is + blocked until every row has a four-digit year, and the file lands in the pool + as `Title (rok).ext`. Same title + different year no longer collide. + - **Rename to the convention**: *Filmy → "Přejmenovat dle ČSFD (rok)…"* + (`FileManager.rename_all_to_canonical`) renames selected movies (or the whole + pool) to `Title (rok).ext` using the year from their ČSFD metadata, with a + cancelable progress dialog; files with no known year are skipped and name + collisions reported. `File.title` keeps the clean title (without the year); + `File.name_context` recovers the year from a canonical filename when no + ČSFD/tag year exists. + +### Changed +- **Filmotéka hardlinks are named by the clean title (no year).** In the tag + tree a movie now links as `Title.ext` instead of the pooled `Title (YYYY).ext`. + When two *different* films would collide on that name in the **same folder**, + the colliding ones keep the year (`Title (YYYY).ext`) to tell them apart; a + residual clash (same title *and* year) gets a stable numeric suffix. Copy-as-is + mirrors (Seriály) are untouched — they stay 1:1. A per-category + `filename_template` (Tag schéma dialog) still overrides the default inside its + folders. Link creation and obsolete-detection now share one collision-resolved + plan (`HardlinkManager._plan_links`), so they can't disagree on a name — the + first generation after upgrading re-lays the affected links once, then it is + stable. +- `File.name_context`'s `year` field is now always a string (was an int when it + came straight from the ČSFD cache) — uniform for filename templates. + ## 1.10.0 — 2026-07-07 ### Added diff --git a/PROJECT.md b/PROJECT.md index ede1d97..7171c8b 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -98,8 +98,17 @@ movie table, and one-click Filmotéka generation. Filmotéka output, with the files materialized as **hardlinks** into the pool. So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure, hardlinked files). This is how Seriály work. -- **File naming:** imported movies are renamed to **`Title.ext`** (no year in the - filename; year lives in metadata/tags). +- **File naming:** pooled movies follow the convention **`Title (YYYY).ext`** — a + clean title plus a four-digit year in parentheses (`naming.py`: + `canonical_pool_stem` / `parse_pool_stem`). The year lets two same-named films + coexist and makes the filename self-describing (`File.name_context` recovers + the year from the filename when no ČSFD/tag year is present). The GUI **import + requires a four-digit year** (per-row *Rok* field, pre-filled from the source + filename and fillable from ČSFD — a ČSFD-suggested year is tinted blue to be + verified, a name colliding with the pool is tinted red). *Filmy → "Přejmenovat + dle ČSFD (rok)…"* (`FileManager.rename_all_to_canonical`) brings older + `Title.ext` files up to the convention from their ČSFD metadata; files without + a known year are skipped. `File.title` always stays the clean title (no year). - **Import copy vs move:** by default the original file is **copied** into the pool (non-destructive); the import dialog also offers a **move** option that relocates the source into the pool instead. @@ -122,11 +131,20 @@ movie table, and one-click Filmotéka generation. The `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90–100 %`); it is applied at Filmotéka generation via `filmoteka_category_transforms`. -- **Per-category filename template** (`filename_template` in a schema entry): the - hardlink name **inside that category's folders only** is rendered from the - movie's metadata (`File.name_context`: title/year/rating/ext/stem/filename plus - any free-form attributes), e.g. a Kolekce with `"{collection_sort} - {title}{ext}"`. - Other folders and the pool file keep the plain name; applied via +- **Filmotéka link naming (clean title, year on collision):** the default + hardlink name in the tag tree is the movie's **clean title** — `Title.ext`, + *not* the pooled `Title (YYYY).ext`. When two different films would collide on + that name **within one folder**, the colliding ones keep the disambiguating + **year** (`Title (YYYY).ext`); a residual same-title-and-year clash gets a + stable numeric suffix. `HardlinkManager._plan_links` resolves the whole tag + tree to a collision-free `(file, path)` plan that **both** link creation and + obsolete-detection consume, so they can never disagree on a name (the previous + churn source). Copy-as-is mirrors (Seriály) are unaffected — they mirror 1:1. +- **Per-category filename template** (`filename_template` in a schema entry): + overrides the default naming **inside that category's folders only**, 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}"`. 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 @@ -152,6 +170,9 @@ movie table, and one-click Filmotéka generation. ## Done +- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at + import (per-row Rok field, blue = ČSFD-suggested, red = pool collision), plus + "Přejmenovat dle ČSFD (rok)" to bring older files up to the convention - Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"): FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or quick (ffprobe), over selected movies or the whole pool diff --git a/pyproject.toml b/pyproject.toml index 0eff5d1..3ff4126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "1.10.0" +version = "1.11.0" description = "" authors = [ {name = "jan.doubravsky@gmail.com"} diff --git a/src/_version.py b/src/_version.py index e4c71bb..8d3853b 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.10.0" +__version__ = "1.11.0" diff --git a/src/core/file.py b/src/core/file.py index 6a5c678..ec2aea9 100644 --- a/src/core/file.py +++ b/src/core/file.py @@ -139,12 +139,18 @@ class File: if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit(): year = t.name break + if year is None: + # Last resort: recover it from a canonical "Title (YYYY)" filename. + from .naming import parse_pool_stem + parsed = parse_pool_stem(self.file_path.stem) + if parsed is not None: + year = parsed[1] 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, + "year": "" if year is None else str(year), "rating": "" if rating is None else rating, "ext": self.file_path.suffix, "stem": self.file_path.stem, diff --git a/src/core/file_manager.py b/src/core/file_manager.py index 0ab8df7..44de199 100644 --- a/src/core/file_manager.py +++ b/src/core/file_manager.py @@ -7,6 +7,7 @@ from .tag_manager import TagManager from .pool_index import PoolIndex from .utils import list_files from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult +from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem from typing import Callable, Iterable from src.core.config import ( load_global_config, save_global_config, DEFAULT_TAG_SCHEMA @@ -326,8 +327,14 @@ class FileManager: def import_movie( self, source: Path, title: str, csfd_link: str | None = None, move: bool = False, on_conflict: str = "suffix", + year: int | str | None = None, ) -> File | None: - """Bring a video file into pool/Filmy as 'Title.ext' and index its metadata. + """Bring a video file into pool/Filmy and index its metadata. + + The pooled filename follows the naming convention ``Title (YYYY).ext`` + when a ``year`` is given (validated to four digits); without a year it + falls back to ``Title.ext``. The GUI import always supplies a year, so a + library built through the app is uniformly ``Title (YYYY).ext``. By default the original is **copied** (non-destructive). With ``move=True`` the source file is moved into the pool instead, leaving nothing behind. @@ -339,6 +346,9 @@ class FileManager: - ``"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``. + + Raises: + ValueError: a ``year`` was given but is not a four-digit number. """ movies = self.movies_dir pool = self.pool_dir @@ -351,9 +361,11 @@ class FileManager: source = Path(source) safe_title = title.strip() or source.stem - target = movies / f"{safe_title}{source.suffix}" + has_year = year is not None and str(year).strip() != "" + stem = canonical_pool_stem(safe_title, year) if has_year else safe_title + target = movies / f"{stem}{source.suffix}" - existing = self.pooled_with_stem(safe_title) + existing = self.pooled_with_stem(stem) conflict = bool(existing) or target.exists() if conflict and on_conflict == "skip": @@ -367,7 +379,7 @@ class FileManager: # "suffix": never clobber an existing exact filename counter = 1 while target.exists(): - target = movies / f"{safe_title}_{counter}{source.suffix}" + target = movies / f"{stem}_{counter}{source.suffix}" counter += 1 if move: @@ -420,6 +432,111 @@ class FileManager: self.on_files_changed(self.filelist) return file_obj + def _canonical_name_for(self, file_obj: File) -> str | None: + """The ``Title (YYYY)`` stem this movie should have, or ``None``. + + Uses the clean title and the year from the movie's metadata + (``name_context``: ČSFD cache → ``Rok`` tag → the current filename). A + ``(YYYY)`` that leaked into the title is stripped so it isn't doubled. + Returns ``None`` when no four-digit year is known. + """ + ctx = file_obj.name_context() + title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip() + year = str(ctx.get("year") or "").strip() + + parsed = parse_pool_stem(title) + if parsed is not None: + title = parsed[0] + year = year or str(parsed[1]) + + title = title.replace("/", "-").replace("\\", "-").strip() + if not (title and is_valid_year(year)): + return None + return canonical_pool_stem(title, year) + + def rename_to_canonical(self, file_obj: File) -> File | None: + """Rename a pooled movie to ``Title (YYYY).ext`` from its metadata. + + The year comes from the movie's ČSFD metadata (falling back to a ``Rok`` + tag or the existing filename). ``file_obj.title`` keeps the clean title + (without the year). Does not fire ``on_files_changed`` — the caller + refreshes once after a batch. + + Returns: + The file when renamed (or already canonical); ``None`` when no + four-digit year is known, so no canonical name can be built. + + Raises: + FileExistsError: another pooled file already uses that name. + """ + new_stem = self._canonical_name_for(file_obj) + if new_stem is None: + return None + + old_path = file_obj.file_path + new_path = old_path.with_name(f"{new_stem}{old_path.suffix}") + if new_path == old_path: + return file_obj # already canonical + if new_path.exists(): + raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.") + + parsed = parse_pool_stem(new_stem) + old_path.rename(new_path) + file_obj.relocate(new_path) + if parsed is not None: + file_obj.title = parsed[0] + file_obj.save_metadata() + return file_obj + + def rename_all_to_canonical( + self, + files: list[File], + on_progress: Callable[[int, int, File], None] | None = None, + should_cancel: Callable[[], bool] | None = None, + ) -> dict: + """Rename each movie to ``Title (YYYY).ext`` from its metadata. + + Skips files already canonical and those without a known year. Reports + conflicts (target name taken) without aborting the rest. + + Returns a dict with: + renamed — (old_name, new_name) pairs actually renamed + skipped_no_year — filenames with no four-digit year available + conflicts — (old_name, message) where the target name was taken + unchanged — count already in canonical form + cancelled — True if stopped early + """ + renamed: list[tuple[str, str]] = [] + skipped_no_year: list[str] = [] + conflicts: list[tuple[str, str]] = [] + unchanged = 0 + cancelled = False + total = len(files) + for i, f in enumerate(files, 1): + if should_cancel is not None and should_cancel(): + cancelled = True + break + old_name = f.filename + try: + result = self.rename_to_canonical(f) + except FileExistsError as exc: + conflicts.append((old_name, str(exc))) + else: + if result is None: + skipped_no_year.append(old_name) + elif f.filename != old_name: + renamed.append((old_name, f.filename)) + else: + unchanged += 1 + if on_progress is not None: + on_progress(i, total, f) + + if renamed and self.on_files_changed: + self.on_files_changed(self.filelist) + return {"renamed": renamed, "skipped_no_year": skipped_no_year, + "conflicts": conflicts, "unchanged": unchanged, + "cancelled": cancelled} + def assign_tag_to_file_objects(self, files_objs: list[File], tag): """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu.""" for f in files_objs: diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py index b1165f6..f184859 100644 --- a/src/core/hardlink_manager.py +++ b/src/core/hardlink_manager.py @@ -19,9 +19,11 @@ Example: """ import os import random +from collections import defaultdict from pathlib import Path from typing import List, Tuple, Optional, Dict, Set from .file import File +from .naming import is_valid_year, parse_pool_stem class _SafeDict(dict): @@ -69,24 +71,102 @@ class HardlinkManager: return {cat: cat for cat in categories} return None - 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. + def _clean_title(self, file_obj: File) -> str: + """The movie's clean title (no trailing ``(YYYY)``), path-safe.""" + ctx = file_obj.name_context() + title = str(ctx.get("title") or file_obj.file_path.stem or "").strip() + parsed = parse_pool_stem(title) + if parsed is not None: + title = parsed[0] + title = title.replace("/", "-").replace("\\", "-").strip() + return title or file_obj.file_path.stem - 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 + def _default_link_name(self, file_obj: File) -> str: + """Filmotéka hardlink name: the clean title + extension (no year).""" + return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}" + + def _year_link_name(self, file_obj: File) -> Optional[str]: + """``Title (YYYY).ext`` used to disambiguate a name collision, or None.""" + year = str(file_obj.name_context().get("year") or "").strip() + if not is_valid_year(year): + return None + return f"{self._clean_title(file_obj)} ({year}){file_obj.file_path.suffix}" + + def _render_template(self, file_obj: File, template: str) -> str: + """Render a per-category filename template; fall back to the clean name.""" try: rendered = template.format_map(_SafeDict(file_obj.name_context())) except (ValueError, KeyError, IndexError, AttributeError): - return file_obj.filename + return self._default_link_name(file_obj) rendered = rendered.replace("/", "-").replace("\\", "-").strip() - return rendered or file_obj.filename + return rendered or self._default_link_name(file_obj) + + def _plan_links( + self, + files: List[File], + roots: Optional[Dict[str, str]], + transforms: Optional[Dict[str, str]], + templates: Optional[Dict[str, str]], + ) -> List[Tuple[File, Path]]: + """Resolve the tag tree to a collision-free ``(file, link_path)`` plan. + + The default hardlink name is the movie's **clean title** (no year). When + two *different* movies would land on the same name in the same folder, + the colliding ones fall back to the year-qualified ``Title (YYYY).ext`` to + tell them apart; a residual clash (same title *and* year) gets a stable + numeric suffix so every path is unique. A per-category + ``filename_template`` overrides the default inside that category's folders. + + Both link creation and obsolete detection consume this one plan, so they + can never disagree on a name (which is what caused hardlink churn before). + """ + # (file_obj, target_dir, name, is_templated), one per file+folder + entries: List[List] = [] + seen_fd: set[tuple[str, str]] = set() + for file_obj in files: + for tag in file_obj.tags: + target_dir = self._target_dir(tag, roots, transforms) + if target_dir is None: + continue + key = (str(file_obj.file_path), str(target_dir)) + if key in seen_fd: # one link per movie per folder + continue + seen_fd.add(key) + template = templates.get(tag.category) if templates else None + if template: + entries.append( + [file_obj, target_dir, self._render_template(file_obj, template), True]) + else: + entries.append( + [file_obj, target_dir, self._default_link_name(file_obj), False]) + + # Disambiguate default-named collisions (different movies) by their year. + groups: dict[tuple[Path, str], set[str]] = defaultdict(set) + for file_obj, target_dir, name, templated in entries: + if not templated: + groups[(target_dir, name)].add(str(file_obj.file_path)) + for entry in entries: + file_obj, target_dir, name, templated = entry + if not templated and len(groups[(target_dir, name)]) > 1: + year_name = self._year_link_name(file_obj) + if year_name: + entry[2] = year_name + + # Final pass: guarantee unique paths (stable numeric suffix on any tie). + by_path: dict[tuple[Path, str], List[List]] = defaultdict(list) + for entry in entries: + by_path[(entry[1], entry[2])].append(entry) + plan: List[Tuple[File, Path]] = [] + for (target_dir, name), group in by_path.items(): + if len(group) == 1: + plan.append((group[0][0], target_dir / name)) + continue + ordered = sorted(group, key=lambda e: str(e[0].file_path)) + stem, suffix = Path(name).stem, Path(name).suffix + for i, entry in enumerate(ordered): + fname = name if i == 0 else f"{stem}_{i + 1}{suffix}" + plan.append((entry[0], target_dir / fname)) + return plan def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str: """Folder name for a tag — its value run through the category transform. @@ -185,41 +265,28 @@ class HardlinkManager: success_count = 0 fail_count = 0 - for file_obj in files: - if not file_obj.tags: - continue + plan = self._plan_links( + files, roots, category_transforms, category_filename_templates) + for file_obj, target_file in plan: + try: + if not dry_run: + target_file.parent.mkdir(parents=True, exist_ok=True) + if target_file.exists(): + # Already the right link → nothing to do (not re-counted). + if self._is_same_file(file_obj.file_path, target_file): + continue + # A wrong occupant sits on the planned path; the plan is + # authoritative, so replace it. + target_file.unlink() - for tag in file_obj.tags: - # Resolve the target dir; None means this category is excluded - target_dir = self._target_dir(tag, roots, category_transforms) - if target_dir is None: - continue - target_file = target_dir / self._link_name( - file_obj, tag, category_filename_templates) + os.link(file_obj.file_path, target_file) - try: - if not dry_run: - # Create directory structure - target_dir.mkdir(parents=True, exist_ok=True) + self.created_links.append(target_file) + success_count += 1 - # Skip if link already exists - if target_file.exists(): - # Check if it's already a hardlink to the same file - if self._is_same_file(file_obj.file_path, target_file): - continue - else: - # Different file exists, add suffix - target_file = self._get_unique_name(target_file) - - # Create hardlink - os.link(file_obj.file_path, target_file) - - self.created_links.append(target_file) - success_count += 1 - - except OSError as e: - self.errors.append((file_obj.file_path, str(e))) - fail_count += 1 + except OSError as e: + self.errors.append((file_obj.file_path, str(e))) + fail_count += 1 return success_count, fail_count @@ -291,7 +358,7 @@ class HardlinkManager: created = 0 fail = 0 for file_obj in chosen: - target = base / file_obj.filename + target = base / self._default_link_name(file_obj) try: if not dry_run: base.mkdir(parents=True, exist_ok=True) @@ -299,7 +366,13 @@ class HardlinkManager: if self._is_same_file(file_obj.file_path, target): created += 1 continue - target = self._get_unique_name(target) + # Collision with another movie: prefer the year-qualified + # name, else a numeric suffix. + year_name = self._year_link_name(file_obj) + if year_name and not (base / year_name).exists(): + target = base / year_name + else: + target = self._get_unique_name(target) os.link(file_obj.file_path, target) self.created_links.append(target) created += 1 @@ -418,22 +491,9 @@ class HardlinkManager: List of tuples (source_path, target_path) """ roots = self._resolve_roots(categories, category_roots) - preview = [] - - for file_obj in files: - if not file_obj.tags: - continue - - for tag in file_obj.tags: - target_dir = self._target_dir(tag, roots, category_transforms) - if target_dir is None: - continue - target_file = target_dir / self._link_name( - file_obj, tag, category_filename_templates) - - preview.append((file_obj.file_path, target_file)) - - return preview + plan = self._plan_links( + files, roots, category_transforms, category_filename_templates) + return [(file_obj.file_path, target_file) for file_obj, target_file in plan] def find_obsolete_links( self, @@ -479,26 +539,20 @@ class HardlinkManager: continue # Every link the tag tree *should* contain, mapped to the inode(s) it may - # point at. A path can be wanted by more than one movie (same rendered - # name in the same folder), so the value is a *set* of valid inodes. - # Matching by inode too — not just the path — is what catches an **orphan - # that squats the expected name**: a link whose name is right but which - # points to an old inode (movie re-imported/replaced) is still obsolete, - # while the correctly re-created link (forced to a ``_1`` suffix because - # the orphan holds the base name) is *not* wrongly swept. + # point at — taken from the very same plan that creates the links, so the + # two never disagree on a name. Matching by inode too — not just the path + # — is what catches an **orphan that squats the expected name**: a link + # whose name is right but which points to an old inode (movie + # re-imported/replaced) is still obsolete, while the correctly created + # link is *not* wrongly swept. expected: dict[Path, set[int]] = {} - for file_obj in files: + for file_obj, path in self._plan_links( + files, roots, category_transforms, category_filename_templates + ): try: - file_inode = file_obj.file_path.stat().st_ino + expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino) except OSError: continue - for tag in file_obj.tags: - target_dir = self._target_dir(tag, roots, category_transforms) - if target_dir is None: - continue - path = target_dir / self._link_name( - file_obj, tag, category_filename_templates) - expected.setdefault(path, set()).add(file_inode) # Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors). # Inside them, any file that isn't an expected link is obsolete — whether diff --git a/src/core/naming.py b/src/core/naming.py new file mode 100644 index 0000000..24fcf39 --- /dev/null +++ b/src/core/naming.py @@ -0,0 +1,62 @@ +"""Pool file naming convention. + +Every movie in the pool is named ``Title (YYYY).ext`` — a title followed by a +four-digit year in parentheses. The year lets two films of the same name live +side by side and makes the filename self-describing (the year can be recovered +without any metadata). These helpers build, validate and parse that stem (the +filename without its extension). +""" +import re + +# A trailing four-digit year in parentheses. The title is non-greedy so a title +# that itself contains parentheses keeps them: "Já, robot (2004)" but also +# "Kurz (special) (2019)" → title "Kurz (special)", year 2019. +_POOL_STEM_RE = re.compile(r"^(?P