From 97a39709e85f592924c17aee66c0e83383e5077b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Tue, 7 Jul 2026 16:18:24 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20endless=20Filmot=C3=A9ka=20hardlink=20chu?= =?UTF-8?q?rn=20and=20show=20pool/output=20paths=20in=20the=20status=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .Curator.!gtag.bak-20260701-170242 | 50 ------------------------------ CHANGELOG.md | 20 ++++++++++++ pyproject.toml | 2 +- src/_version.py | 2 +- src/core/hardlink_manager.py | 37 ++++++++++++++++------ src/ui/qt_app.py | 13 +++++--- tests/test_hardlink_manager.py | 39 +++++++++++++++++++++++ 7 files changed, 97 insertions(+), 66 deletions(-) delete mode 100644 .Curator.!gtag.bak-20260701-170242 diff --git a/.Curator.!gtag.bak-20260701-170242 b/.Curator.!gtag.bak-20260701-170242 deleted file mode 100644 index 79ef87f..0000000 --- a/.Curator.!gtag.bak-20260701-170242 +++ /dev/null @@ -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}" - } - ] -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdb8d2..0c6fad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 3e6b83e..5ddcf1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curator" -version = "1.8.0" +version = "1.9.0" description = "" authors = [ {name = "jan.doubravsky@gmail.com"} diff --git a/src/_version.py b/src/_version.py index 04fc7eb..db8ff93 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.8.0" +__version__ = "1.9.0" diff --git a/src/core/hardlink_manager.py b/src/core/hardlink_manager.py index 08d8d9f..b1165f6 100644 --- a/src/core/hardlink_manager.py +++ b/src/core/hardlink_manager.py @@ -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 diff --git a/src/ui/qt_app.py b/src/ui/qt_app.py index 4721911..32144e8 100644 --- a/src/ui/qt_app.py +++ b/src/ui/qt_app.py @@ -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 diff --git a/tests/test_hardlink_manager.py b/tests/test_hardlink_manager.py index 56fac36..99e13d7 100644 --- a/tests/test_hardlink_manager.py +++ b/tests/test_hardlink_manager.py @@ -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 ):