Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout

This commit is contained in:
2026-06-15 17:31:52 +02:00
parent 86c689b9f1
commit b3a61f9e86
18 changed files with 1407 additions and 168 deletions
+126 -53
View File
@@ -19,12 +19,21 @@ Example:
"""
import os
from pathlib import Path
from typing import List, Tuple, Optional
from typing import List, Tuple, Optional, Dict, Set
from .file import File
class HardlinkManager:
"""Manager for creating hardlink-based directory structures from tagged files."""
"""Manager for creating hardlink-based directory structures from tagged files.
The output layout is driven by a *category → root folder* mapping
(``category_roots``). Each tag is placed at
``output/<root>/<tag_name>/<file>``; an empty root means the tag's own
folders sit directly at the output root (e.g. genre folders next to the
"Dle roku" / "Dle země původu" folders). The legacy ``categories`` list
(folder == category name) is still accepted and treated as the identity
mapping ``{cat: cat}``.
"""
def __init__(self, output_dir: Path):
"""
@@ -37,11 +46,61 @@ class HardlinkManager:
self.created_links: List[Path] = []
self.errors: List[Tuple[Path, str]] = []
def _resolve_roots(
self,
categories: Optional[List[str]],
category_roots: Optional[Dict[str, str]],
) -> Optional[Dict[str, str]]:
"""Normalize the two filter styles into a category → root-folder map.
``None`` means "all categories", folder == category name.
"""
if category_roots is not None:
return dict(category_roots)
if categories is not None:
return {cat: cat for cat in categories}
return None
def _target_dir(self, tag, roots: Optional[Dict[str, str]]) -> Optional[Path]:
"""Output directory for a tag, or None if its category is excluded."""
if roots is None:
folder = tag.category
elif tag.category in roots:
folder = roots[tag.category]
else:
return None
base = self.output_dir / folder if folder else self.output_dir
return base / tag.name
def _managed_top_dirs(
self, files: List[File], roots: Optional[Dict[str, str]]
) -> 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).
"""
if roots is None:
return None
tops: Set[str] = set()
for cat, folder in roots.items():
if folder:
tops.add(folder)
else:
for file_obj in files:
for tag in file_obj.tags:
if tag.category == cat:
tops.add(tag.name)
return tops
def create_structure_for_files(
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None,
) -> Tuple[int, int]:
"""
Create hardlink structure for given files based on their tags.
@@ -50,6 +109,8 @@ class HardlinkManager:
files: List of File objects to process
categories: Optional list of categories to include (None = all)
dry_run: If True, only simulate without creating actual links
category_roots: Optional category → root-folder map (see class doc);
overrides ``categories`` when given.
Returns:
Tuple of (successful_links, failed_links)
@@ -57,6 +118,7 @@ class HardlinkManager:
self.created_links = []
self.errors = []
roots = self._resolve_roots(categories, category_roots)
success_count = 0
fail_count = 0
@@ -65,12 +127,10 @@ class HardlinkManager:
continue
for tag in file_obj.tags:
# Skip if category filter is set and this category is not included
if categories is not None and tag.category not in categories:
# Resolve the target dir; None means this category is excluded
target_dir = self._target_dir(tag, roots)
if target_dir is None:
continue
# Create target directory path: output/category/tag_name/
target_dir = self.output_dir / tag.category / tag.name
target_file = target_dir / file_obj.filename
try:
@@ -204,17 +264,25 @@ class HardlinkManager:
except OSError:
pass
def get_preview(self, files: List[File], categories: Optional[List[str]] = None) -> List[Tuple[Path, Path]]:
def get_preview(
self,
files: List[File],
categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, str]] = None,
) -> List[Tuple[Path, Path]]:
"""
Get a preview of what links would be created.
Args:
files: List of File objects
categories: Optional list of categories to include
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
List of tuples (source_path, target_path)
"""
roots = self._resolve_roots(categories, category_roots)
preview = []
for file_obj in files:
@@ -222,10 +290,9 @@ class HardlinkManager:
continue
for tag in file_obj.tags:
if categories is not None and tag.category not in categories:
target_dir = self._target_dir(tag, roots)
if target_dir is None:
continue
target_dir = self.output_dir / tag.category / tag.name
target_file = target_dir / file_obj.filename
preview.append((file_obj.file_path, target_file))
@@ -235,26 +302,33 @@ class HardlinkManager:
def find_obsolete_links(
self,
files: List[File],
categories: Optional[List[str]] = None
categories: Optional[List[str]] = None,
category_roots: Optional[Dict[str, str]] = None,
) -> List[Tuple[Path, Path]]:
"""
Find hardlinks in the output directory that no longer match file tags.
Scans the output directory for hardlinks that point to source files,
but whose category/tag path no longer matches the file's current tags.
Scans the managed parts of the output directory for hardlinks that point
to source files but whose path no longer matches the file's current tags.
Only the tag-tree's own top-level folders are scanned, so copy-as-is
mirrors (e.g. Seriály) are left untouched.
Args:
files: List of File objects (source files)
categories: Optional list of categories to check (None = all)
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
List of tuples (link_path, source_path) for obsolete links
"""
obsolete = []
obsolete: List[Tuple[Path, Path]] = []
if not self.output_dir.exists():
return obsolete
roots = self._resolve_roots(categories, category_roots)
# Build a map of source file inodes to File objects
inode_to_file: dict[int, File] = {}
for file_obj in files:
@@ -272,44 +346,33 @@ class HardlinkManager:
expected_paths[inode] = set()
for tag in file_obj.tags:
if categories is not None and tag.category not in categories:
target_dir = self._target_dir(tag, roots)
if target_dir is None:
continue
target = self.output_dir / tag.category / tag.name / file_obj.filename
expected_paths[inode].add(target)
expected_paths[inode].add(target_dir / file_obj.filename)
except OSError:
continue
# Scan output directory for existing hardlinks
for category_dir in self.output_dir.iterdir():
if not category_dir.is_dir():
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
top_dirs = self._managed_top_dirs(files, roots)
for top in self.output_dir.iterdir():
if not top.is_dir():
continue
if top_dirs is not None and top.name not in top_dirs:
continue
# Filter by categories if specified
if categories is not None and category_dir.name not in categories:
continue
for tag_dir in category_dir.iterdir():
if not tag_dir.is_dir():
# Depth-agnostic: genres sit one level deep, "Dle roku"/"Dle země
# původu" two levels deep — walk all files under the managed folder.
for link_file in top.rglob("*"):
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
if link_inode in expected_paths:
if link_file not in expected_paths[link_inode]:
obsolete.append((link_file, inode_to_file[link_inode].file_path))
except OSError:
continue
for link_file in tag_dir.iterdir():
if not link_file.is_file():
continue
try:
link_inode = link_file.stat().st_ino
# Check if this inode belongs to one of our source files
if link_inode in inode_to_file:
source_file = inode_to_file[link_inode]
# Check if this link path is expected
if link_inode in expected_paths:
if link_file not in expected_paths[link_inode]:
# This link exists but tag was removed
obsolete.append((link_file, source_file.file_path))
except OSError:
continue
return obsolete
@@ -317,7 +380,8 @@ class HardlinkManager:
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None,
) -> Tuple[int, List[Path]]:
"""
Remove hardlinks that no longer match file tags.
@@ -326,11 +390,13 @@ class HardlinkManager:
files: List of File objects
categories: Optional list of categories to check
dry_run: If True, only return what would be removed
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
Returns:
Tuple of (removed_count, list_of_removed_paths)
"""
obsolete = self.find_obsolete_links(files, categories)
obsolete = self.find_obsolete_links(files, categories, category_roots)
removed_paths = []
if dry_run:
@@ -352,7 +418,8 @@ class HardlinkManager:
self,
files: List[File],
categories: Optional[List[str]] = None,
dry_run: bool = False
dry_run: bool = False,
category_roots: Optional[Dict[str, str]] = None,
) -> Tuple[int, int, int, int]:
"""
Synchronize hardlink structure with current file tags.
@@ -365,19 +432,25 @@ class HardlinkManager:
files: List of File objects
categories: Optional list of categories to sync
dry_run: If True, only simulate
category_roots: Optional category → root-folder map (overrides
``categories`` when given).
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))
obsolete_count = len(self.find_obsolete_links(files, categories, category_roots))
# Remove obsolete links
removed, removed_paths = self.remove_obsolete_links(files, categories, dry_run)
removed, removed_paths = self.remove_obsolete_links(
files, categories, dry_run, category_roots
)
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)
created, create_failed = self.create_structure_for_files(
files, categories, dry_run, category_roots
)
return created, create_failed, removed, remove_failed