From c38ba6a69562a846f5f811f15064ec76757b5c75 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:52:59 -0500 Subject: [PATCH] Maintenance: reload settings on file change so audit reflects current cache paths The MaintenanceService singleton memoized settings and the derived cache/array paths for the life of the web process. When path mappings were edited after startup, the audit kept scanning the previous cache path, reported '0 on cache', and flagged every tracked exclude/timestamp entry as stale until a container restart. _load_settings() now reloads when the file's mtime changes and clears the derived path cache, so audits reflect the current configuration without a restart. Refs StudioNirin/PlexCache-D#176 --- tests/test_maintenance_service.py | 88 +++++++++++++++++++++++++++++ web/services/maintenance_service.py | 31 ++++++++-- 2 files changed, 113 insertions(+), 6 deletions(-) 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 = []