Files
Curator/docs/modules/core.hardlink_manager.md

13 KiB

src/core/hardlink_manager.py

Hardlink Manager for Curator

Creates directory structure based on file tags and creates hardlinks to organize files without duplicating them on disk.

Example: A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:

output/
├── žánr/
│   ├── Komedie/
│   │   └── film.mkv  (hardlink)
│   └── Akční/
│       └── film.mkv  (hardlink)
└── rok/
    └── 1988/
        └── film.mkv  (hardlink)

Source: src/core/hardlink_manager.py

Classes

_SafeDict

class · line 30

class _SafeDict(dict):

dict for str.format_map that leaves unknown fields as an empty string.

HardlinkManager

class · line 37

class HardlinkManager:

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}.

HardlinkManager.__init__

method · line 49

__init__(output_dir: Path)

Initialize HardlinkManager.

Args: output_dir: Base directory where the tag-based structure will be created

HardlinkManager._resolve_roots

method · line 60

_resolve_roots(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.

HardlinkManager._clean_title

method · line 75

_clean_title(file_obj: File) -> str

The movie's clean title (no trailing (YYYY)), path-safe.

method · line 85

_default_link_name(file_obj: File) -> str

Filmotéka hardlink name: the clean title + extension (no year).

method · line 89

_year_link_name(file_obj: File) -> Optional[str]

Title (YYYY).ext used to disambiguate a name collision, or None.

HardlinkManager._render_template

method · line 96

_render_template(file_obj: File, template: str) -> str

Render a per-category filename template; fall back to the clean name.

method · line 105

_plan_links(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).

HardlinkManager._folder_value

method · line 172

_folder_value(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.

HardlinkManager._target_dir

method · line 183

_target_dir(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.

HardlinkManager._managed_top_dirs

method · line 197

_managed_top_dirs(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Optional[Set[str]]

Top-level output folders owned by the tag tree (None = all of them).

A category with a non-empty root owns that root folder. A category placed at the output root (empty root, e.g. genres) owns its own folders at the root — and, so a genre whose last movie dropped the tag still gets its now-stale folder cleaned, every root-level folder is treated as owned except the grouping roots and any reserved_subfolders (copy-as-is mirrors, "Tipy dne", …). The obsolete check still only removes links to current pool files, so reserved/foreign folders are doubly protected.

HardlinkManager.create_structure_for_files

method · line 236

create_structure_for_files(files: List[File], 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.

Args: 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. 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)

HardlinkManager.mirror_as_is

method · line 294

mirror_as_is(source_dir: Path, subfolder: str | None=None, dry_run: bool=False) -> Tuple[int, int]

Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.

Recreates the exact directory hierarchy of source_dir under output_dir/subfolder (or directly under output_dir when subfolder is None) and hardlinks every file. Curator metadata files (.!tag / .!ftag / .!gtag / .!index) are skipped.

Used for Seriály: the pool structure is the source of truth and is cloned verbatim instead of being rebuilt from tags.

Returns: Tuple of (successful_links, failed_links)

HardlinkManager.remove_mirror

method · line 345

remove_mirror(name: str) -> bool

Remove output_dir/name entirely (file or folder tree).

Used to drop a whole mirrored library (or one entry of the shared mixed-library-series bucket) when it falls out of the miniDLNA generation's scope. Mirrors are pure hardlinks, so deleting the folder loses nothing — it's fully regenerable from the pool. name may contain "/" to address one entry inside a bucket folder.

Returns whether anything was actually removed.

HardlinkManager._fill_special_folder

method · line 365

_fill_special_folder(chosen: List[File], subfolder: str, dry_run: bool) -> Tuple[int, int]

Empty subfolder and hardlink chosen files into it.

HardlinkManager.generate_recently_added

method · line 405

generate_recently_added(files: List[File], count: int=10, subfolder: str='- Nově přidané', dry_run: bool=False) -> Tuple[int, int]

(Re)fill subfolder with the count most recently added movies.

Ordering is by File.added_timestamp() (import time, ctime fallback), newest first. Returns (created_links, failed_links).

HardlinkManager.generate_random_tips

method · line 420

generate_random_tips(files: List[File], count: int=10, subfolder: str='Tipy dne', dry_run: bool=False) -> Tuple[int, int]

(Re)fill subfolder with count random files as hardlinks.

The folder is emptied first so each run picks a fresh random selection. files should be the pool movies only (copy-as-is mirrors excluded).

Returns: Tuple of (created_links, failed_links)

HardlinkManager._is_same_file

method · line 438

_is_same_file(path1: Path, path2: Path) -> bool

Check if two paths point to the same file (same inode).

HardlinkManager._get_unique_name

method · line 445

_get_unique_name(path: Path) -> Path

Get a unique filename by adding a numeric suffix.

method · line 459

remove_created_links() -> int

Remove all hardlinks created by the last operation.

Returns: Number of links removed

HardlinkManager._remove_empty_parents

method · line 481

_remove_empty_parents(path: Path) -> None

Remove empty parent directories up to output_dir.

HardlinkManager.get_preview

method · line 492

get_preview(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.

Args: files: List of File objects 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)

method · line 519

find_obsolete_links(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, reserved_subfolders: Optional[Set[str]]=None) -> List[Tuple[Path, Path]]

Find hardlinks in the output directory that no longer match file 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). category_transforms: Optional category → transform-name map for folders.

Returns: List of tuples (link_path, source_path) for obsolete links

method · line 608

remove_obsolete_links(files: List[File], 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, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, List[Path]]

Remove hardlinks that no longer match file tags.

Args: 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). category_transforms: Optional category → transform-name map for folders. category_filename_templates: Optional category → hardlink-name template. reserved_subfolders: Output top-level folders to never touch (mirrors, "Tipy dne", …).

Returns: Tuple of (removed_count, list_of_removed_paths)

HardlinkManager.sync_structure

method · line 655

sync_structure(files: List[File], 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, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, int, int, int]

Synchronize hardlink structure with current file tags.

This will:

  1. Remove hardlinks for removed tags
  2. Create new hardlinks for new tags

Args: 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). category_transforms: Optional category → transform-name map for folders. category_filename_templates: Optional category → hardlink-name template. reserved_subfolders: Output top-level folders to never touch (mirrors, "Tipy dne", …).

Returns: Tuple of (created, create_failed, removed, remove_failed)

Functions

function · line 707

create_hardlink_structure(files: List[File], output_dir: Path, categories: Optional[List[str]]=None) -> Tuple[int, int, List[Tuple[Path, str]]]

Convenience function to create hardlink structure.

Args: files: List of File objects to process output_dir: Base directory for output categories: Optional list of categories to include

Returns: Tuple of (successful_count, failed_count, errors_list)