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
+133 -79
View File
@@ -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