diff --git a/CHANGELOG.md b/CHANGELOG.md index dd28bea..c7c2035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi existing job for repeated submissions; completed job mappings are evicted with the job cache. - **Image Concurrency Bound.** Consistent image generation is limited to two active GPU jobs; additional requests remain observable as queued jobs and fail clearly if no slot becomes available within 15 minutes. +- **Fish.audio Cache Eviction.** Offline search responses are now evicted by configurable file-count and byte-size + limits, preventing an unbounded cache from consuming the configuration volume. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index 2a1b2ae..20547bf 100644 --- a/TODO.md +++ b/TODO.md @@ -55,8 +55,8 @@ the first block before investing in architecture or polish. repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs. - [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice tables, update only affected rows, and disable preloading on low-memory devices. -- [ ] **M — Add explicit cache invalidation and eviction.** Cache `/api/voices` safely - and add LRU cleanup for audio/image entries and object URLs. +- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses now have bounded eviction; + add equivalent LRU cleanup for audio/image entries and object URLs. - [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling, a 4 GB VM/container, and measure browser, Docker and temporary-disk usage. - [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription, diff --git a/routes/sources.py b/routes/sources.py index df4084a..e3e6762 100644 --- a/routes/sources.py +++ b/routes/sources.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import glob import json +import os import re import uuid from datetime import datetime, timezone @@ -29,6 +30,26 @@ from core.voice_index import upsert_voice_in_index router = APIRouter() +_FISH_CACHE_MAX_FILES = int(os.environ.get("FISHAUDIO_CACHE_MAX_FILES", "200")) +_FISH_CACHE_MAX_BYTES = int(os.environ.get("FISHAUDIO_CACHE_MAX_MB", "256")) * 1024 * 1024 + + +def _evict_fish_cache(cache_dir: Path) -> None: + """Keep the offline search cache bounded by count and total bytes.""" + try: + files = [p for p in cache_dir.glob("*.json") if p.is_file()] + total = sum(p.stat().st_size for p in files) + files.sort(key=lambda p: p.stat().st_mtime) + while files and (len(files) > _FISH_CACHE_MAX_FILES or total > _FISH_CACHE_MAX_BYTES): + old = files.pop(0) + size = old.stat().st_size + old.unlink() + total -= size + except OSError: + # Cache maintenance must never turn a successful provider response + # into an API failure. + return + async def _prepare_imported_voice(audio: Path, settings: dict) -> None: """Make a downloaded reference usable for Qwen clone playback. @@ -798,6 +819,7 @@ async def fishaudio_voices(request: Request): try: cache_dir.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps(result), encoding="utf-8") + _evict_fish_cache(cache_dir) except Exception: pass return result diff --git a/tests/test_security.py b/tests/test_security.py index 14537ad..41012a3 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -78,6 +78,23 @@ class SecurityValidationTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(_load_settings().get("llm_api_key"), "keep-this-secret") + def test_fish_cache_eviction_is_bounded(self): + from pathlib import Path + from tempfile import TemporaryDirectory + import routes.sources as sources + with TemporaryDirectory() as directory: + cache = Path(directory) + old_count, old_bytes = sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES + try: + sources._FISH_CACHE_MAX_FILES = 2 + sources._FISH_CACHE_MAX_BYTES = 1024 + for index in range(4): + (cache / f"{index}.json").write_text("{}") + sources._evict_fish_cache(cache) + self.assertLessEqual(len(list(cache.glob("*.json"))), 2) + finally: + sources._FISH_CACHE_MAX_FILES, sources._FISH_CACHE_MAX_BYTES = old_count, old_bytes + if __name__ == "__main__": unittest.main()