Handling tags directory cleanup when the original files are changed.

This commit is contained in:
Roland 2026-02-28 16:45:46 -07:00
parent 1deaeb3144
commit 0e9a0a3f79
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 143 additions and 2 deletions

View file

@ -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)

View file

@ -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."""

View file

@ -1 +1 @@
0.17.5-beta
0.17.6-beta