Fix endless Filmotéka hardlink churn and show pool/output paths in the status bar

This commit is contained in:
2026-07-07 16:18:24 +02:00
parent 7ae0709f38
commit 97a39709e8
7 changed files with 97 additions and 66 deletions
-50
View File
@@ -1,50 +0,0 @@
{
"window_geometry": "1669x887",
"window_maximized": false,
"last_folder": null,
"sidebar_width": 250,
"recent_folders": [],
"pool_dir": "/mnt/RHD-SRV2/Filmotéka",
"filmoteka_dir": "/mnt/RHD-SRV2/Filmy",
"copyasis_folders": [
"Seriály",
"Dokumenty"
],
"tag_schema": [
{
"category": "Žánr",
"csfd_field": "genres",
"transform": null,
"filmoteka_root": "",
"filename_template": null
},
{
"category": "Rok",
"csfd_field": "year",
"transform": null,
"filmoteka_root": "Dle roku",
"filename_template": null
},
{
"category": "Země původu",
"csfd_field": "countries",
"transform": null,
"filmoteka_root": "Dle země původu",
"filename_template": null
},
{
"category": "Hodnocení",
"csfd_field": "rating",
"transform": "decade_band",
"filmoteka_root": "Dle hodnocení",
"filename_template": null
},
{
"category": "Kolekce",
"csfd_field": null,
"transform": null,
"filmoteka_root": "Dle kolekce",
"filename_template": "{Sort} - {title}{ext}"
}
]
}
+20
View File
@@ -21,6 +21,26 @@ Each version entry uses these sections (include only those that apply):
## Unreleased
## 1.9.0 — 2026-07-07
### Fixed
- **Endless Filmotéka churn (`vytvořeno: N, odebráno: N` every run).** When a movie
was re-imported/replaced (new inode), the old hardlink kept the correct name
but pointed at the dead inode. Obsolete detection matched links **by name
only**, so it spared that orphan and instead forced the freshly created,
correct link to a `_1` suffix — which, having an unexpected name, was swept on
the next run and recreated again, forever. `HardlinkManager.find_obsolete_links`
now matches **both the path and the inode**: an orphan squatting the expected
name is removed and the base name is restored, so generation is stable after
one cleanup run.
### Changed
- **Status bar** now shows the current **Pool** and **Filmotéka** paths as
permanent widgets (right-aligned), so they stay visible while the transient
area shows the movie count or the current selection — previously the paths
were part of the transient message and were wiped as soon as a selection
updated the status.
## 1.8.0 — 2026-07-03
### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "curator"
version = "1.8.0"
version = "1.9.0"
description = ""
authors = [
{name = "jan.doubravsky@gmail.com"}
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.8.0"
__version__ = "1.9.0"
+27 -10
View File
@@ -478,15 +478,27 @@ class HardlinkManager:
except OSError:
continue
# Every link the tag tree *should* contain, given current files+tags.
expected_all: set[Path] = set()
# Every link the tag tree *should* contain, mapped to the inode(s) it may
# point at. A path can be wanted by more than one movie (same rendered
# name in the same folder), so the value is a *set* of valid inodes.
# Matching by inode too — not just the path — is what catches an **orphan
# that squats the expected name**: a link whose name is right but which
# points to an old inode (movie re-imported/replaced) is still obsolete,
# while the correctly re-created link (forced to a ``_1`` suffix because
# the orphan holds the base name) is *not* wrongly swept.
expected: dict[Path, set[int]] = {}
for file_obj in files:
try:
file_inode = file_obj.file_path.stat().st_ino
except OSError:
continue
for tag in file_obj.tags:
target_dir = self._target_dir(tag, roots, category_transforms)
if target_dir is None:
continue
expected_all.add(target_dir / self._link_name(
file_obj, tag, category_filename_templates))
path = target_dir / self._link_name(
file_obj, tag, category_filename_templates)
expected.setdefault(path, set()).add(file_inode)
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors).
# Inside them, any file that isn't an expected link is obsolete — whether
@@ -503,13 +515,18 @@ class HardlinkManager:
for link_file in top.rglob("*"):
if not link_file.is_file():
continue
if link_file in expected_all:
continue
try:
source = inode_to_file[link_file.stat().st_ino].file_path
except (OSError, KeyError):
source = link_file # orphan (no current pool movie)
obsolete.append((link_file, source))
link_inode = link_file.stat().st_ino
except OSError:
continue
# Valid only when both the path *and* the inode match a current
# movie; a name-only match (orphan squatting the name) is stale.
if link_inode in expected.get(link_file, ()):
continue
source = inode_to_file.get(link_inode)
obsolete.append(
(link_file, source.file_path if source else link_file)
)
return obsolete
+9 -4
View File
@@ -579,6 +579,12 @@ class QtApp(QMainWindow):
def _build_statusbar(self) -> None:
self.status = self.statusBar()
# Pool / Filmotéka paths as permanent widgets on the right so they stay
# visible even while the transient area shows counts or a selection.
self.pool_label = QLabel()
self.output_label = QLabel()
self.status.addPermanentWidget(self.pool_label)
self.status.addPermanentWidget(self.output_label)
self._update_path_status()
# ------------------------------------------------------------------
@@ -717,10 +723,9 @@ class QtApp(QMainWindow):
def _update_path_status(self) -> None:
pool = self.filehandler.pool_dir
out = self.filehandler.filmoteka_dir
self.status.showMessage(
f"Pool: {pool or ''} | Filmotéka: {out or ''} | "
f"{len(self.filehandler.filelist)} filmů"
)
self.pool_label.setText(f"Pool: {pool or ''}")
self.output_label.setText(f"Output: {out or ''}")
self.status.showMessage(f"{len(self.filehandler.filelist)} filmů")
# ------------------------------------------------------------------
# Actions
+39
View File
@@ -169,6 +169,45 @@ class TestHardlinkManager:
assert (temp_output_dir / "- Dle roku" / "1968" / "file1.txt").exists()
assert not (temp_output_dir / "Dle roku").exists()
def test_sync_replaces_orphan_squatting_the_expected_name(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""An orphan link with the *expected name* but a stale inode is replaced.
Regression: when a movie is re-imported (new inode), the old hardlink
keeps the correct name but points at the dead inode. Name-only obsolete
detection spared it and forced the real link to a ``_1`` suffix, which
was then swept every run — an endless created==removed churn. Obsolete
detection now matches the inode too, so the orphan is removed and the
base name is restored, stably.
"""
f = File(temp_source_dir / "file1.txt", tag_manager)
f.tags.clear()
f.add_tag(Tag("žánr", "Drama"))
roots = {"žánr": ""}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure([f], category_roots=roots)
link = temp_output_dir / "Drama" / "file1.txt"
assert link.stat().st_ino == f.file_path.stat().st_ino
# Simulate a re-import: the expected name now points at a *different*
# (orphan) inode, exactly as a replaced movie leaves behind.
orphan_src = temp_source_dir / "orphan.txt"
orphan_src.write_text("old content")
link.unlink()
os.link(orphan_src, link)
assert link.stat().st_ino != f.file_path.stat().st_ino
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
# The orphan was swept and the correct link recreated under the base name.
assert removed >= 1 and created >= 1
assert link.stat().st_ino == f.file_path.stat().st_ino
assert not (temp_output_dir / "Drama" / "file1_1.txt").exists()
# And it is now stable: a second run touches nothing (no churn).
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
assert (created2, removed2) == (0, 0)
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
self, temp_source_dir, temp_output_dir, tag_manager
):