From 9a565e19eec7a46551f3d81bc0351aaa92ea757b Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 8 Oct 2026 16:19:49 -0600 Subject: [PATCH] Ignored directories: match whole directories, and check where a link points An ignored directory was matched as a string prefix, so ignoring `private` also ignored `private-notes`. A file is now ignored only when it is the ignored directory or inside it. Viewing a file, or saving its progress, checked only the path it was given, so a link outside an ignored directory to a file inside it was tracked. is_ignored_file checks the link and its target. Refresh does not follow links, so the bulk paths keep the cheaper lexical check. Co-Authored-By: Claude Opus 5.5 --- wrolpi/files/lib.py | 17 ++++++++++++----- wrolpi/files/test/test_lib.py | 15 +++++++++++++++ wrolpi/files/test/test_progress.py | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index ae0de494..3eec4d80 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -146,9 +146,7 @@ async def set_file_viewed(session: Session, file: pathlib.Path): Files in ignored directories are not tracked. """ - # Check if file is in an ignored directory - remaining = remove_files_in_ignored_directories([file]) - if not remaining: + if is_ignored_file(file): return try: @@ -170,7 +168,7 @@ def _trackable_path(file: pathlib.Path) -> pathlib.Path | None: file = pathlib.Path(os.path.normpath(file)) if not get_paths_in_media_directory([file]): raise InvalidFile('File must be in the media directory') - if not remove_files_in_ignored_directories([file]): + if is_ignored_file(file): return None return file @@ -880,12 +878,21 @@ def _upsert_files(files: List[pathlib.Path], progress_callback(processed_count, total_files) +def is_ignored_file(file: pathlib.Path) -> bool: + """Is `file`, or the file it links to, in an ignored directory? + + Refresh does not follow links, but a link can be viewed directly.""" + return len(remove_files_in_ignored_directories([file, file.resolve()])) < 2 + + def remove_files_in_ignored_directories(files: List[pathlib.Path]) -> List[pathlib.Path]: """Return a new list which does not contain any file paths that are in ignored directories.""" from wrolpi.db import get_db_file ignored_directories = get_normalized_ignored_directories() - files = [i for i in files if not any(str(i).startswith(j) for j in ignored_directories)] + # `private` must not hide `private-notes`. + files = [i for i in files + if not any(str(i) == j or str(i).startswith(f'{j}{os.sep}') for j in ignored_directories)] # Never index the database (or its WAL sidecars), even if the user un-ignores `config`. db_file = str(get_db_file()) files = [i for i in files if not str(i).startswith(db_file)] diff --git a/wrolpi/files/test/test_lib.py b/wrolpi/files/test/test_lib.py index 903261ad..f74f9748 100644 --- a/wrolpi/files/test/test_lib.py +++ b/wrolpi/files/test/test_lib.py @@ -1896,3 +1896,18 @@ def test_bulk_update_file_groups_reorganize_updates_stem(test_session, test_dire fg = test_session.query(FileGroup).filter_by(id=fg_id).one() assert fg.stem == '2024-01-01 New Title' assert fg.primary_path == new_path + + +@pytest.mark.asyncio +async def test_remove_files_in_ignored_directories(async_client, test_directory, test_wrolpi_config, await_switches): + """An ignored directory hides its own files only, not those of a sibling which shares its name as a prefix.""" + get_wrolpi_config().ignored_directories = [str(test_directory / 'private')] + await await_switches() + + private = test_directory / 'private/movie.mp4' + nested = test_directory / 'private/deeper/movie.mp4' + sibling = test_directory / 'private-notes/notes.txt' + other = test_directory / 'videos/movie.mp4' + assert lib.remove_files_in_ignored_directories([private, nested, sibling, other]) == [sibling, other] + # The ignored directory itself is ignored. + assert lib.remove_files_in_ignored_directories([test_directory / 'private']) == [] diff --git a/wrolpi/files/test/test_progress.py b/wrolpi/files/test/test_progress.py index 06c3c5ba..9e3c5c6a 100644 --- a/wrolpi/files/test/test_progress.py +++ b/wrolpi/files/test/test_progress.py @@ -415,3 +415,21 @@ async def test_recently_viewed_import_outside_media_directory(test_session, asyn assert [i['path'] for i in read_recently_viewed(test_directory)] == ['videos/movie.mp4'] finally: outside.unlink() + + +@pytest.mark.asyncio +async def test_progress_symlink_into_ignored_directory(test_session, async_client, test_directory, + make_files_structure, test_wrolpi_config, await_switches, + await_background_tasks): + """A link outside an ignored directory, to a file in it, is not tracked (viewed or progress).""" + from wrolpi.common import get_wrolpi_config + movie, _ = make_files_structure(['private/movie.mp4', 'videos/other.mp4']) + (test_directory / 'videos/link.mp4').symlink_to(movie) + get_wrolpi_config().ignored_directories = [str(test_directory / 'private')] + await await_switches() + + body = dict(file='videos/link.mp4', progress=0.4, position=dict(kind='time', seconds=480)) + await async_client.post('/api/files/progress', content=json.dumps(body)) + await async_client.post('/api/files/file', content=json.dumps(dict(file='videos/link.mp4'))) + await await_background_tasks() + assert test_session.query(FileGroup).count() == 0