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:
@@ -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 = []
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ROOT_APP_DIR=./app-dev
|
||||
DEBUG=true
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user