Project started 🍾
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""mcma-backend — self-hosted, offline-first music service.
|
||||
|
||||
Hexagonal (ports & adapters) architecture:
|
||||
|
||||
* ``app.domain`` — pure business core: entities, value objects, errors,
|
||||
and *ports* (Protocols). No framework imports.
|
||||
* ``app.application`` — use cases / services. Orchestrate domain via ports.
|
||||
* ``app.infrastructure`` — driven adapters: ORM models, repositories, db, redis,
|
||||
source backends, ML/HTTP clients.
|
||||
* ``app.api`` — driving adapter: FastAPI routers & schemas.
|
||||
* ``app.core`` — cross-cutting concerns: config, logging, security.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""Driving adapter — FastAPI routers, schemas, dependency wiring."""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Shared FastAPI dependencies — the composition root for request-scoped wiring.
|
||||
|
||||
Concrete adapters are bound to ports here so routers and services stay
|
||||
decoupled from infrastructure. Repository/service providers are added in
|
||||
later steps as the domain grows.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.infrastructure.db import get_sessionmaker
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Request-scoped DB session. Commits on success, rolls back on exception."""
|
||||
session = get_sessionmaker()()
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Maps domain exceptions to HTTP responses. The only place that knows both."""
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.errors import (
|
||||
AlreadyExistsError,
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
DependencyUnavailableError,
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_STATUS_BY_ERROR: dict[type[DomainError], int] = {
|
||||
NotFoundError: status.HTTP_404_NOT_FOUND,
|
||||
AlreadyExistsError: status.HTTP_409_CONFLICT,
|
||||
ConflictError: status.HTTP_409_CONFLICT,
|
||||
ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
||||
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
|
||||
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
}
|
||||
|
||||
|
||||
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
|
||||
return {"error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(DomainError)
|
||||
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse:
|
||||
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||
return JSONResponse(
|
||||
status_code=http_status,
|
||||
content=_error_body(exc.code, exc.message),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
|
||||
log.error("unhandled_exception", exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=_error_body("internal_error", "An unexpected error occurred."),
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Health & readiness endpoints — used by compose healthchecks and the admin UI.
|
||||
|
||||
* ``/health`` — liveness: the process is up. Always 200 if serving.
|
||||
* ``/health/ready`` — readiness: checks DB, Redis, and (optionally) ML.
|
||||
Returns 503 if a *required* dependency is down. ML is optional — its absence
|
||||
degrades, never fails, readiness (graceful degradation, see plan §6.5).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.infrastructure.cache import get_redis
|
||||
from app.infrastructure.db import get_sessionmaker
|
||||
|
||||
log = get_logger(__name__)
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
CheckStatus = Literal["ok", "down", "skipped"]
|
||||
|
||||
# A readiness probe must answer fast and never hang — bound every dependency
|
||||
# check. A check that exceeds this is reported "down".
|
||||
CHECK_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok"] = "ok"
|
||||
|
||||
|
||||
class ReadinessResponse(BaseModel):
|
||||
status: Literal["ready", "degraded"]
|
||||
checks: dict[str, CheckStatus]
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
|
||||
async def _check_db() -> CheckStatus:
|
||||
try:
|
||||
async with asyncio.timeout(CHECK_TIMEOUT_SECONDS):
|
||||
async with get_sessionmaker()() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return "ok"
|
||||
except Exception as exc:
|
||||
log.warning("healthcheck_db_down", error=str(exc))
|
||||
return "down"
|
||||
|
||||
|
||||
async def _check_redis() -> CheckStatus:
|
||||
try:
|
||||
async with asyncio.timeout(CHECK_TIMEOUT_SECONDS):
|
||||
await get_redis().ping()
|
||||
return "ok"
|
||||
except Exception as exc:
|
||||
log.warning("healthcheck_redis_down", error=str(exc))
|
||||
return "down"
|
||||
|
||||
|
||||
async def _check_ml() -> CheckStatus:
|
||||
# Optional dependency. A real client lands in step 12; absence is fine.
|
||||
return "skipped" if get_settings().ml_service_url is None else "ok"
|
||||
|
||||
|
||||
@router.get("/health/ready", response_model=ReadinessResponse)
|
||||
async def readiness(response: Response) -> ReadinessResponse:
|
||||
db, redis, ml = await asyncio.gather(_check_db(), _check_redis(), _check_ml())
|
||||
checks: dict[str, CheckStatus] = {"database": db, "redis": redis, "ml": ml}
|
||||
|
||||
required_down = db == "down" or redis == "down"
|
||||
if required_down:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
return ReadinessResponse(
|
||||
status="degraded" if required_down else "ready",
|
||||
checks=checks,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""HTTP middleware: bind a correlation id and log each request."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.core.logging import correlation_id, get_logger
|
||||
|
||||
log = get_logger("http")
|
||||
|
||||
_HEADER = "x-correlation-id"
|
||||
|
||||
|
||||
class CorrelationIdMiddleware:
|
||||
"""Pure-ASGI middleware: reuse inbound ``X-Correlation-Id`` or mint one,
|
||||
bind it for downstream logs, echo it back, and log request completion."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = dict(scope["headers"])
|
||||
inbound = headers.get(_HEADER.encode())
|
||||
cid = inbound.decode() if inbound else uuid.uuid4().hex
|
||||
token = correlation_id.set(cid)
|
||||
started = time.perf_counter()
|
||||
status_code = 0
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
nonlocal status_code
|
||||
if message["type"] == "http.response.start":
|
||||
status_code = message["status"]
|
||||
message.setdefault("headers", [])
|
||||
message["headers"].append((_HEADER.encode(), cid.encode()))
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
finally:
|
||||
log.info(
|
||||
"request",
|
||||
method=scope["method"],
|
||||
path=scope["path"],
|
||||
status=status_code,
|
||||
duration_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
)
|
||||
correlation_id.reset(token)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Application layer — use cases / services.
|
||||
|
||||
Services orchestrate the domain through *ports* (Protocols defined in
|
||||
``app.domain``). They never import concrete adapters directly; adapters are
|
||||
injected at the composition root (``app.main`` / ``app.api.deps``).
|
||||
"""
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""Management CLI (``mcma``). Admin/seed commands land here in later steps.
|
||||
|
||||
For now it exposes ``mcma version``. ``mcma create-admin`` arrives with auth
|
||||
(plan §11 step 3).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
from app import __version__
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="mcma", description="mcma-backend management CLI")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
sub.add_parser("version", help="Print the backend version")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "version":
|
||||
print(__version__)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Cross-cutting concerns: configuration, logging, security."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Application settings — single source of truth, sourced from environment.
|
||||
|
||||
Nothing is hardcoded. All knobs come from env vars (or a local ``.env`` during
|
||||
development). Access the cached singleton via :func:`get_settings`.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# -- runtime ----------------------------------------------------------
|
||||
environment: Literal["dev", "test", "prod"] = "dev"
|
||||
log_level: str = "INFO"
|
||||
log_json: bool = Field(
|
||||
default=False,
|
||||
description="Structured JSON logs (enable in prod).",
|
||||
)
|
||||
|
||||
# -- database ---------------------------------------------------------
|
||||
# Async driver required (asyncpg). Example:
|
||||
# postgresql+asyncpg://mcma:mcma@db:5432/mcma
|
||||
database_url: str = "postgresql+asyncpg://mcma:mcma@localhost:5432/mcma"
|
||||
db_echo: bool = False
|
||||
db_pool_size: int = 5
|
||||
db_max_overflow: int = 10
|
||||
|
||||
# -- redis (cache + arq broker) --------------------------------------
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# -- auth -------------------------------------------------------------
|
||||
jwt_secret: SecretStr = SecretStr("change-me-in-prod")
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
||||
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
||||
|
||||
# -- media / storage --------------------------------------------------
|
||||
media_path: Path = Path("/data/media")
|
||||
transcode_cache_path: Path = Path("/data/transcode-cache")
|
||||
max_parallel_downloads: int = 2
|
||||
|
||||
# -- external services (all optional; graceful degradation) ----------
|
||||
ml_service_url: str | None = None
|
||||
acoustid_api_key: SecretStr | None = None
|
||||
musicbrainz_user_agent: str = "mcma-backend/0.1.0 ( https://github.com/your/repo )"
|
||||
youtube_cookies_path: Path | None = None
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def _require_async_driver(cls, v: str) -> str:
|
||||
if "+asyncpg" not in v:
|
||||
raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://")
|
||||
return v
|
||||
|
||||
@property
|
||||
def is_prod(self) -> bool:
|
||||
return self.environment == "prod"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton. Patch the cache in tests via ``get_settings.cache_clear()``."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Structured logging via structlog.
|
||||
|
||||
Emits key=value (dev) or JSON (prod) with a per-request/task ``correlation_id``
|
||||
bound through a contextvar. Call :func:`configure_logging` once at startup.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from typing import cast
|
||||
|
||||
import structlog
|
||||
from structlog.typing import EventDict, FilteringBoundLogger, Processor, WrappedLogger
|
||||
|
||||
# Bound by middleware (HTTP) and worker entrypoints (tasks) so every log line
|
||||
# downstream carries the same id without explicit passing.
|
||||
correlation_id: ContextVar[str | None] = ContextVar("correlation_id", default=None)
|
||||
|
||||
|
||||
def _add_correlation_id(
|
||||
_logger: WrappedLogger, _method: str, event_dict: EventDict
|
||||
) -> EventDict:
|
||||
cid = correlation_id.get()
|
||||
if cid is not None:
|
||||
event_dict["correlation_id"] = cid
|
||||
return event_dict
|
||||
|
||||
|
||||
def configure_logging(*, level: str = "INFO", json: bool = False) -> None:
|
||||
shared: list[Processor] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
_add_correlation_id,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
renderer = (
|
||||
structlog.processors.JSONRenderer() if json else structlog.dev.ConsoleRenderer(colors=True)
|
||||
)
|
||||
|
||||
structlog.configure(
|
||||
processors=[*shared, renderer],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.getLevelName(level.upper())),
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
# Route stdlib logging (uvicorn, sqlalchemy) through structlog formatting.
|
||||
logging.basicConfig(level=level.upper(), format="%(message)s")
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> FilteringBoundLogger:
|
||||
return cast(FilteringBoundLogger, structlog.get_logger(name))
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pure business core — entities, value objects, errors, and ports (Protocols).
|
||||
|
||||
This package MUST NOT import frameworks (FastAPI, SQLAlchemy, redis, …).
|
||||
Adapters depend on the domain; the domain depends on nothing but stdlib.
|
||||
"""
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Domain exception hierarchy — framework-agnostic.
|
||||
|
||||
Services raise these; the API layer maps them to HTTP responses
|
||||
(see ``app.api.errors``). The domain never references HTTP status codes.
|
||||
"""
|
||||
|
||||
|
||||
class DomainError(Exception):
|
||||
"""Base for all expected, business-meaningful failures.
|
||||
|
||||
``code`` is a stable, machine-readable identifier returned to clients.
|
||||
"""
|
||||
|
||||
code: str = "domain_error"
|
||||
|
||||
def __init__(self, message: str | None = None) -> None:
|
||||
super().__init__(message or self.__class__.__doc__ or self.code)
|
||||
self.message = str(self)
|
||||
|
||||
|
||||
class NotFoundError(DomainError):
|
||||
"""Requested resource does not exist."""
|
||||
|
||||
code = "not_found"
|
||||
|
||||
|
||||
class AlreadyExistsError(DomainError):
|
||||
"""Resource conflicts with an existing one (e.g. duplicate)."""
|
||||
|
||||
code = "already_exists"
|
||||
|
||||
|
||||
class ConflictError(DomainError):
|
||||
"""Operation conflicts with current state (e.g. stale version on write)."""
|
||||
|
||||
code = "conflict"
|
||||
|
||||
|
||||
class ValidationError(DomainError):
|
||||
"""Input is well-formed but violates a business rule."""
|
||||
|
||||
code = "validation_error"
|
||||
|
||||
|
||||
class AuthenticationError(DomainError):
|
||||
"""Caller could not be authenticated."""
|
||||
|
||||
code = "authentication_error"
|
||||
|
||||
|
||||
class PermissionDeniedError(DomainError):
|
||||
"""Caller authenticated but not authorized for this action."""
|
||||
|
||||
code = "permission_denied"
|
||||
|
||||
|
||||
class DependencyUnavailableError(DomainError):
|
||||
"""An external dependency (source, ML, MusicBrainz) is unavailable.
|
||||
|
||||
Callers should degrade gracefully rather than propagate as a hard 500.
|
||||
"""
|
||||
|
||||
code = "dependency_unavailable"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Driven adapters — concrete implementations of domain ports.
|
||||
|
||||
ORM models, repositories, the async DB engine, the Redis client, source
|
||||
backends, and external HTTP clients live here. Everything framework- and
|
||||
vendor-specific is confined to this package.
|
||||
"""
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
"""Redis adapter: shared connection pool for cache and arq broker."""
|
||||
|
||||
from app.infrastructure.cache.redis import close_redis, get_redis
|
||||
|
||||
__all__ = ["close_redis", "get_redis"]
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
"""Redis client provider — a single shared async connection pool."""
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis() -> Redis:
|
||||
"""Return the process-wide Redis client (created on first use)."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = Redis.from_url(
|
||||
str(get_settings().redis_url),
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Database adapter: declarative base, async engine, session factory."""
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.engine import (
|
||||
dispose_engine,
|
||||
get_engine,
|
||||
get_sessionmaker,
|
||||
session_scope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"dispose_engine",
|
||||
"get_engine",
|
||||
"get_sessionmaker",
|
||||
"session_scope",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Declarative base with a fixed naming convention.
|
||||
|
||||
The naming convention makes Alembic autogenerate deterministic, named
|
||||
constraints — essential for clean, reversible migrations.
|
||||
"""
|
||||
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base for all ORM models. Import models so Alembic sees their metadata."""
|
||||
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Async engine + session factory, created lazily from settings.
|
||||
|
||||
The engine is process-global and cached. The API binds a session per request
|
||||
(see ``app.api.deps``); workers and scripts use :func:`session_scope`.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_engine() -> AsyncEngine:
|
||||
settings = get_settings()
|
||||
return create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.db_echo,
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
pool_pre_ping=True, # survive Postgres restarts / idle drops
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(
|
||||
bind=get_engine(),
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_scope() -> AsyncIterator[AsyncSession]:
|
||||
"""Transactional session for workers/scripts: commit on success, rollback on error."""
|
||||
session = get_sessionmaker()()
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose the pooled engine on shutdown. Safe to call if never initialized."""
|
||||
if get_engine.cache_info().currsize:
|
||||
await get_engine().dispose()
|
||||
get_engine.cache_clear()
|
||||
get_sessionmaker.cache_clear()
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""FastAPI composition root: wiring, lifespan, middleware, routers."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.errors import register_exception_handlers
|
||||
from app.api.health import router as health_router
|
||||
from app.api.middleware import CorrelationIdMiddleware
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.infrastructure.cache import close_redis
|
||||
from app.infrastructure.db import dispose_engine
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
log.info("startup", environment=settings.environment)
|
||||
yield
|
||||
log.info("shutdown")
|
||||
await dispose_engine()
|
||||
await close_redis()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
configure_logging(level=settings.log_level, json=settings.log_json)
|
||||
|
||||
app = FastAPI(
|
||||
title="mcma-backend",
|
||||
version="0.1.0",
|
||||
summary="Self-hosted, offline-first music service.",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(CorrelationIdMiddleware)
|
||||
register_exception_handlers(app)
|
||||
|
||||
app.include_router(health_router)
|
||||
# Versioned API routers (auth, library, …) are mounted in later steps:
|
||||
# app.include_router(api_v1_router, prefix="/api/v1")
|
||||
# app.include_router(subsonic_router, prefix="/rest")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""arq worker — background tasks (downloads, enrichment, transcoding).
|
||||
|
||||
CPU/IO-heavy work runs here, never in the request cycle (plan §2.6).
|
||||
"""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""arq worker settings — the queue runtime. Task functions register here.
|
||||
|
||||
Run with: ``arq app.workers.arq_worker.WorkerSettings``.
|
||||
Tasks (download, enrich, transcode) are appended to ``functions`` in later steps.
|
||||
"""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
|
||||
log = get_logger("worker")
|
||||
|
||||
|
||||
async def startup(_ctx: dict[str, Any]) -> None:
|
||||
settings = get_settings()
|
||||
configure_logging(level=settings.log_level, json=settings.log_json)
|
||||
log.info("worker_startup", environment=settings.environment)
|
||||
|
||||
|
||||
async def shutdown(_ctx: dict[str, Any]) -> None:
|
||||
log.info("worker_shutdown")
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
functions: ClassVar[list[Any]] = [] # populated as tasks are implemented
|
||||
on_startup = startup
|
||||
on_shutdown = shutdown
|
||||
max_jobs = get_settings().max_parallel_downloads
|
||||
redis_settings = RedisSettings.from_dsn(str(get_settings().redis_url))
|
||||
Reference in New Issue
Block a user