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.+?) \((?P<year>\d{4})\)$") +_FOUR_DIGITS = re.compile(r"\d{4}") + + +def is_valid_year(year: object) -> bool: + """True when ``year`` is (or stringifies to) exactly four digits.""" + return bool(re.fullmatch(r"\d{4}", str(year).strip())) + + +def canonical_pool_stem(title: str, year: object) -> str: + """Build the canonical pool stem ``Title (YYYY)``. + + Raises: + ValueError: the title is empty or the year is not four digits. + """ + title = (title or "").strip() + year_s = str(year).strip() + if not title: + raise ValueError("Název filmu nesmí být prázdný.") + if not re.fullmatch(r"\d{4}", year_s): + raise ValueError(f"Rok musí být čtyřmístné číslo, ne „{year}“.") + return f"{title} ({year_s})" + + +def parse_pool_stem(stem: str) -> tuple[str, int] | None: + """Split a canonical stem into ``(title, year)``; ``None`` if it isn't one.""" + match = _POOL_STEM_RE.match((stem or "").strip()) + if not match: + return None + return match.group("title").strip(), int(match.group("year")) + + +def is_canonical_pool_stem(stem: str) -> bool: + """True when ``stem`` matches the ``Title (YYYY)`` convention.""" + return _POOL_STEM_RE.match((stem or "").strip()) is not None + + +def year_from_filename(name: str) -> str | None: + """Best-effort four-digit year guessed from a raw (release) filename. + + Prefers a parenthesised year (``… (1999) …``) and otherwise falls back to the + last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the + user confirms it. Returns the year as a string, or ``None``. + """ + paren = re.search(r"\((19\d{2}|20\d{2})\)", name) + if paren: + return paren.group(1) + loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name) + return loose[-1] if loose else None diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 1ccdf3a..36fdd66 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -14,8 +14,8 @@ import subprocess from pathlib import Path from typing import List, Optional -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtCore import Qt, QTimer, QRegularExpression +from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, @@ -29,6 +29,7 @@ from src.core.file import File from src.core.tag import Tag from src.constants import APP_TITLE from src.core.hardlink_manager import HardlinkManager +from src.core.naming import canonical_pool_stem, is_valid_year, year_from_filename # Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV # browsers sort them ahead of the genre folders at the output root. @@ -54,21 +55,30 @@ class ImportMoviesDialog(QDialog): self.setWindowTitle("Importovat filmy do poolu") self.setMinimumSize(720, 360) - # Lower-cased names already present in the pool (for collision highlight) + # Lower-cased canonical stems already present in the pool (collision check) self.existing_names = {n.lower() for n in (existing_names or set())} - # (source path, title field, ČSFD field) per row - self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = [] + # (source path, title field, year field, ČSFD field) per row + self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit]] = [] layout = QVBoxLayout(self) - self.table = QTableWidget(0, 4) - self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""]) + info = QLabel( + "Filmy se do poolu uloží jako „Název (rok).ext“. " + "Rok je povinný (čtyřmístné číslo). " + "Modře = rok navržený z ČSFD (ověř ho); červeně = název už v poolu je.") + info.setWordWrap(True) + layout.addWidget(info) + + self.table = QTableWidget(0, 5) + self.table.setHorizontalHeaderLabels( + ["Soubor", "Název", "Rok", "ČSFD odkaz", ""]) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) - header.setSectionResizeMode(2, QHeaderView.Stretch) - header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) layout.addWidget(self.table) add_row = QHBoxLayout() @@ -99,8 +109,20 @@ class ImportMoviesDialog(QDialog): name_item = QTableWidgetItem(source.name) name_item.setFlags(Qt.ItemIsEnabled) self.table.setItem(row, 0, name_item) + title_edit = QLineEdit(source.stem) title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) + + year_edit = QLineEdit(year_from_filename(source.name) or "") + year_edit.setPlaceholderText("rok") + year_edit.setMaximumWidth(64) + year_edit.setValidator( + QRegularExpressionValidator(QRegularExpression(r"\d{0,4}"), year_edit)) + year_edit.textChanged.connect( + lambda _t, e=title_edit: self._mark_collision(e)) + # Manual edits clear the "suggested" (blue) marker; ČSFD autofill re-adds it. + year_edit.textEdited.connect(lambda _t, e=year_edit: self._set_suggested(e, False)) + csfd_edit = QLineEdit() csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") remove_btn = QPushButton("✕") @@ -108,24 +130,52 @@ class ImportMoviesDialog(QDialog): remove_btn.setToolTip("Odebrat tento soubor z importu") remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e)) self.table.setCellWidget(row, 1, title_edit) - self.table.setCellWidget(row, 2, csfd_edit) - self.table.setCellWidget(row, 3, remove_btn) - self._rows.append((source, title_edit, csfd_edit)) + self.table.setCellWidget(row, 2, year_edit) + self.table.setCellWidget(row, 3, csfd_edit) + self.table.setCellWidget(row, 4, remove_btn) + self._rows.append((source, title_edit, year_edit, csfd_edit)) self._mark_collision(title_edit) + def _row_of(self, title_edit: QLineEdit) -> tuple[Path, QLineEdit, QLineEdit, QLineEdit] | None: + for entry in self._rows: + if entry[1] is title_edit: + return entry + return None + + def _candidate_stem(self, title: str, year: str) -> str | None: + """The canonical 'Title (year)' stem, or None if it can't be built yet.""" + if not (title.strip() and is_valid_year(year)): + return None + try: + return canonical_pool_stem(title, year) + except ValueError: + return None + 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 + """Red title when the resulting 'Title (year)' name already exists.""" + entry = self._row_of(title_edit) + if entry is None: + return + _src, _title, year_edit, _csfd = entry + stem = self._candidate_stem(title_edit.text(), year_edit.text()) + clashes = stem is not None and stem.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 "") + "V poolu už existuje film „" + stem + "“ — při importu se tě zeptám, " + "co s ním." if clashes else "") + + def _set_suggested(self, year_edit: QLineEdit, suggested: bool) -> None: + """Blue tint marks a year proposed by ČSFD (not yet confirmed by the user).""" + year_edit.setStyleSheet( + "QLineEdit { color: #2980b9; }" if suggested else "") + year_edit.setToolTip( + "Rok navržený z ČSFD — ověř ho." if suggested 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: + for index, entry in enumerate(self._rows): + if entry[1] is title_edit: self.table.removeRow(index) self._rows.pop(index) return @@ -136,11 +186,15 @@ class ImportMoviesDialog(QDialog): self._append_row(Path(path)) def _autofill_csfd(self) -> None: - """Fill empty ČSFD fields by searching ČSFD for each file's cleaned name.""" + """Fill empty ČSFD links (and empty years) from a ČSFD search per row. + + Uses the top search hit: its URL fills an empty link, its year fills an + empty year field and is tinted blue (a suggestion to verify). + """ import requests from src.core import csfd - targets = [(t, c) for _, t, c in self._rows if not c.text().strip()] + targets = [(t, y, c) for _, t, y, c in self._rows if not c.text().strip()] if not targets: QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.") return @@ -149,15 +203,21 @@ class ImportMoviesDialog(QDialog): QApplication.setOverrideCursor(Qt.WaitCursor) try: with requests.Session() as session: - for title_edit, csfd_edit in targets: + for title_edit, year_edit, csfd_edit in targets: query = csfd.clean_filename_to_query(title_edit.text()) try: - url = csfd.find_csfd_url(query, session=session) + hits = csfd.search_movies(query, limit=1, session=session) except Exception: # noqa: BLE001 — network/parse failure for one row - url = None - if url: - csfd_edit.setText(url) + hits = [] + if not hits: + continue + hit = hits[0] + if hit.url: + csfd_edit.setText(hit.url) found += 1 + if hit.year and not year_edit.text().strip(): + year_edit.setText(str(hit.year)) + self._set_suggested(year_edit, True) finally: QApplication.restoreOverrideCursor() @@ -165,16 +225,34 @@ class ImportMoviesDialog(QDialog): self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů." ) + def accept(self) -> None: # noqa: D401 — Qt override + """Block the import until every row has a title and a four-digit year.""" + bad_year: list[str] = [] + for source, title_edit, year_edit, _csfd in self._rows: + title = title_edit.text().strip() or source.stem + if not is_valid_year(year_edit.text()): + bad_year.append(title) + if bad_year: + listing = "\n".join(f" • {t}" for t in bad_year[:10]) + more = f"\n … a další ({len(bad_year) - 10})" if len(bad_year) > 10 else "" + QMessageBox.warning( + self, "Chybí rok", + "Každý film musí mít čtyřmístný rok (uloží se jako „Název " + f"(rok).ext“):\n{listing}{more}") + return + super().accept() + @property def move_files(self) -> bool: return self.move_check.isChecked() - def entries(self) -> list[tuple[Path, str, str]]: - """Return (source, title, csfd_link) per row; title falls back to stem.""" - result: list[tuple[Path, str, str]] = [] - for source, title_edit, csfd_edit in self._rows: + def entries(self) -> list[tuple[Path, str, str, str]]: + """Return (source, title, year, csfd_link) per row; title falls back to stem.""" + result: list[tuple[Path, str, str, str]] = [] + for source, title_edit, year_edit, csfd_edit in self._rows: title = title_edit.text().strip() or source.stem - result.append((source, title, csfd_edit.text().strip())) + result.append( + (source, title, year_edit.text().strip(), csfd_edit.text().strip())) return result @@ -499,6 +577,8 @@ class QtApp(QMainWindow): movie_menu = bar.addMenu("&Filmy") self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I") self._add_action(movie_menu, "Přejmenovat…", self.rename_movie, "F2") + self._add_action( + movie_menu, "Přejmenovat dle ČSFD (rok)…", self.rename_to_csfd_naming) self._add_action(movie_menu, "Přiřadit štítky…", self.assign_tags, "Ctrl+T") self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute) @@ -772,8 +852,13 @@ class QtApp(QMainWindow): return move = dialog.move_files - # Files whose name already exists in the pool (the red-highlighted rows) - colliding = [t for s, t, _ in entries if self.filehandler.pooled_with_stem(t)] + # Rows whose canonical "Title (year)" name already exists in the pool + colliding = [ + canonical_pool_stem(t, y) + for s, t, y, _ in entries + if is_valid_year(y) + and self.filehandler.pooled_with_stem(canonical_pool_stem(t, y)) + ] on_conflict = "suffix" if colliding: on_conflict = self._resolve_import_conflicts(colliding) @@ -783,10 +868,11 @@ class QtApp(QMainWindow): imported: list[File] = [] skipped = 0 errors: list[str] = [] - for source, title, csfd_link in entries: + for source, title, year, csfd_link in entries: try: movie = self.filehandler.import_movie( - source, title, csfd_link or None, move=move, on_conflict=on_conflict) + source, title, csfd_link or None, move=move, + on_conflict=on_conflict, year=year or None) if movie is None: skipped += 1 else: @@ -931,6 +1017,68 @@ class QtApp(QMainWindow): self.refresh_table() self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000) + def rename_to_csfd_naming(self) -> None: + """Rename movies to the 'Title (rok).ext' convention from their metadata.""" + files = self._selected_movies() or self.filehandler.filelist + if not files: + QMessageBox.information(self, "Přejmenovat dle ČSFD", "Pool je prázdný.") + return + scope = "vybrané filmy" if self._selected_movies() else "celý pool" + + answer = QMessageBox.question( + self, "Přejmenovat dle ČSFD (rok)", + f"Přejmenovat {scope} ({len(files)}) na „Název (rok).ext“ podle " + "staženého roku z ČSFD?\n\nFilmy bez známého roku se přeskočí.", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if answer != QMessageBox.Yes: + return + + progress = QProgressDialog("Přejmenovávám…", "Zrušit", 0, len(files), self) + progress.setWindowTitle("Přejmenovat dle ČSFD (rok)") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + + def on_progress(done: int, total: int, f: File) -> None: + progress.setValue(done) + progress.setLabelText(f"[{done}/{total}] {f.filename}") + QApplication.processEvents() + + result = self.filehandler.rename_all_to_canonical( + files, on_progress=on_progress, should_cancel=progress.wasCanceled) + progress.setValue(len(files)) + self.refresh_table() + + renamed = result["renamed"] + no_year = result["skipped_no_year"] + conflicts = result["conflicts"] + suffix = " (zrušeno)" if result["cancelled"] else "" + summary = ( + f"Přejmenováno: {len(renamed)}, beze změny: {result['unchanged']}, " + f"bez roku: {len(no_year)}, kolize: {len(conflicts)}{suffix}") + + if not (no_year or conflicts): + QMessageBox.information(self, "Přejmenování dokončeno", summary) + else: + detail: list[str] = [] + if renamed: + detail.append(f"■ Přejmenováno ({len(renamed)}):") + detail += [f" • {old} → {new}" for old, new in renamed] + detail.append("") + if no_year: + detail.append(f"■ Bez známého roku — přeskočeno ({len(no_year)}):") + detail += [f" • {n}" for n in no_year] + detail.append("") + if conflicts: + detail.append(f"■ Kolize názvu ({len(conflicts)}):") + detail += [f" • {old} — {msg}" for old, msg in conflicts] + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning if conflicts else QMessageBox.Information) + box.setWindowTitle("Přejmenování dle ČSFD") + box.setText(summary + "\n\nPodrobnosti přes „Show Details…“.") + box.setDetailedText("\n".join(detail)) + box.exec() + self.status.showMessage(summary, 5000) + def edit_csfd(self) -> None: files = self._selected_movies() if len(files) != 1: diff --git a/tests/test_file.py b/tests/test_file.py index 51c585d..bceb5a1 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -271,7 +271,7 @@ class TestApplyCsfdTags: movie_file.csfd_cache = {"year": 1962, "rating": 75} ctx = movie_file.name_context() assert ctx["title"] == "Dr. No" - assert ctx["year"] == 1962 + assert ctx["year"] == "1962" # coerced to str for uniform template fields assert ctx["rating"] == 75 assert ctx["ext"] == movie_file.file_path.suffix assert ctx["filename"] == movie_file.filename diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index db67769..2f4036b 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -409,6 +409,69 @@ class TestPoolManagement: with pytest.raises(ValueError): file_manager.rename_movie(movie, " ") + def test_import_movie_with_year_uses_convention(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + source = tmp_path / "raw.mkv" + source.write_bytes(b"x" * 10) + + movie = file_manager.import_movie(source, "Matrix", year=1999) + + assert movie.file_path.name == "Matrix (1999).mkv" + assert movie.title == "Matrix" # clean title, no year + + def test_import_movie_rejects_invalid_year(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "a.mkv").write_bytes(b"a") + with pytest.raises(ValueError): + file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year="99") + + def test_import_same_title_different_year_coexist(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", "Solaris", year=1972) + second = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=2002) + + assert first.file_path.name == "Solaris (1972).mkv" + assert second.file_path.name == "Solaris (2002).mkv" # no collision/suffix + + def test_rename_to_canonical_from_csfd_year(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "raw.mkv").write_bytes(b"x") + movie = file_manager.import_movie(tmp_path / "raw.mkv", "Matrix") # plain + assert movie.file_path.name == "Matrix.mkv" + + movie.csfd_cache = {"year": 1999} + renamed = file_manager.rename_to_canonical(movie) + + assert renamed is not None + assert movie.file_path.name == "Matrix (1999).mkv" + assert movie.title == "Matrix" + + def test_rename_to_canonical_skips_without_year(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + (tmp_path / "raw.mkv").write_bytes(b"x") + movie = file_manager.import_movie(tmp_path / "raw.mkv", "Unknown") + + assert file_manager.rename_to_canonical(movie) is None + assert movie.file_path.name == "Unknown.mkv" # untouched + + def test_rename_all_to_canonical_reports(self, file_manager, tmp_path): + file_manager.set_pool_dir(tmp_path / "pool") + for n in ("a.mkv", "b.mkv", "c.mkv"): + (tmp_path / n).write_bytes(b"x") + with_year = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") + with_year.csfd_cache = {"year": 1999} + already = file_manager.import_movie(tmp_path / "b.mkv", "Solaris", year=1972) + _no_year = file_manager.import_movie(tmp_path / "c.mkv", "Mystery") + + result = file_manager.rename_all_to_canonical(file_manager.filelist) + + assert result["renamed"] == [("Matrix.mkv", "Matrix (1999).mkv")] + assert result["unchanged"] == 1 # Solaris (1972) already canonical + assert result["skipped_no_year"] == ["Mystery.mkv"] + assert already.file_path.name == "Solaris (1972).mkv" + def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path): file_manager.set_pool_dir(tmp_path / "pool") source = tmp_path / "raw.mkv" diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py index 99e13d7..8c5cbb5 100644 --- a/tests/test_hardlink_manager.py +++ b/tests/test_hardlink_manager.py @@ -208,6 +208,78 @@ class TestHardlinkManager: created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots) assert (created2, removed2) == (0, 0) + def test_link_name_strips_year_by_default( + self, tmp_path, tag_manager + ): + """A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'.""" + source = tmp_path / "source" + source.mkdir() + (source / "Solaris (1972).mkv").write_text("x") + f = File(source / "Solaris (1972).mkv", tag_manager) + f.tags.clear() + f.title = "Solaris" + f.add_tag(Tag("žánr", "Sci-Fi")) + + output = tmp_path / "output" + manager = HardlinkManager(output) + manager.create_structure_for_files([f], category_roots={"žánr": ""}) + + assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped + assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists() + + def test_link_name_keeps_year_on_collision( + self, tmp_path, tag_manager + ): + """Two same-title films in one folder keep the year to disambiguate.""" + source = tmp_path / "source" + source.mkdir() + (source / "Solaris (1972).mkv").write_text("a") + (source / "Solaris (2002).mkv").write_text("b") + f1 = File(source / "Solaris (1972).mkv", tag_manager) + f1.tags.clear() + f1.title = "Solaris" + f1.csfd_cache = {"year": 1972} + f1.add_tag(Tag("žánr", "Sci-Fi")) + f1.add_tag(Tag("rok", "1972")) + f2 = File(source / "Solaris (2002).mkv", tag_manager) + f2.tags.clear() + f2.title = "Solaris" + f2.csfd_cache = {"year": 2002} + f2.add_tag(Tag("žánr", "Sci-Fi")) + f2.add_tag(Tag("rok", "2002")) + + output = tmp_path / "output" + manager = HardlinkManager(output) + roots = {"žánr": "", "rok": "Dle roku"} + manager.create_structure_for_files([f1, f2], category_roots=roots) + + # Same genre folder → clash → both keep the year + assert (output / "Sci-Fi" / "Solaris (1972).mkv").exists() + assert (output / "Sci-Fi" / "Solaris (2002).mkv").exists() + assert not (output / "Sci-Fi" / "Solaris.mkv").exists() + # Separate year folders → no clash → clean name + assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists() + assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists() + + def test_clean_naming_cleanup_is_consistent( + self, tmp_path, tag_manager + ): + """Clean-named links are stable: a second sync removes/creates nothing.""" + source = tmp_path / "source" + source.mkdir() + (source / "Solaris (1972).mkv").write_text("x") + f = File(source / "Solaris (1972).mkv", tag_manager) + f.tags.clear() + f.title = "Solaris" + f.add_tag(Tag("žánr", "Sci-Fi")) + roots = {"žánr": ""} + + output = tmp_path / "output" + manager = HardlinkManager(output) + manager.sync_structure([f], category_roots=roots) + created, _, removed, _ = manager.sync_structure([f], category_roots=roots) + assert (created, removed) == (0, 0) + def test_sync_removes_root_genre_folder_when_last_movie_drops_tag( self, temp_source_dir, temp_output_dir, tag_manager ): @@ -263,8 +335,8 @@ class TestHardlinkManager: # 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() + # Other categories use the default clean title (no year, not the pool name) + assert (temp_output_dir / "Akční" / "Dr. No.txt").exists() def test_filename_template_cleanup_is_consistent( self, temp_source_dir, temp_output_dir, tag_manager @@ -421,30 +493,27 @@ class TestHardlinkManager: assert fail2 == 0 def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager): - """Test že při konfliktu (jiný soubor) se použije unikátní jméno""" - # Create first file + """Two different movies with the same clean name (no year to tell them + apart) in one folder get distinct links via a numeric suffix.""" f1 = File(temp_source_dir / "file1.txt", tag_manager) f1.tags.clear() f1.add_tag(Tag("test", "tag")) - manager = HardlinkManager(temp_output_dir) - manager.create_structure_for_files([f1]) - - # Create different file with same name in different location source2 = temp_source_dir / "subdir" source2.mkdir() (source2 / "file1.txt").write_text("different content") - f2 = File(source2 / "file1.txt", tag_manager) f2.tags.clear() f2.add_tag(Tag("test", "tag")) - # Should create file1_1.txt - manager2 = HardlinkManager(temp_output_dir) - success, fail = manager2.create_structure_for_files([f2]) + # One generation over both files → the plan disambiguates the clash. + manager = HardlinkManager(temp_output_dir) + success, fail = manager.create_structure_for_files([f1, f2]) - assert success == 1 - assert (temp_output_dir / "test" / "tag" / "file1_1.txt").exists() + assert success == 2 and fail == 0 + folder = temp_output_dir / "test" / "tag" + assert (folder / "file1.txt").exists() + assert (folder / "file1_2.txt").exists() def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager): """Test českých znaků v názvech tagů""" @@ -746,9 +815,11 @@ class TestEdgeCases: """Test souboru se speciálními znaky v názvu""" source = tmp_path / "source" source.mkdir() - (source / "file with spaces (2024).txt").write_text("content") + # Parentheses that are NOT a four-digit year must survive verbatim + # (a trailing "(YYYY)" would be stripped by the naming rule). + (source / "file with spaces (part 2).txt").write_text("content") - f = File(source / "file with spaces (2024).txt", tag_manager) + f = File(source / "file with spaces (part 2).txt", tag_manager) f.tags.clear() f.add_tag(Tag("test", "tag")) @@ -759,7 +830,7 @@ class TestEdgeCases: success, fail = manager.create_structure_for_files([f]) assert success == 1 - assert (output / "test" / "tag" / "file with spaces (2024).txt").exists() + assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists() def test_empty_category_filter(self, tmp_path, tag_manager): """Test s prázdným seznamem kategorií""" diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 0000000..6e3ea9c --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,62 @@ +"""Tests for the pool naming convention helpers (src.core.naming).""" +import pytest + +from src.core.naming import ( + canonical_pool_stem, + is_canonical_pool_stem, + is_valid_year, + parse_pool_stem, + year_from_filename, +) + + +@pytest.mark.parametrize("year,ok", [ + ("1968", True), (1968, True), ("968", False), ("19688", False), + ("abcd", False), ("", False), (" 1999 ", True), +]) +def test_is_valid_year(year, ok): + assert is_valid_year(year) is ok + + +def test_canonical_pool_stem_builds_title_year(): + assert canonical_pool_stem("Matrix", 1999) == "Matrix (1999)" + assert canonical_pool_stem(" Matrix ", "1999") == "Matrix (1999)" + + +def test_canonical_pool_stem_rejects_bad_year(): + with pytest.raises(ValueError): + canonical_pool_stem("Matrix", "99") + + +def test_canonical_pool_stem_rejects_empty_title(): + with pytest.raises(ValueError): + canonical_pool_stem(" ", "1999") + + +def test_parse_pool_stem_roundtrip(): + assert parse_pool_stem("Matrix (1999)") == ("Matrix", 1999) + # title keeps its own parentheses; only the trailing year is peeled off + assert parse_pool_stem("Já, robot (2004)") == ("Já, robot", 2004) + assert parse_pool_stem("Kurz (special) (2019)") == ("Kurz (special)", 2019) + + +def test_parse_pool_stem_non_canonical(): + assert parse_pool_stem("Matrix") is None + assert parse_pool_stem("Matrix (99)") is None + assert parse_pool_stem("Matrix 1999") is None + + +def test_is_canonical_pool_stem(): + assert is_canonical_pool_stem("Matrix (1999)") + assert not is_canonical_pool_stem("Matrix") + + +@pytest.mark.parametrize("name,year", [ + ("Matrix.1999.1080p.BluRay.mkv", "1999"), + ("Šíleně smutná princezna (1968).mkv", "1968"), + ("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins + ("No Year Here.mkv", None), + ("Film 12345.mkv", None), # not a bare 4-digit year +]) +def test_year_from_filename(name, year): + assert year_from_filename(name) == year