Adopt "Title (YYYY)" pool naming and clean Filmotéka link names

This commit is contained in:
2026-07-27 20:51:31 +02:00
parent 4c4180f28c
commit 1c78ca9013
13 changed files with 784 additions and 145 deletions
+35
View File
@@ -21,6 +21,41 @@ Each version entry uses these sections (include only those that apply):
## Unreleased ## 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 ## 1.10.0 — 2026-07-07
### Added ### Added
+28 -7
View File
@@ -98,8 +98,17 @@ movie table, and one-click Filmotéka generation.
Filmotéka output, with the files materialized as **hardlinks** into the pool. 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, So `pool/Seriály/...` is cloned 1:1 into `output/Seriály/...` (same structure,
hardlinked files). This is how Seriály work. hardlinked files). This is how Seriály work.
- **File naming:** imported movies are renamed to **`Title.ext`** (no year in the - **File naming:** pooled movies follow the convention **`Title (YYYY).ext`** — a
filename; year lives in metadata/tags). 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 - **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 pool (non-destructive); the import dialog also offers a **move** option that
relocates the source into the pool instead. 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 `transform` (e.g. `decade_band`) shapes only the **folder name** — tags keep
the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90100 %`); the **exact value** (rating → tag `Hodnocení/90`, folder `Dle hodnocení/90100 %`);
it is applied at Filmotéka generation via `filmoteka_category_transforms`. it is applied at Filmotéka generation via `filmoteka_category_transforms`.
- **Per-category filename template** (`filename_template` in a schema entry): the - **Filmotéka link naming (clean title, year on collision):** the default
hardlink name **inside that category's folders only** is rendered from the hardlink name in the tag tree is the movie's **clean title**`Title.ext`,
movie's metadata (`File.name_context`: title/year/rating/ext/stem/filename plus *not* the pooled `Title (YYYY).ext`. When two different films would collide on
any free-form attributes), e.g. a Kolekce with `"{collection_sort} - {title}{ext}"`. that name **within one folder**, the colliding ones keep the disambiguating
Other folders and the pool file keep the plain name; applied via **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`. `filmoteka_category_filename_templates`.
- **Free-form per-movie attributes** (`File.attributes`, set in the GUI): arbitrary - **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 `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 ## 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"): - Video integrity check (`integrity.py`, Testy → "Kontrola integrity videa"):
FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or FFmpeg-based scan for corrupted/unreadable video data, deep (full decode) or
quick (ffprobe), over selected movies or the whole pool quick (ffprobe), over selected movies or the whole pool
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "curator" name = "curator"
version = "1.10.0" version = "1.11.0"
description = "" description = ""
authors = [ authors = [
{name = "jan.doubravsky@gmail.com"} {name = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually.""" """Auto-generated — do not edit manually."""
__version__ = "1.10.0" __version__ = "1.11.0"
+7 -1
View File
@@ -139,12 +139,18 @@ class File:
if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit(): if isinstance(t, Tag) and t.category == "Rok" and t.name.isdigit():
year = t.name year = t.name
break 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") rating = cache.get("rating")
# user attributes first; core fields take precedence over same-named keys # user attributes first; core fields take precedence over same-named keys
context = dict(self.attributes) context = dict(self.attributes)
context.update({ context.update({
"title": self.title or self.file_path.stem, "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, "rating": "" if rating is None else rating,
"ext": self.file_path.suffix, "ext": self.file_path.suffix,
"stem": self.file_path.stem, "stem": self.file_path.stem,
+121 -4
View File
@@ -7,6 +7,7 @@ from .tag_manager import TagManager
from .pool_index import PoolIndex from .pool_index import PoolIndex
from .utils import list_files from .utils import list_files
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult 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 typing import Callable, Iterable
from src.core.config import ( from src.core.config import (
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
@@ -326,8 +327,14 @@ class FileManager:
def import_movie( def import_movie(
self, source: Path, title: str, csfd_link: str | None = None, self, source: Path, title: str, csfd_link: str | None = None,
move: bool = False, on_conflict: str = "suffix", move: bool = False, on_conflict: str = "suffix",
year: int | str | None = None,
) -> File | 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`` By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind. the source file is moved into the pool instead, leaving nothing behind.
@@ -339,6 +346,9 @@ class FileManager:
- ``"replace"``: evict the existing same-named movie(s) (file + metadata) - ``"replace"``: evict the existing same-named movie(s) (file + metadata)
and import the new one under the plain name. and import the new one under the plain name.
- ``"skip"``: do not import; return ``None``. - ``"skip"``: do not import; return ``None``.
Raises:
ValueError: a ``year`` was given but is not a four-digit number.
""" """
movies = self.movies_dir movies = self.movies_dir
pool = self.pool_dir pool = self.pool_dir
@@ -351,9 +361,11 @@ class FileManager:
source = Path(source) source = Path(source)
safe_title = title.strip() or source.stem 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() conflict = bool(existing) or target.exists()
if conflict and on_conflict == "skip": if conflict and on_conflict == "skip":
@@ -367,7 +379,7 @@ class FileManager:
# "suffix": never clobber an existing exact filename # "suffix": never clobber an existing exact filename
counter = 1 counter = 1
while target.exists(): while target.exists():
target = movies / f"{safe_title}_{counter}{source.suffix}" target = movies / f"{stem}_{counter}{source.suffix}"
counter += 1 counter += 1
if move: if move:
@@ -420,6 +432,111 @@ class FileManager:
self.on_files_changed(self.filelist) self.on_files_changed(self.filelist)
return file_obj 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): 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.""" """Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
for f in files_objs: for f in files_objs:
+133 -79
View File
@@ -19,9 +19,11 @@ Example:
""" """
import os import os
import random import random
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import List, Tuple, Optional, Dict, Set from typing import List, Tuple, Optional, Dict, Set
from .file import File from .file import File
from .naming import is_valid_year, parse_pool_stem
class _SafeDict(dict): class _SafeDict(dict):
@@ -69,24 +71,102 @@ class HardlinkManager:
return {cat: cat for cat in categories} return {cat: cat for cat in categories}
return None return None
def _link_name( def _clean_title(self, file_obj: File) -> str:
self, file_obj: File, tag, templates: Optional[Dict[str, str]] """The movie's clean title (no trailing ``(YYYY)``), path-safe."""
) -> str: ctx = file_obj.name_context()
"""Hardlink filename for a tag — a per-category template or the pool name. 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 def _default_link_name(self, file_obj: File) -> str:
the file's ``name_context``; path separators are flattened. Any failure or """Filmotéka hardlink name: the clean title + extension (no year)."""
empty result falls back to the pool filename. return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
"""
template = templates.get(tag.category) if templates else None def _year_link_name(self, file_obj: File) -> Optional[str]:
if not template: """``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
return file_obj.filename 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: try:
rendered = template.format_map(_SafeDict(file_obj.name_context())) rendered = template.format_map(_SafeDict(file_obj.name_context()))
except (ValueError, KeyError, IndexError, AttributeError): except (ValueError, KeyError, IndexError, AttributeError):
return file_obj.filename return self._default_link_name(file_obj)
rendered = rendered.replace("/", "-").replace("\\", "-").strip() 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: def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
"""Folder name for a tag — its value run through the category transform. """Folder name for a tag — its value run through the category transform.
@@ -185,41 +265,28 @@ class HardlinkManager:
success_count = 0 success_count = 0
fail_count = 0 fail_count = 0
for file_obj in files: plan = self._plan_links(
if not file_obj.tags: files, roots, category_transforms, category_filename_templates)
continue 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: os.link(file_obj.file_path, target_file)
# 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)
try: self.created_links.append(target_file)
if not dry_run: success_count += 1
# Create directory structure
target_dir.mkdir(parents=True, exist_ok=True)
# Skip if link already exists except OSError as e:
if target_file.exists(): self.errors.append((file_obj.file_path, str(e)))
# Check if it's already a hardlink to the same file fail_count += 1
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
return success_count, fail_count return success_count, fail_count
@@ -291,7 +358,7 @@ class HardlinkManager:
created = 0 created = 0
fail = 0 fail = 0
for file_obj in chosen: for file_obj in chosen:
target = base / file_obj.filename target = base / self._default_link_name(file_obj)
try: try:
if not dry_run: if not dry_run:
base.mkdir(parents=True, exist_ok=True) base.mkdir(parents=True, exist_ok=True)
@@ -299,7 +366,13 @@ class HardlinkManager:
if self._is_same_file(file_obj.file_path, target): if self._is_same_file(file_obj.file_path, target):
created += 1 created += 1
continue 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) os.link(file_obj.file_path, target)
self.created_links.append(target) self.created_links.append(target)
created += 1 created += 1
@@ -418,22 +491,9 @@ class HardlinkManager:
List of tuples (source_path, target_path) List of tuples (source_path, target_path)
""" """
roots = self._resolve_roots(categories, category_roots) roots = self._resolve_roots(categories, category_roots)
preview = [] plan = self._plan_links(
files, roots, category_transforms, category_filename_templates)
for file_obj in files: return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
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
def find_obsolete_links( def find_obsolete_links(
self, self,
@@ -479,26 +539,20 @@ class HardlinkManager:
continue continue
# Every link the tag tree *should* contain, mapped to the inode(s) it may # 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 # point at — taken from the very same plan that creates the links, so the
# name in the same folder), so the value is a *set* of valid inodes. # two never disagree on a name. Matching by inode too — not just the path
# Matching by inode too — not just the path — is what catches an **orphan # — is what catches an **orphan that squats the expected name**: a link
# that squats the expected name**: a link whose name is right but which # whose name is right but which points to an old inode (movie
# points to an old inode (movie re-imported/replaced) is still obsolete, # re-imported/replaced) is still obsolete, while the correctly created
# while the correctly re-created link (forced to a ``_1`` suffix because # link is *not* wrongly swept.
# the orphan holds the base name) is *not* wrongly swept.
expected: dict[Path, set[int]] = {} 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: try:
file_inode = file_obj.file_path.stat().st_ino expected.setdefault(path, set()).add(file_obj.file_path.stat().st_ino)
except OSError: except OSError:
continue 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). # 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 # Inside them, any file that isn't an expected link is obsolete — whether
+62
View File
@@ -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<title>.+?) \((?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
+182 -34
View File
@@ -14,8 +14,8 @@ import subprocess
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer, QRegularExpression
from PySide6.QtGui import QAction, QKeySequence from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, QApplication, QMainWindow, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem,
QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -29,6 +29,7 @@ from src.core.file import File
from src.core.tag import Tag from src.core.tag import Tag
from src.constants import APP_TITLE from src.constants import APP_TITLE
from src.core.hardlink_manager import HardlinkManager 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 # Special auto-generated Filmotéka folders. The "- " prefix makes DLNA/TV
# browsers sort them ahead of the genre folders at the output root. # 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.setWindowTitle("Importovat filmy do poolu")
self.setMinimumSize(720, 360) 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())} self.existing_names = {n.lower() for n in (existing_names or set())}
# (source path, title field, ČSFD field) per row # (source path, title field, year field, ČSFD field) per row
self._rows: list[tuple[Path, QLineEdit, QLineEdit]] = [] self._rows: list[tuple[Path, QLineEdit, QLineEdit, QLineEdit]] = []
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.table = QTableWidget(0, 4) info = QLabel(
self.table.setHorizontalHeaderLabels(["Soubor", "Název", "ČSFD odkaz", ""]) "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) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
layout.addWidget(self.table) layout.addWidget(self.table)
add_row = QHBoxLayout() add_row = QHBoxLayout()
@@ -99,8 +109,20 @@ class ImportMoviesDialog(QDialog):
name_item = QTableWidgetItem(source.name) name_item = QTableWidgetItem(source.name)
name_item.setFlags(Qt.ItemIsEnabled) name_item.setFlags(Qt.ItemIsEnabled)
self.table.setItem(row, 0, name_item) self.table.setItem(row, 0, name_item)
title_edit = QLineEdit(source.stem) title_edit = QLineEdit(source.stem)
title_edit.textChanged.connect(lambda _t, e=title_edit: self._mark_collision(e)) 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 = QLineEdit()
csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...") csfd_edit.setPlaceholderText("https://www.csfd.cz/film/...")
remove_btn = QPushButton("") remove_btn = QPushButton("")
@@ -108,24 +130,52 @@ class ImportMoviesDialog(QDialog):
remove_btn.setToolTip("Odebrat tento soubor z importu") remove_btn.setToolTip("Odebrat tento soubor z importu")
remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e)) remove_btn.clicked.connect(lambda _c, e=title_edit: self._remove_row(e))
self.table.setCellWidget(row, 1, title_edit) self.table.setCellWidget(row, 1, title_edit)
self.table.setCellWidget(row, 2, csfd_edit) self.table.setCellWidget(row, 2, year_edit)
self.table.setCellWidget(row, 3, remove_btn) self.table.setCellWidget(row, 3, csfd_edit)
self._rows.append((source, title_edit, csfd_edit)) self.table.setCellWidget(row, 4, remove_btn)
self._rows.append((source, title_edit, year_edit, csfd_edit))
self._mark_collision(title_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: def _mark_collision(self, title_edit: QLineEdit) -> None:
"""Highlight the title in red when that name already exists in the pool.""" """Red title when the resulting 'Title (year)' name already exists."""
clashes = title_edit.text().strip().lower() in self.existing_names 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( title_edit.setStyleSheet(
"QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "") "QLineEdit { color: #c0392b; font-weight: bold; }" if clashes else "")
title_edit.setToolTip( title_edit.setToolTip(
"V poolu už existuje film s tímto názvem — při importu dostane " "V poolu už existuje film " + stem + " — při importu se tě zeptám, "
"číselnou příponu." if clashes else "") "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: def _remove_row(self, title_edit: QLineEdit) -> None:
"""Drop a single file from the import list without restarting the dialog.""" """Drop a single file from the import list without restarting the dialog."""
for index, (_src, edit, _csfd) in enumerate(self._rows): for index, entry in enumerate(self._rows):
if edit is title_edit: if entry[1] is title_edit:
self.table.removeRow(index) self.table.removeRow(index)
self._rows.pop(index) self._rows.pop(index)
return return
@@ -136,11 +186,15 @@ class ImportMoviesDialog(QDialog):
self._append_row(Path(path)) self._append_row(Path(path))
def _autofill_csfd(self) -> None: 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 import requests
from src.core import csfd 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: if not targets:
QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.") QMessageBox.information(self, "ČSFD", "Všechny řádky už mají odkaz.")
return return
@@ -149,15 +203,21 @@ class ImportMoviesDialog(QDialog):
QApplication.setOverrideCursor(Qt.WaitCursor) QApplication.setOverrideCursor(Qt.WaitCursor)
try: try:
with requests.Session() as session: 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()) query = csfd.clean_filename_to_query(title_edit.text())
try: 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 except Exception: # noqa: BLE001 — network/parse failure for one row
url = None hits = []
if url: if not hits:
csfd_edit.setText(url) continue
hit = hits[0]
if hit.url:
csfd_edit.setText(hit.url)
found += 1 found += 1
if hit.year and not year_edit.text().strip():
year_edit.setText(str(hit.year))
self._set_suggested(year_edit, True)
finally: finally:
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
@@ -165,16 +225,34 @@ class ImportMoviesDialog(QDialog):
self, "ČSFD", f"Vyplněno {found} z {len(targets)} hledaných odkazů." 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 @property
def move_files(self) -> bool: def move_files(self) -> bool:
return self.move_check.isChecked() return self.move_check.isChecked()
def entries(self) -> list[tuple[Path, str, str]]: def entries(self) -> list[tuple[Path, str, str, str]]:
"""Return (source, title, csfd_link) per row; title falls back to stem.""" """Return (source, title, year, csfd_link) per row; title falls back to stem."""
result: list[tuple[Path, str, str]] = [] result: list[tuple[Path, str, str, str]] = []
for source, title_edit, csfd_edit in self._rows: for source, title_edit, year_edit, csfd_edit in self._rows:
title = title_edit.text().strip() or source.stem 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 return result
@@ -499,6 +577,8 @@ class QtApp(QMainWindow):
movie_menu = bar.addMenu("&Filmy") movie_menu = bar.addMenu("&Filmy")
self._add_action(movie_menu, "Importovat filmy…", self.import_movie, "Ctrl+I") 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…", 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, "Přiřadit štítky…", self.assign_tags, "Ctrl+T")
self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D") self._add_action(movie_menu, "Nastavit datum…", self.set_date, "Ctrl+D")
self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute) self._add_action(movie_menu, "Nastavit atribut…", self.set_attribute)
@@ -772,8 +852,13 @@ class QtApp(QMainWindow):
return return
move = dialog.move_files move = dialog.move_files
# Files whose name already exists in the pool (the red-highlighted rows) # Rows whose canonical "Title (year)" name already exists in the pool
colliding = [t for s, t, _ in entries if self.filehandler.pooled_with_stem(t)] 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" on_conflict = "suffix"
if colliding: if colliding:
on_conflict = self._resolve_import_conflicts(colliding) on_conflict = self._resolve_import_conflicts(colliding)
@@ -783,10 +868,11 @@ class QtApp(QMainWindow):
imported: list[File] = [] imported: list[File] = []
skipped = 0 skipped = 0
errors: list[str] = [] errors: list[str] = []
for source, title, csfd_link in entries: for source, title, year, csfd_link in entries:
try: try:
movie = self.filehandler.import_movie( 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: if movie is None:
skipped += 1 skipped += 1
else: else:
@@ -931,6 +1017,68 @@ class QtApp(QMainWindow):
self.refresh_table() self.refresh_table()
self.status.showMessage(f"Přejmenováno na: {f.filename}", 5000) 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: def edit_csfd(self) -> None:
files = self._selected_movies() files = self._selected_movies()
if len(files) != 1: if len(files) != 1:
+1 -1
View File
@@ -271,7 +271,7 @@ class TestApplyCsfdTags:
movie_file.csfd_cache = {"year": 1962, "rating": 75} movie_file.csfd_cache = {"year": 1962, "rating": 75}
ctx = movie_file.name_context() ctx = movie_file.name_context()
assert ctx["title"] == "Dr. No" 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["rating"] == 75
assert ctx["ext"] == movie_file.file_path.suffix assert ctx["ext"] == movie_file.file_path.suffix
assert ctx["filename"] == movie_file.filename assert ctx["filename"] == movie_file.filename
+63
View File
@@ -409,6 +409,69 @@ class TestPoolManagement:
with pytest.raises(ValueError): with pytest.raises(ValueError):
file_manager.rename_movie(movie, " ") 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): def test_load_pool_movies_reads_from_index(self, file_manager, tmp_path):
file_manager.set_pool_dir(tmp_path / "pool") file_manager.set_pool_dir(tmp_path / "pool")
source = tmp_path / "raw.mkv" source = tmp_path / "raw.mkv"
+88 -17
View File
@@ -208,6 +208,78 @@ class TestHardlinkManager:
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots) created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
assert (created2, removed2) == (0, 0) 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( def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
self, temp_source_dir, temp_output_dir, tag_manager self, temp_source_dir, temp_output_dir, tag_manager
): ):
@@ -263,8 +335,8 @@ class TestHardlinkManager:
# Templated name inside the collection folder # Templated name inside the collection folder
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists() assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
# Other categories keep the pool filename # Other categories use the default clean title (no year, not the pool name)
assert (temp_output_dir / "Akční" / "file1.txt").exists() assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
def test_filename_template_cleanup_is_consistent( def test_filename_template_cleanup_is_consistent(
self, temp_source_dir, temp_output_dir, tag_manager self, temp_source_dir, temp_output_dir, tag_manager
@@ -421,30 +493,27 @@ class TestHardlinkManager:
assert fail2 == 0 assert fail2 == 0
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager): 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""" """Two different movies with the same clean name (no year to tell them
# Create first file apart) in one folder get distinct links via a numeric suffix."""
f1 = File(temp_source_dir / "file1.txt", tag_manager) f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear() f1.tags.clear()
f1.add_tag(Tag("test", "tag")) 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 = temp_source_dir / "subdir"
source2.mkdir() source2.mkdir()
(source2 / "file1.txt").write_text("different content") (source2 / "file1.txt").write_text("different content")
f2 = File(source2 / "file1.txt", tag_manager) f2 = File(source2 / "file1.txt", tag_manager)
f2.tags.clear() f2.tags.clear()
f2.add_tag(Tag("test", "tag")) f2.add_tag(Tag("test", "tag"))
# Should create file1_1.txt # One generation over both files → the plan disambiguates the clash.
manager2 = HardlinkManager(temp_output_dir) manager = HardlinkManager(temp_output_dir)
success, fail = manager2.create_structure_for_files([f2]) success, fail = manager.create_structure_for_files([f1, f2])
assert success == 1 assert success == 2 and fail == 0
assert (temp_output_dir / "test" / "tag" / "file1_1.txt").exists() 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): def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
"""Test českých znaků v názvech tagů""" """Test českých znaků v názvech tagů"""
@@ -746,9 +815,11 @@ class TestEdgeCases:
"""Test souboru se speciálními znaky v názvu""" """Test souboru se speciálními znaky v názvu"""
source = tmp_path / "source" source = tmp_path / "source"
source.mkdir() 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.tags.clear()
f.add_tag(Tag("test", "tag")) f.add_tag(Tag("test", "tag"))
@@ -759,7 +830,7 @@ class TestEdgeCases:
success, fail = manager.create_structure_for_files([f]) success, fail = manager.create_structure_for_files([f])
assert success == 1 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): def test_empty_category_filter(self, tmp_path, tag_manager):
"""Test s prázdným seznamem kategorií""" """Test s prázdným seznamem kategorií"""
+62
View File
@@ -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