Migrate from Poetry and pyenv to uv

This commit is contained in:
2026-10-12 15:48:26 +02:00
parent c399a6aed3
commit c69f5f9166
93 changed files with 13068 additions and 2401 deletions
+440
View File
@@ -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)