Add per-movie attributes and per-category filename templates
This commit is contained in:
@@ -23,6 +23,13 @@ from typing import List, Tuple, Optional, Dict, Set
|
||||
from .file import File
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
||||
|
||||
def __missing__(self, key): # noqa: ANN001
|
||||
return ""
|
||||
|
||||
|
||||
class HardlinkManager:
|
||||
"""Manager for creating hardlink-based directory structures from tagged files.
|
||||
|
||||
@@ -61,7 +68,40 @@ class HardlinkManager:
|
||||
return {cat: cat for cat in categories}
|
||||
return None
|
||||
|
||||
def _target_dir(self, tag, roots: Optional[Dict[str, str]]) -> Optional[Path]:
|
||||
def _link_name(
|
||||
self, file_obj: File, tag, templates: Optional[Dict[str, str]]
|
||||
) -> str:
|
||||
"""Hardlink filename for a tag — a per-category template or the pool name.
|
||||
|
||||
Applies ``templates[tag.category]`` (e.g. ``"{year} - {title}{ext}"``) to
|
||||
the file's ``name_context``; path separators are flattened. Any failure or
|
||||
empty result falls back to the pool filename.
|
||||
"""
|
||||
template = templates.get(tag.category) if templates else None
|
||||
if not template:
|
||||
return file_obj.filename
|
||||
try:
|
||||
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||
except (ValueError, KeyError, IndexError, AttributeError):
|
||||
return file_obj.filename
|
||||
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||
return rendered or file_obj.filename
|
||||
|
||||
def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
|
||||
"""Folder name for a tag — its value run through the category transform.
|
||||
|
||||
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||
band) only happens here, when naming the folder.
|
||||
"""
|
||||
if transforms and tag.category in transforms:
|
||||
from .csfd import apply_transform
|
||||
return apply_transform(tag.name, transforms[tag.category])
|
||||
return tag.name
|
||||
|
||||
def _target_dir(
|
||||
self, tag, roots: Optional[Dict[str, str]],
|
||||
transforms: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Path]:
|
||||
"""Output directory for a tag, or None if its category is excluded."""
|
||||
if roots is None:
|
||||
folder = tag.category
|
||||
@@ -70,17 +110,18 @@ class HardlinkManager:
|
||||
else:
|
||||
return None
|
||||
base = self.output_dir / folder if folder else self.output_dir
|
||||
return base / tag.name
|
||||
return base / self._folder_value(tag, transforms)
|
||||
|
||||
def _managed_top_dirs(
|
||||
self, files: List[File], roots: Optional[Dict[str, str]]
|
||||
self, files: List[File], roots: Optional[Dict[str, str]],
|
||||
transforms: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Set[str]]:
|
||||
"""Top-level output folders owned by the tag tree (None = all of them).
|
||||
|
||||
For a category with a non-empty root the root folder is owned; for a
|
||||
category placed at the output root (empty root, e.g. genres) each of its
|
||||
tag names is its own top-level folder. This lets cleanup skip unrelated
|
||||
root entries such as the copy-as-is mirror (Seriály).
|
||||
(transformed) tag values is its own top-level folder. This lets cleanup
|
||||
skip unrelated root entries such as the copy-as-is mirror (Seriály).
|
||||
"""
|
||||
if roots is None:
|
||||
return None
|
||||
@@ -92,7 +133,7 @@ class HardlinkManager:
|
||||
for file_obj in files:
|
||||
for tag in file_obj.tags:
|
||||
if tag.category == cat:
|
||||
tops.add(tag.name)
|
||||
tops.add(self._folder_value(tag, transforms))
|
||||
return tops
|
||||
|
||||
def create_structure_for_files(
|
||||
@@ -101,6 +142,8 @@ class HardlinkManager:
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Create hardlink structure for given files based on their tags.
|
||||
@@ -111,6 +154,10 @@ class HardlinkManager:
|
||||
dry_run: If True, only simulate without creating actual links
|
||||
category_roots: Optional category → root-folder map (see class doc);
|
||||
overrides ``categories`` when given.
|
||||
category_transforms: Optional category → transform-name map applied to
|
||||
the tag value when naming its folder (e.g. rating → decade band).
|
||||
category_filename_templates: Optional category → hardlink-name template
|
||||
applied only inside that category's folders.
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_links, failed_links)
|
||||
@@ -128,10 +175,11 @@ class HardlinkManager:
|
||||
|
||||
for tag in file_obj.tags:
|
||||
# Resolve the target dir; None means this category is excluded
|
||||
target_dir = self._target_dir(tag, roots)
|
||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||
if target_dir is None:
|
||||
continue
|
||||
target_file = target_dir / file_obj.filename
|
||||
target_file = target_dir / self._link_name(
|
||||
file_obj, tag, category_filename_templates)
|
||||
|
||||
try:
|
||||
if not dry_run:
|
||||
@@ -269,6 +317,8 @@ class HardlinkManager:
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Get a preview of what links would be created.
|
||||
@@ -278,6 +328,8 @@ class HardlinkManager:
|
||||
categories: Optional list of categories to include
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
``categories`` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
category_filename_templates: Optional category → hardlink-name template.
|
||||
|
||||
Returns:
|
||||
List of tuples (source_path, target_path)
|
||||
@@ -290,10 +342,11 @@ class HardlinkManager:
|
||||
continue
|
||||
|
||||
for tag in file_obj.tags:
|
||||
target_dir = self._target_dir(tag, roots)
|
||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||
if target_dir is None:
|
||||
continue
|
||||
target_file = target_dir / file_obj.filename
|
||||
target_file = target_dir / self._link_name(
|
||||
file_obj, tag, category_filename_templates)
|
||||
|
||||
preview.append((file_obj.file_path, target_file))
|
||||
|
||||
@@ -304,6 +357,8 @@ class HardlinkManager:
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> List[Tuple[Path, Path]]:
|
||||
"""
|
||||
Find hardlinks in the output directory that no longer match file tags.
|
||||
@@ -318,6 +373,7 @@ class HardlinkManager:
|
||||
categories: Optional list of categories to check (None = all)
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
``categories`` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
|
||||
Returns:
|
||||
List of tuples (link_path, source_path) for obsolete links
|
||||
@@ -346,15 +402,16 @@ class HardlinkManager:
|
||||
expected_paths[inode] = set()
|
||||
|
||||
for tag in file_obj.tags:
|
||||
target_dir = self._target_dir(tag, roots)
|
||||
target_dir = self._target_dir(tag, roots, category_transforms)
|
||||
if target_dir is None:
|
||||
continue
|
||||
expected_paths[inode].add(target_dir / file_obj.filename)
|
||||
expected_paths[inode].add(target_dir / self._link_name(
|
||||
file_obj, tag, category_filename_templates))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
|
||||
top_dirs = self._managed_top_dirs(files, roots)
|
||||
top_dirs = self._managed_top_dirs(files, roots, category_transforms)
|
||||
for top in self.output_dir.iterdir():
|
||||
if not top.is_dir():
|
||||
continue
|
||||
@@ -382,6 +439,8 @@ class HardlinkManager:
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, List[Path]]:
|
||||
"""
|
||||
Remove hardlinks that no longer match file tags.
|
||||
@@ -392,11 +451,15 @@ class HardlinkManager:
|
||||
dry_run: If True, only return what would be removed
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
``categories`` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
category_filename_templates: Optional category → hardlink-name template.
|
||||
|
||||
Returns:
|
||||
Tuple of (removed_count, list_of_removed_paths)
|
||||
"""
|
||||
obsolete = self.find_obsolete_links(files, categories, category_roots)
|
||||
obsolete = self.find_obsolete_links(
|
||||
files, categories, category_roots, category_transforms,
|
||||
category_filename_templates)
|
||||
removed_paths = []
|
||||
|
||||
if dry_run:
|
||||
@@ -420,6 +483,8 @@ class HardlinkManager:
|
||||
categories: Optional[List[str]] = None,
|
||||
dry_run: bool = False,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Synchronize hardlink structure with current file tags.
|
||||
@@ -434,22 +499,28 @@ class HardlinkManager:
|
||||
dry_run: If True, only simulate
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
``categories`` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
category_filename_templates: Optional category → hardlink-name template.
|
||||
|
||||
Returns:
|
||||
Tuple of (created, create_failed, removed, remove_failed)
|
||||
"""
|
||||
# First find how many obsolete links there are
|
||||
obsolete_count = len(self.find_obsolete_links(files, categories, category_roots))
|
||||
obsolete_count = len(self.find_obsolete_links(
|
||||
files, categories, category_roots, category_transforms,
|
||||
category_filename_templates))
|
||||
|
||||
# Remove obsolete links
|
||||
removed, removed_paths = self.remove_obsolete_links(
|
||||
files, categories, dry_run, category_roots
|
||||
files, categories, dry_run, category_roots, category_transforms,
|
||||
category_filename_templates
|
||||
)
|
||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||
|
||||
# Then create new links
|
||||
created, create_failed = self.create_structure_for_files(
|
||||
files, categories, dry_run, category_roots
|
||||
files, categories, dry_run, category_roots, category_transforms,
|
||||
category_filename_templates
|
||||
)
|
||||
|
||||
return created, create_failed, removed, remove_failed
|
||||
|
||||
Reference in New Issue
Block a user