diff --git a/tests/test_maintenance_service.py b/tests/test_maintenance_service.py index 9dd42dde..d22f4979 100644 --- a/tests/test_maintenance_service.py +++ b/tests/test_maintenance_service.py @@ -181,6 +181,94 @@ def test_strips_trailing_slashes(self, tmp_path): assert not array_dirs[0].endswith("/") +# ============================================================================ +# Settings reload tests (StudioNirin/PlexCache-D#176) +# ============================================================================ + +class TestSettingsReloadOnChange: + """The singleton must not serve a stale settings/path snapshot. + + A stale snapshot caused the audit to scan an old/empty cache path and report + "0 on cache", flagging every tracked file as stale until a restart. + """ + + @staticmethod + def _bump_mtime(path): + """Force a newer mtime (avoids same-second resolution flakiness).""" + future = path.stat().st_mtime + 10 + os.utime(path, (future, future)) + + def test_get_paths_refreshes_when_settings_change(self, tmp_path): + svc = _make_service(tmp_path) + cache_dirs, _ = svc._get_paths() + assert cache_dirs == ["/mnt/cache/media/Movies", "/mnt/cache/media/TV"] + + # Simulate the user editing path mappings after the process started. + new_settings = { + "path_mappings": [ + { + "name": "Movies", + "cache_path": "/mnt/cache/newmedia/Movies", + "real_path": "/mnt/user/newmedia/Movies", + "cacheable": True, + "enabled": True, + }, + ] + } + svc.settings_file.write_text(json.dumps(new_settings), encoding="utf-8") + self._bump_mtime(svc.settings_file) + + cache_dirs2, array_dirs2 = svc._get_paths() + assert cache_dirs2 == ["/mnt/cache/newmedia/Movies"] + assert array_dirs2 == ["/mnt/user0/newmedia/Movies"] + + def test_get_paths_stays_cached_when_unchanged(self, tmp_path): + svc = _make_service(tmp_path) + cache1, array1 = svc._get_paths() + cache2, array2 = svc._get_paths() + # No file change → same cached objects (no needless recompute). + assert cache1 is cache2 + assert array1 is array2 + + def test_cache_scan_recovers_after_mapping_fix(self, tmp_path): + """Reproduces #176: a corrected cache_path is picked up without restart.""" + # Start with a mapping pointing at an empty cache dir → 0 files found. + empty_cache = tmp_path / "empty_cache" + empty_cache.mkdir() + settings_v1 = { + "path_mappings": [{ + "name": "Movies", + "cache_path": str(empty_cache), + "real_path": "/mnt/user/media/Movies", + "cacheable": True, + "enabled": True, + }] + } + svc = _make_service(tmp_path, settings_v1) + assert svc.get_cache_files() == set() + + # User fixes the mapping to the real cache dir that holds a file. + real_cache = tmp_path / "real_cache" + real_cache.mkdir() + (real_cache / "movie.mkv").write_text("x", encoding="utf-8") + settings_v2 = { + "path_mappings": [{ + "name": "Movies", + "cache_path": str(real_cache), + "real_path": "/mnt/user/media/Movies", + "cacheable": True, + "enabled": True, + }] + } + svc.settings_file.write_text(json.dumps(settings_v2), encoding="utf-8") + self._bump_mtime(svc.settings_file) + + # Without the fix this still returns set() (stale path). + files = svc.get_cache_files() + assert len(files) == 1 + assert os.path.basename(next(iter(files))) == "movie.mkv" + + # ============================================================================ # _cache_to_array_path() tests # ============================================================================ diff --git a/web/services/maintenance_service.py b/web/services/maintenance_service.py index 294d2ac7..bc1bfab3 100644 --- a/web/services/maintenance_service.py +++ b/web/services/maintenance_service.py @@ -200,21 +200,37 @@ def __init__(self): self._cache_dirs: List[str] = [] self._array_dirs: List[str] = [] self._settings: Dict = {} + self._settings_mtime: Optional[float] = None def _load_settings(self) -> Dict: - """Load settings from plexcache_settings.json""" - if self._settings: + """Load settings from plexcache_settings.json. + + Reloads automatically when the file's modification time changes so this + long-lived singleton never serves a stale snapshot. Previously the first + load was memoized for the life of the web process; if settings were + edited afterwards (e.g. path mappings gaining a cache_path) the audit + kept scanning the old/empty cache path and flagged every tracked file as + stale until a container restart. See StudioNirin/PlexCache-D#176. + """ + try: + mtime = self.settings_file.stat().st_mtime + except OSError: + # File missing or unreadable — keep serving the last good snapshot. return self._settings - if not self.settings_file.exists(): - return {} + if self._settings and mtime == self._settings_mtime: + return self._settings try: with open(self.settings_file, 'r', encoding='utf-8') as f: self._settings = json.load(f) + self._settings_mtime = mtime + # Settings changed on disk — paths derived in _get_paths() are stale. + self._cache_dirs = [] + self._array_dirs = [] return self._settings except (json.JSONDecodeError, IOError): - return {} + return self._settings def _translate_host_to_container_path(self, path: str) -> str: """Translate host cache path to container path.""" @@ -257,10 +273,13 @@ def _should_skip_directory(self, dirname: str) -> bool: def _get_paths(self) -> tuple: """Get cache and array directory paths from settings""" + # Load settings first: _load_settings() clears the cached dirs below when + # the file changes, so this short-circuit can't return stale paths for + # the life of the singleton (StudioNirin/PlexCache-D#176). + settings = self._load_settings() if self._cache_dirs and self._array_dirs: return self._cache_dirs, self._array_dirs - settings = self._load_settings() cache_dirs = [] array_dirs = []