Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions tests/test_maintenance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand Down
31 changes: 25 additions & 6 deletions web/services/maintenance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 = []

Expand Down
Loading