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 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-08 16:19:49 -06:00
parent 9588af2601
commit 9a565e19ee
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 45 additions and 5 deletions

View file

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

View file

@ -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']) == []

View file

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