Fix endless Filmotéka hardlink churn and show pool/output paths in the status bar
This commit is contained in:
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "curator"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "jan.doubravsky@gmail.com"}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "1.8.0"
|
||||
__version__ = "1.9.0"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user