Auto-fill ČSFD links on import, rename in pool, multi-country tags, Filmotéka layout
This commit is contained in:
+126
-53
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user