From 0e9a0a3f79f6efb2c7fb8012d0c8f6beeee17bca Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 28 Feb 2026 16:45:46 -0700 Subject: [PATCH] Handling tags directory cleanup when the original files are changed. --- wrolpi/tags.py | 36 ++++++++++++- wrolpi/test/test_tags.py | 107 +++++++++++++++++++++++++++++++++++++++ wrolpi/version.txt | 2 +- 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/wrolpi/tags.py b/wrolpi/tags.py index 492db8f5..8be47486 100644 --- a/wrolpi/tags.py +++ b/wrolpi/tags.py @@ -559,9 +559,43 @@ def _sync_tags_directory_tag_files(tags_directory: pathlib.Path, session: Sessio .outerjoin(Tag, FileGroup).all() links = list() for tag, tag_file, file_group in tag_files: - for file, link in file_group.get_tag_directory_paths_map().items(): + paths_map = file_group.get_tag_directory_paths_map() + + # Check if ANY source file still exists in this FileGroup. + any_source_exists = any(file.is_file() for file in paths_map.keys()) + + for file, link in paths_map.items(): link = tags_directory / link + + if not file.is_file(): + if any_source_exists: + # Partial deletion - remove stale hardlink for this file. + if link.is_file(): + logger.debug(f'Removing stale hardlink (source deleted): {link}') + link.unlink() + # Don't add to links - nothing should exist here. + continue + else: + # ALL files deleted - preserve hardlinks as safety measure. + if link.is_file(): + links.append(link) + continue + + need_create = False if not link.is_file(): + need_create = True + else: + # Check if existing link points to the correct file (same inode). + # When a source file is replaced, the hardlink becomes stale. + try: + if link.stat().st_ino != file.stat().st_ino: + # Hardlink is stale - points to old version of file. + link.unlink() + need_create = True + except FileNotFoundError: + need_create = True + + if need_create: try: link.parent.mkdir(parents=True, exist_ok=True) link.hardlink_to(file) diff --git a/wrolpi/test/test_tags.py b/wrolpi/test/test_tags.py index 149271fd..58a779a8 100644 --- a/wrolpi/test/test_tags.py +++ b/wrolpi/test/test_tags.py @@ -512,6 +512,113 @@ async def test_tag_rename_with_channel(test_session, test_directory, video_facto tag.delete() +@pytest.mark.asyncio +async def test_tags_directory_file_replaced(test_session, test_directory, tag_factory, video_bytes, await_switches): + """Tags directory hardlinks should update when source file is replaced.""" + from wrolpi import tags as tags_module + + # Create a video file and its FileGroup. + video_file = test_directory / 'video.mp4' + video_file.write_bytes(video_bytes) + fg = FileGroup.from_paths(test_session, video_file) + test_session.add(fg) + test_session.commit() + original_inode = video_file.stat().st_ino + + # Tag the FileGroup. + tag = await tag_factory('favorite') + fg.add_tag(test_session, tag.id) + await await_switches() + + # Verify hardlink points to original inode. + tag_link = test_directory / 'tags' / tag.name / video_file.name + assert tag_link.is_file(), 'Tag link should exist' + assert tag_link.stat().st_ino == original_inode, 'Hardlink should point to original file' + + # Replace the source file (same name, different inode). + video_file.unlink() + video_file.write_bytes(b'new content') + new_inode = video_file.stat().st_ino + assert new_inode != original_inode, 'New file should have different inode' + + # Sync and verify hardlink points to new inode. + tags_module.sync_tags_directory() + assert tag_link.stat().st_ino == new_inode, 'Hardlink should point to new file after sync' + + +@pytest.mark.asyncio +async def test_tags_directory_partial_file_deleted(test_session, test_directory, tag_factory, video_bytes, + image_bytes_factory, await_switches): + """When one file in a FileGroup is deleted, its hardlink should be removed.""" + from wrolpi import tags as tags_module + + # Create a video with poster. + video_file = test_directory / 'video.mp4' + poster_file = test_directory / 'video.jpg' + video_file.write_bytes(video_bytes) + poster_file.write_bytes(image_bytes_factory()) + + fg = FileGroup.from_paths(test_session, video_file, poster_file) + test_session.add(fg) + test_session.commit() + + # Tag the FileGroup. + tag = await tag_factory('favorite') + fg.add_tag(test_session, tag.id) + await await_switches() + + # Verify both hardlinks exist. + video_link = test_directory / 'tags' / tag.name / 'video.mp4' + poster_link = test_directory / 'tags' / tag.name / 'video.jpg' + assert video_link.is_file() + assert poster_link.is_file() + + # Delete the poster file from source (partial deletion). + poster_file.unlink() + + # Sync and verify poster hardlink is removed, video link preserved. + tags_module.sync_tags_directory() + assert video_link.is_file(), 'Video link should still exist' + assert not poster_link.exists(), 'Poster link should be removed after source deleted' + + +@pytest.mark.asyncio +async def test_tags_directory_all_files_deleted(test_session, test_directory, tag_factory, video_bytes, + image_bytes_factory, await_switches): + """When ALL files in a FileGroup are deleted, hardlinks should be preserved.""" + from wrolpi import tags as tags_module + + # Create a video with poster. + video_file = test_directory / 'video.mp4' + poster_file = test_directory / 'video.jpg' + video_file.write_bytes(video_bytes) + poster_file.write_bytes(image_bytes_factory()) + + fg = FileGroup.from_paths(test_session, video_file, poster_file) + test_session.add(fg) + test_session.commit() + + # Tag the FileGroup. + tag = await tag_factory('favorite') + fg.add_tag(test_session, tag.id) + await await_switches() + + # Verify both hardlinks exist. + video_link = test_directory / 'tags' / tag.name / 'video.mp4' + poster_link = test_directory / 'tags' / tag.name / 'video.jpg' + assert video_link.is_file() + assert poster_link.is_file() + + # Delete ALL source files. + video_file.unlink() + poster_file.unlink() + + # Sync and verify ALL hardlinks are preserved (safety measure). + tags_module.sync_tags_directory() + assert video_link.is_file(), 'Video link should be preserved when all sources deleted' + assert poster_link.is_file(), 'Poster link should be preserved when all sources deleted' + + @pytest.mark.asyncio async def test_tags_directory_disabled(test_session, test_directory, tag_factory, video_factory, await_switches): """Test that Tag Directory is NOT synchronized when tags_directory setting is disabled.""" diff --git a/wrolpi/version.txt b/wrolpi/version.txt index 715fd561..6d837272 100644 --- a/wrolpi/version.txt +++ b/wrolpi/version.txt @@ -1 +1 @@ -0.17.5-beta +0.17.6-beta