Project started 🍾
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.env
|
||||
*.md
|
||||
tests/
|
||||
media/
|
||||
data/
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copy to .env and adjust. Never commit real secrets.
|
||||
|
||||
# runtime
|
||||
ENVIRONMENT=dev # dev | test | prod
|
||||
LOG_LEVEL=INFO
|
||||
LOG_JSON=false # true in prod
|
||||
|
||||
# database (async driver required)
|
||||
DATABASE_URL=postgresql+asyncpg://mcma:mcma@localhost:5432/mcma
|
||||
DB_ECHO=false
|
||||
|
||||
# redis (cache + arq broker)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# auth — GENERATE a strong secret for prod: `openssl rand -hex 32`
|
||||
JWT_SECRET=change-me-in-prod
|
||||
ACCESS_TOKEN_TTL_SECONDS=900
|
||||
REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||
|
||||
# media / storage
|
||||
MEDIA_PATH=/data/media
|
||||
TRANSCODE_CACHE_PATH=/data/transcode-cache
|
||||
MAX_PARALLEL_DOWNLOADS=2
|
||||
|
||||
# external services (all optional — backend degrades gracefully if unset)
|
||||
# ML_SERVICE_URL=http://ml:9000
|
||||
# ACOUSTID_API_KEY=
|
||||
MUSICBRAINZ_USER_AGENT=mcma-backend/0.1.0 ( https://github.com/your/repo )
|
||||
# YOUTUBE_COOKIES_PATH=/data/cookies.txt
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Tooling caches
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Secrets & local config
|
||||
.env
|
||||
|
||||
# Local data (media, transcode cache, db dumps)
|
||||
/data/
|
||||
media/
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -0,0 +1,70 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
`mcma-backend` — a self-hosted, **offline-first** music service (FOSS, homelab scale: units–tens of users). Downloads music from external sources (yt-dlp etc.), stores files + metadata, streams to clients, serves a native REST API plus a Subsonic-compatible API. The server is a **sync layer**, not the sole source of truth — clients are expected to work offline, which shapes the data model (timestamps, event-logs) even before sync is built.
|
||||
|
||||
Status: **Phase 1 (MVP)**. Step 1 (skeleton) is done; the data model, auth, sources, enrichment, streaming, and Subsonic layer follow. There is no in-repo plan document — the build order and deferred work live in Claude's project memory.
|
||||
|
||||
## Commands
|
||||
|
||||
Uses **uv** (managed Python 3.14) for everything.
|
||||
|
||||
```bash
|
||||
uv sync # install deps (runtime + dev group)
|
||||
uv run uvicorn app.main:app --reload # run API (needs Postgres + Redis up)
|
||||
uv run arq app.workers.arq_worker.WorkerSettings # run background worker
|
||||
|
||||
uv run ruff check . # lint
|
||||
uv run ruff format . # format
|
||||
uv run mypy app # type-check (strict)
|
||||
uv run pytest # all tests
|
||||
uv run pytest tests/test_health.py::test_liveness_ok # single test
|
||||
|
||||
uv run alembic revision --autogenerate -m "msg" # new migration (after model changes)
|
||||
uv run alembic upgrade head # apply migrations
|
||||
|
||||
docker compose up --build # full stack: api, worker, db, redis
|
||||
```
|
||||
|
||||
Tests run in-process against the ASGI app (httpx + asgi-lifespan) — no server, no network. They do **not** require a running DB/Redis: dependency-backed checks degrade rather than fail.
|
||||
|
||||
## Architecture — hexagonal (ports & adapters)
|
||||
|
||||
Dependencies point **inward**. The domain knows nothing about frameworks; outer layers are wired together only at the composition root.
|
||||
|
||||
- `app/domain/` — pure business core: entities, value objects, errors, and **ports** (Protocols). No framework imports (no FastAPI/SQLAlchemy/redis here, ever).
|
||||
- `app/application/` — use cases / services. Depend on domain ports, never on concrete adapters.
|
||||
- `app/infrastructure/` — driven adapters: ORM models + repositories (`db/`), Redis client (`cache/`), source backends, ML/HTTP clients. All vendor-specific code is confined here.
|
||||
- `app/api/` — driving adapter (FastAPI): routers, schemas, `deps.py` (request-scoped wiring / composition root), middleware, error mapping.
|
||||
- `app/core/` — cross-cutting: config, logging, security.
|
||||
- `app/workers/` — arq background tasks (heavy I/O and CPU work).
|
||||
|
||||
**Composition roots** are `app/main.py` (`create_app`, lifespan) and `app/api/deps.py` (binds concrete adapters to ports per request). When adding a feature: define the port in `domain`, the service in `application`, the adapter in `infrastructure`, and wire it in `deps.py`.
|
||||
|
||||
### Cross-cutting conventions
|
||||
|
||||
- **Errors:** services raise framework-agnostic exceptions from `app/domain/errors.py`. `app/api/errors.py` is the *only* place that maps domain errors → HTTP status codes. Don't reference HTTP status from domain/application code.
|
||||
- **Config:** `app/core/config.py` — `get_settings()` is a cached singleton over pydantic-settings. Everything comes from env (or `.env` in dev); nothing is hardcoded. `database_url` must use the `+asyncpg` driver (validated).
|
||||
- **Logging:** `app/core/logging.py` — structlog, console in dev / JSON in prod. A `correlation_id` contextvar is bound per HTTP request (middleware) and should be bound per worker task; it auto-attaches to every log line.
|
||||
- **DB sessions:** API uses `SessionDep` (request-scoped, commit-on-success/rollback-on-error). Workers/scripts use `session_scope()` from `app.infrastructure.db`.
|
||||
- **Migrations:** Alembic is async and settings-driven — the URL is injected in `alembic/env.py`, never written to `alembic.ini`. Uncomment the models import in `env.py` once ORM models exist so autogenerate sees them.
|
||||
- **Health:** `/health` is liveness; `/health/ready` checks db + redis (required) + ml (optional). Every dependency check is wrapped in `asyncio.timeout` — a readiness probe must never hang.
|
||||
|
||||
## Non-negotiable domain invariants
|
||||
|
||||
These exist for future sync + ML and are easy to violate by accident:
|
||||
|
||||
- **Likes are an append-only event-log, not a boolean.** Current state = the latest event per `(user, track)`. Never update a like row in place.
|
||||
- **`track.id` is the stable `content_id`** — a uuid that never changes; it's the basis of client-side caching. Don't regenerate it.
|
||||
- **Dedup on both** `(source, source_id)` **and** `acoustid_fingerprint`. Downloads/imports must be idempotent.
|
||||
- **Graceful degradation is mandatory.** ML, music sources, MusicBrainz/AcoustID are all optional. One being down must degrade a feature, never crash the system. The ML service is *not* a required dependency — radio must still work (worse) without it.
|
||||
- **Never overwrite `metadata_status = manual`** with automatic enrichment.
|
||||
- **No heavy work in the request cycle.** ffmpeg, fingerprinting, downloads — all go to arq workers.
|
||||
- **Subsonic compatibility is a strategic goal:** the Subsonic API is an adapter over services, not a reimplementation of logic.
|
||||
|
||||
## Python 3.14 note
|
||||
|
||||
The project targets Python 3.14, which makes annotations lazy by default (PEP 649). `from __future__ import annotations` is therefore intentionally **absent** — do not add it back. All pins (pyproject `requires-python`, ruff `target-version`, mypy `python_version`, Dockerfile, `.python-version`) are on 3.14.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Single image serves both `api` and `worker` (different commands in compose).
|
||||
FROM python:3.14-slim AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Runtime tools: ffmpeg (transcode/HLS), fpcalc (Chromaprint fingerprinting).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg libchromaprint-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.4 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dependency layer — cached unless lockfile changes.
|
||||
COPY pyproject.toml uv.lock* ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Project layer.
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Default: API server. Worker overrides command in docker-compose.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,68 @@
|
||||
# mcma-backend
|
||||
|
||||
Self-hosted, **offline-first** music service — backend. Searches/downloads music
|
||||
from external sources, stores files + metadata, streams to clients, and serves a
|
||||
native REST API plus a Subsonic-compatible API.
|
||||
|
||||
> Status: **Phase 1 (core/MVP)** — project skeleton in place. See the
|
||||
> implementation plan for the full roadmap.
|
||||
|
||||
## Architecture — hexagonal (ports & adapters)
|
||||
|
||||
```
|
||||
app/
|
||||
├── domain/ pure core: entities, value objects, errors, ports (Protocols)
|
||||
├── application/ use cases / services — orchestrate domain via ports
|
||||
├── infrastructure/ driven adapters: ORM, repositories, db, redis, sources, ML client
|
||||
│ ├── db/ declarative base, async engine, session factory
|
||||
│ └── cache/ redis client
|
||||
├── api/ driving adapter: FastAPI routers, schemas, deps, middleware
|
||||
├── workers/ arq background tasks (download, enrich, transcode)
|
||||
└── core/ cross-cutting: config, logging, security
|
||||
```
|
||||
|
||||
**Rule:** dependencies point inward. `domain` imports nothing framework-specific;
|
||||
`application` depends on domain ports; `infrastructure`/`api` are the outer ring
|
||||
and are wired together at the composition root (`app/main.py`, `app/api/deps.py`).
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then set a real JWT_SECRET
|
||||
docker compose up --build # api on :8000, worker, postgres, redis
|
||||
curl localhost:8000/health # {"status":"ok"}
|
||||
curl localhost:8000/health/ready
|
||||
```
|
||||
|
||||
## Local dev (without Docker)
|
||||
|
||||
```bash
|
||||
uv sync # install deps (uses managed Python 3.14)
|
||||
# start Postgres + Redis (e.g. `docker compose up db redis`)
|
||||
cp .env.example .env
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
```bash
|
||||
uv run ruff check . # lint
|
||||
uv run ruff format . # format
|
||||
uv run mypy app # type-check (strict)
|
||||
uv run pytest # tests
|
||||
```
|
||||
|
||||
## Database migrations (Alembic)
|
||||
|
||||
```bash
|
||||
uv run alembic revision --autogenerate -m "message" # after model changes
|
||||
uv run alembic upgrade head # apply
|
||||
```
|
||||
|
||||
The DB URL is injected from app settings — never hardcoded in `alembic.ini`.
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings come from environment variables (or `.env` in dev). See
|
||||
[`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz)
|
||||
are **optional** — the backend degrades gracefully when they are absent.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Alembic configuration. The database URL is injected at runtime from
|
||||
# app settings (see alembic/env.py) — do NOT hardcode it here.
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
# Use a timestamp + slug for migration filenames.
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Alembic environment — async, settings-driven, model-aware.
|
||||
|
||||
The DB URL comes from app settings (env), never alembic.ini. ``target_metadata``
|
||||
points at the ORM ``Base.metadata``; importing ``app.infrastructure.db.models``
|
||||
registers every model so autogenerate sees the full schema.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from app.core.config import get_settings
|
||||
from app.infrastructure.db import Base
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
# Import side-effect: registers all ORM models on Base.metadata.
|
||||
# Uncomment once models exist (plan §11 step 2):
|
||||
# import app.infrastructure.db.models
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
render_as_batch=False,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -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))
|
||||
@@ -0,0 +1,97 @@
|
||||
# Dev/prod stack. Worker reuses the api image with a different command.
|
||||
# The ML service is intentionally a commented placeholder — it is optional
|
||||
# (graceful degradation) and lands in a later phase.
|
||||
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://mcma:mcma@db:5432/mcma
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- media:/data/media
|
||||
- transcode_cache:/data/transcode-cache
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
build: .
|
||||
command: arq app.workers.arq_worker.WorkerSettings
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://mcma:mcma@db:5432/mcma
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
- media:/data/media
|
||||
- transcode_cache:/data/transcode-cache
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: mcma
|
||||
POSTGRES_PASSWORD: mcma
|
||||
POSTGRES_DB: mcma
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mcma"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Reverse proxy + auto-HTTPS (enable on prod with a real domain).
|
||||
# caddy:
|
||||
# image: caddy:2-alpine
|
||||
# ports: ["80:80", "443:443"]
|
||||
# volumes:
|
||||
# - ./Caddyfile:/etc/caddy/Caddyfile
|
||||
# - caddy_data:/data
|
||||
# depends_on: [api]
|
||||
# restart: unless-stopped
|
||||
|
||||
# ML recommendation service — OPTIONAL, added in a later phase.
|
||||
# The backend must run fully without it (set ML_SERVICE_URL to enable).
|
||||
# ml:
|
||||
# image: mcma-ml:latest
|
||||
# environment:
|
||||
# MODEL_PATH: /models
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
media:
|
||||
transcode_cache:
|
||||
# caddy_data:
|
||||
@@ -0,0 +1,87 @@
|
||||
[project]
|
||||
name = "mcma-backend"
|
||||
version = "0.1.0"
|
||||
description = "Self-hosted, offline-first music service — backend (hexagonal architecture)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
# web
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"python-multipart>=0.0.12",
|
||||
# data
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30",
|
||||
"alembic>=1.14",
|
||||
# config / validation
|
||||
"pydantic>=2.10",
|
||||
"pydantic-settings>=2.6",
|
||||
# cache / queue
|
||||
"redis>=5.2",
|
||||
"arq>=0.26",
|
||||
# auth
|
||||
"pyjwt>=2.10",
|
||||
"pwdlib[argon2]>=0.2.1",
|
||||
# outbound http (ML client, MusicBrainz, AcoustID)
|
||||
"httpx>=0.28",
|
||||
# logging
|
||||
"structlog>=24.4",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcma = "app.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.8",
|
||||
"mypy>=1.13",
|
||||
"asgi-lifespan>=2.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tooling
|
||||
# ---------------------------------------------------------------------------
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 100
|
||||
src = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", "F", "W", # pycodestyle / pyflakes
|
||||
"I", # isort
|
||||
"N", # pep8-naming
|
||||
"UP", # pyupgrade
|
||||
"B", # bugbear
|
||||
"ASYNC", # async pitfalls
|
||||
"RUF",
|
||||
]
|
||||
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
plugins = ["pydantic.mypy"]
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["arq.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra"
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
The ASGI app is driven in-process via httpx + asgi-lifespan (no network, no
|
||||
running server). DB/Redis-backed integration fixtures arrive with the data
|
||||
layer (plan §11 step 2).
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
# Force a test-safe environment before settings load.
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
os.environ.setdefault("JWT_SECRET", "test-secret")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[AsyncClient]:
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
async with LifespanManager(app):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Smoke tests for the health endpoints."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def test_liveness_ok(client: AsyncClient) -> None:
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_correlation_id_echoed(client: AsyncClient) -> None:
|
||||
resp = await client.get("/health", headers={"X-Correlation-Id": "abc123"})
|
||||
assert resp.headers.get("x-correlation-id") == "abc123"
|
||||
|
||||
|
||||
async def test_readiness_reports_checks(client: AsyncClient) -> None:
|
||||
# No DB/Redis running in unit env → degraded, but the contract holds.
|
||||
resp = await client.get("/health/ready")
|
||||
body = resp.json()
|
||||
assert set(body["checks"]) == {"database", "redis", "ml"}
|
||||
assert body["status"] in {"ready", "degraded"}
|
||||
Reference in New Issue
Block a user