chore: adjustments for local development without containers (#21)

* chore: adjustments for local development without containers

* update Contributing.md

* remove dev section from readme and link to contributing doc

* move settings import to runtime
This commit is contained in:
Arno
2026-04-05 17:18:56 +02:00
committed by GitHub
parent e84f2d6127
commit 70809b3d7d
26 changed files with 564 additions and 367 deletions
+11
View File
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)")
cache_cleanup_interval: int = Field(default=300)
root_app_dir: Path = Field(default=Path("/app"), description="Root application directory")
cache_dir: Path = Field(default=Path("/app/cache"), description="Root directory for all cache files")
library_db_path: Path = Field(default=Path("/app/cache/library.db"), description="SQLite library database path")
cover_cache_max_size_mb: int = Field(default=500, description="Maximum cover cache size in MB")
@@ -82,6 +83,16 @@ class Settings(BaseSettings):
@model_validator(mode='after')
def validate_config(self) -> Self:
# Dynamically resolve paths relative to root_app_dir
if self.cache_dir == Path("/app/cache"):
self.cache_dir = self.root_app_dir / "cache"
if self.library_db_path == Path("/app/cache/library.db"):
self.library_db_path = self.cache_dir / "library.db"
if self.queue_db_path == Path("/app/cache/queue.db"):
self.queue_db_path = self.cache_dir / "queue.db"
if self.config_file_path == Path("/app/config/config.json"):
self.config_file_path = self.root_app_dir / "config" / "config.json"
errors = []
warnings = []
+2
View File
@@ -0,0 +1,2 @@
ROOT_APP_DIR=./app-dev
DEBUG=true
+4 -1
View File
@@ -7,7 +7,10 @@ logger = logging.getLogger(__name__)
class QueueStore:
def __init__(self, db_path: Path = Path("/app/cache/queue.db")) -> None:
def __init__(self, db_path: Path | None = None) -> None:
if db_path is None:
from core.config import get_settings
db_path = get_settings().queue_db_path
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._write_lock = threading.Lock()
+18
View File
@@ -5,6 +5,7 @@ from fastapi import FastAPI, APIRouter, HTTPException
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware
from core.dependencies import (
get_request_queue,
get_cache,
@@ -279,6 +280,23 @@ app.add_middleware(
)
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6)
app_settings = get_settings()
if app_settings.debug:
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173",
"http://localhost:3000",
"http://127.0.0.1:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health_check():
+6 -2
View File
@@ -44,11 +44,15 @@ def _record_degradation(msg: str) -> None:
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
COVER_ART_ARCHIVE_BASE = "https://coverartarchive.org"
DEFAULT_CACHE_DIR = Path("/app/cache/covers")
from core.config import get_settings
COVER_NEGATIVE_TTL_SECONDS = 4 * 3600
COVER_MEMORY_MAX_ENTRIES = 128
COVER_MEMORY_MAX_BYTES = 16 * 1024 * 1024
def _default_cache_dir() -> Path:
from core.config import get_settings
return get_settings().cache_dir / "covers"
_coverart_circuit_breaker = CircuitBreaker(
failure_threshold=5,
success_threshold=2,
@@ -175,7 +179,7 @@ class CoverArtRepository:
lidarr_repo: Optional['LidarrRepository'] = None,
jellyfin_repo: Optional['JellyfinRepository'] = None,
audiodb_service: Optional['AudioDBImageService'] = None,
cache_dir: Path = DEFAULT_CACHE_DIR,
cache_dir: Path = _default_cache_dir(),
cover_cache_max_size_mb: Optional[int] = None,
cover_memory_cache_max_entries: int = COVER_MEMORY_MAX_ENTRIES,
cover_memory_cache_max_bytes: int = COVER_MEMORY_MAX_BYTES,
+4 -2
View File
@@ -9,7 +9,6 @@ from uuid import uuid4
logger = logging.getLogger(__name__)
CACHE_DB_PATH = Path("/app/cache/library.db")
_UNSET = object()
@@ -105,9 +104,12 @@ class PlaylistTrackRecord:
self.duration = duration
self.created_at = created_at
def get_cache_dir() -> Path:
from core.config import get_settings
return get_settings().library_db_path
class PlaylistRepository:
def __init__(self, db_path: Path = CACHE_DB_PATH):
def __init__(self, db_path: Path = get_cache_dir()):
self.db_path = db_path
self._local = threading.local()
self._write_lock = threading.Lock()
+9 -5
View File
@@ -13,8 +13,10 @@ YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search"
DEFAULT_DAILY_QUOTA_LIMIT = 80
SEARCH_COST = 100
PREVIEW_CACHE_MAX = 100
QUOTA_FILE = Path("/app/cache/youtube_quota.json")
def get_quota_file_path() -> Path:
from core.config import get_settings
return get_settings().cache_dir / "youtube_quota.json"
class YouTubeQuotaState(msgspec.Struct):
date: str = ""
@@ -56,9 +58,10 @@ class YouTubeRepository:
self._load_quota()
def _load_quota(self) -> None:
quota_file = get_quota_file_path()
try:
if QUOTA_FILE.exists():
data = msgspec.json.decode(QUOTA_FILE.read_bytes(), type=YouTubeQuotaState)
if quota_file.exists():
data = msgspec.json.decode(quota_file.read_bytes(), type=YouTubeQuotaState)
saved_date = data.date
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if saved_date == today:
@@ -72,9 +75,10 @@ class YouTubeRepository:
logger.warning(f"Failed to load YouTube quota state: {e}")
def _save_quota(self) -> None:
quota_file = get_quota_file_path()
try:
QUOTA_FILE.parent.mkdir(parents=True, exist_ok=True)
QUOTA_FILE.write_bytes(msgspec.json.encode(YouTubeQuotaState(date=self._quota_date, count=self._daily_count)))
quota_file.parent.mkdir(parents=True, exist_ok=True)
quota_file.write_bytes(msgspec.json.encode(YouTubeQuotaState(date=self._quota_date, count=self._daily_count)))
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to save YouTube quota state: {e}")
+17 -12
View File
@@ -13,8 +13,9 @@ from api.v1.schemas.cache import CacheStats, CacheClearResponse
logger = logging.getLogger(__name__)
CACHE_DIR = Path("/app/cache/covers")
def get_covers_cache_dir() -> Path:
from core.config import get_settings
return get_settings().cache_dir / "covers"
class CacheService:
@@ -36,6 +37,7 @@ class CacheService:
return 0
async def get_stats(self) -> CacheStats:
covers_cache_dir = get_covers_cache_dir()
async with self._stats_lock:
now = time.time()
if self._cached_stats and (now - self._stats_cache_time) < self._stats_cache_ttl:
@@ -53,13 +55,13 @@ class CacheService:
disk_count = 0
disk_bytes = 0
if CACHE_DIR.exists():
if covers_cache_dir.exists():
du_available = shutil.which('du') is not None
if du_available:
try:
result = await asyncio.to_thread(
subprocess.run,
['du', '-sb', str(CACHE_DIR)],
['du', '-sb', str(covers_cache_dir)],
capture_output=True,
text=True,
timeout=5.0,
@@ -68,7 +70,7 @@ class CacheService:
disk_bytes = int(result.stdout.split()[0])
result = await asyncio.to_thread(
subprocess.run,
['find', str(CACHE_DIR), '-type', 'f'],
['find', str(covers_cache_dir), '-type', 'f'],
capture_output=True,
text=True,
timeout=5.0,
@@ -87,7 +89,7 @@ class CacheService:
def _python_scan() -> tuple[int, int]:
count = 0
total = 0
for file_path in CACHE_DIR.rglob("*"):
for file_path in covers_cache_dir.rglob("*"):
if file_path.is_file():
count += 1
total += file_path.stat().st_size
@@ -154,14 +156,15 @@ class CacheService:
)
async def clear_disk_cache(self) -> CacheClearResponse:
covers_cache_dir = get_covers_cache_dir()
try:
metadata_stats = self._disk_cache.get_stats()
metadata_count = metadata_stats['total_count']
await self._disk_cache.clear_all()
files_cleared = 0
if CACHE_DIR.exists():
for file_path in CACHE_DIR.rglob("*"):
if covers_cache_dir.exists():
for file_path in covers_cache_dir.rglob("*"):
if file_path.is_file():
file_path.unlink()
files_cleared += 1
@@ -187,6 +190,7 @@ class CacheService:
)
async def clear_all_cache(self) -> CacheClearResponse:
covers_cache_dir = get_covers_cache_dir()
try:
memory_entries = self._cache.size()
await self._cache.clear()
@@ -196,8 +200,8 @@ class CacheService:
await self._disk_cache.clear_all()
disk_files = 0
if CACHE_DIR.exists():
for file_path in CACHE_DIR.rglob("*"):
if covers_cache_dir.exists():
for file_path in covers_cache_dir.rglob("*"):
if file_path.is_file():
file_path.unlink()
disk_files += 1
@@ -223,10 +227,11 @@ class CacheService:
)
async def clear_covers_cache(self) -> CacheClearResponse:
covers_cache_dir = get_covers_cache_dir()
try:
files_cleared = 0
if CACHE_DIR.exists():
for file_path in CACHE_DIR.rglob("*"):
if covers_cache_dir.exists():
for file_path in covers_cache_dir.rglob("*"):
if file_path.is_file():
file_path.unlink()
files_cleared += 1