Migrate from Poetry and pyenv to uv
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
# `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`](../../src/core/hardlink_manager.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `_SafeDict`
|
||||
|
||||
*class · line 30*
|
||||
|
||||
```python
|
||||
class _SafeDict(dict):
|
||||
```
|
||||
|
||||
dict for str.format_map that leaves unknown fields as an empty string.
|
||||
|
||||
### `HardlinkManager`
|
||||
|
||||
*class · line 37*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
__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*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
_clean_title(file_obj: File) -> str
|
||||
```
|
||||
|
||||
The movie's clean title (no trailing `(YYYY)`), path-safe.
|
||||
|
||||
#### `HardlinkManager._default_link_name`
|
||||
|
||||
*method · line 85*
|
||||
|
||||
```python
|
||||
_default_link_name(file_obj: File) -> str
|
||||
```
|
||||
|
||||
Filmotéka hardlink name: the clean title + extension (no year).
|
||||
|
||||
#### `HardlinkManager._year_link_name`
|
||||
|
||||
*method · line 89*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
_render_template(file_obj: File, template: str) -> str
|
||||
```
|
||||
|
||||
Render a per-category filename template; fall back to the clean name.
|
||||
|
||||
#### `HardlinkManager._plan_links`
|
||||
|
||||
*method · line 105*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
_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*
|
||||
|
||||
```python
|
||||
_get_unique_name(path: Path) -> Path
|
||||
```
|
||||
|
||||
Get a unique filename by adding a numeric suffix.
|
||||
|
||||
#### `HardlinkManager.remove_created_links`
|
||||
|
||||
*method · line 459*
|
||||
|
||||
```python
|
||||
remove_created_links() -> int
|
||||
```
|
||||
|
||||
Remove all hardlinks created by the last operation.
|
||||
|
||||
Returns:
|
||||
Number of links removed
|
||||
|
||||
#### `HardlinkManager._remove_empty_parents`
|
||||
|
||||
*method · line 481*
|
||||
|
||||
```python
|
||||
_remove_empty_parents(path: Path) -> None
|
||||
```
|
||||
|
||||
Remove empty parent directories up to output_dir.
|
||||
|
||||
#### `HardlinkManager.get_preview`
|
||||
|
||||
*method · line 492*
|
||||
|
||||
```python
|
||||
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)
|
||||
|
||||
#### `HardlinkManager.find_obsolete_links`
|
||||
|
||||
*method · line 519*
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
#### `HardlinkManager.remove_obsolete_links`
|
||||
|
||||
*method · line 608*
|
||||
|
||||
```python
|
||||
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*
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
### `create_hardlink_structure`
|
||||
|
||||
*function · line 707*
|
||||
|
||||
```python
|
||||
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)
|
||||
Reference in New Issue
Block a user