From 0a07afef85c0732f95528675fdce8422029c6300 Mon Sep 17 00:00:00 2001 From: SpiderFish Date: Wed, 26 Aug 2026 10:06:03 +1000 Subject: [PATCH] Honor configurable cache size with LRU eviction --- README.md | 13 +++++++++++++ pretranscode.py | 37 +++++++++++++++++++++++++++++++------ tests/test_core.py | 20 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index aa28210..0410a76 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,19 @@ The fixed source blacklist is: Client error reports never modify this list. +## Cache resizing + +`CACHE_MAX_BYTES` is the worker's configurable LRU limit. After changing it and +restarting the container: + +- A smaller value evicts the oldest unlocked entries until usage fits. +- A larger value immediately makes the additional capacity available for + background population, up to the size of the mounted cache filesystem. + +The host filesystem is deliberately separate from application logic. Enlarge or +shrink its preallocated image during infrastructure maintenance, then set the +matching `CACHE_MAX_BYTES` value in Coolify. + ## Playback locking - An active movie locks that movie. diff --git a/pretranscode.py b/pretranscode.py index 9702bc9..7e4fb7d 100755 --- a/pretranscode.py +++ b/pretranscode.py @@ -31,6 +31,7 @@ JELLYFIN_LIBRARY_ROOT = os.environ.get("JELLYFIN_LIBRARY_ROOT", "/data/library") JELLYFIN_ORIGINAL_ROOT = os.environ.get("JELLYFIN_ORIGINAL_ROOT", "/data/original").rstrip("/") JELLYFIN_CACHE_ROOT = os.environ.get("JELLYFIN_CACHE_ROOT", "/data/cache").rstrip("/") JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") +CACHE_MAX_BYTES = env_int("CACHE_MAX_BYTES", 1_000_000_000_000) SERIES_COOLDOWN_SECONDS = env_int("SERIES_COOLDOWN_SECONDS", 3600) POLL_SECONDS = env_int("POLL_SECONDS", 5) RESCAN_SECONDS = env_int("RESCAN_SECONDS", 300) @@ -501,9 +502,33 @@ class Manager: manifest.unlink(missing_ok=True) return True + def cache_usage(self): + return sum( + path.stat().st_size + for path in (CACHE_ROOT / "files").iterdir() + if path.is_file() + ) + + def cache_available(self): + stat = os.statvfs(CACHE_ROOT) + filesystem_free = stat.f_bavail * stat.f_frsize + configured_free = max(0, CACHE_MAX_BYTES - self.cache_usage()) + return min(filesystem_free, configured_free) + + def enforce_cache_limit(self): + usage = self.cache_usage() + for _, relative, output, manifest in self.cache_entries(): + if usage <= CACHE_MAX_BYTES: + break + if self.is_locked(relative): + continue + size = output.stat().st_size + manifest.stat().st_size + log(f"Evicting {relative} after cache resize") + if self.remove_cache(relative): + usage -= size + def ensure_space(self, required, may_evict): - free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize - if free >= required: + if self.cache_available() >= required: return True if not may_evict: return False @@ -512,8 +537,7 @@ class Manager: continue log(f"Evicting {relative}") self.remove_cache(relative) - free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize - if free >= required: + if self.cache_available() >= required: return True return False @@ -625,6 +649,7 @@ class Manager: if not JELLYFIN_API_KEY: raise RuntimeError("JELLYFIN_API_KEY is required") self.read_sessions() + self.enforce_cache_limit() self.scan_inventory() self.sync_view() self.save() @@ -632,6 +657,7 @@ class Manager: if not self.read_sessions(): time.sleep(POLL_SECONDS) continue + self.enforce_cache_limit() now = time.time() if now - self.last_scan >= RESCAN_SECONDS: changed = self.scan_inventory() @@ -651,8 +677,7 @@ class Manager: for rank, _, relative in candidates: source = SOURCE_ROOT / relative estimate = min(max(int(source.stat().st_size * 2.2), 1_000_000_000), 100_000_000_000) - free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize - if rank == 0 or free >= estimate: + if rank == 0 or self.cache_available() >= estimate: selected = (rank == 0, relative) break if not selected: diff --git a/tests/test_core.py b/tests/test_core.py index 696bb6d..cc59ec2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -64,6 +64,26 @@ class CoreTests(unittest.TestCase): source.write_bytes(b"changed") self.assertFalse(manager.cache_valid(relative)) + def test_smaller_cache_limit_evicts_oldest_content(self): + m = self.module + manager = m.Manager() + relative = "movies/Resize/Resize.mkv" + source = self.root / "source" / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(b"original") + output, manifest = manager.cache_paths(relative) + output.write_bytes(b"cached rendition") + manifest.write_text(json.dumps({"relative": relative, "source_fingerprint": m.fingerprint(source)})) + manager.ensure_link(relative, m.cache_target(relative)) + old_limit = m.CACHE_MAX_BYTES + try: + m.CACHE_MAX_BYTES = 1 + manager.enforce_cache_limit() + finally: + m.CACHE_MAX_BYTES = old_limit + self.assertFalse(output.exists()) + self.assertEqual(os.readlink(self.root / "view" / relative), m.source_target(relative)) + if __name__ == "__main__": unittest.main()