"""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. Each request gets its own repositories/services bound to the request-scoped DB session; stateless adapters (hasher, token service) are process-cached. """ import datetime as dt from collections.abc import AsyncIterator from functools import lru_cache from typing import Annotated from fastapi import Depends, Query from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.application.auth_service import AuthService from app.application.download_service import DownloadService from app.application.lyrics_service import LyricsService from app.application.metadata_service import MetadataEnrichmentService from app.application.recommendation_service import RecommendationService from app.application.remote_library_service import RemoteLibraryService from app.application.streaming_service import StreamingService from app.application.subsonic_auth_service import SubsonicAuthService from app.application.sync_service import SyncService from app.application.transcode_service import TranscodeService from app.application.upload_service import UploadService from app.application.user_service import UserService from app.application.user_settings_service import UserSettingsService from app.core.config import get_settings from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher from app.domain.entities import User from app.domain.errors import AuthenticationError, PermissionDeniedError from app.domain.ports import FileStorage, PasswordHasher, SubsonicCipher, TokenService from app.infrastructure.db import get_sessionmaker from app.infrastructure.db.repositories import ( SqlAlchemyAlbumRepository, SqlAlchemyArtistRepository, SqlAlchemyDownloadJobRepository, SqlAlchemyHistoryRepository, SqlAlchemyLikeRepository, SqlAlchemyLyricsRepository, SqlAlchemyPlaylistRepository, SqlAlchemyRefreshTokenRepository, SqlAlchemyTrackRepository, SqlAlchemyUserRepository, SqlAlchemyUserSettingsRepository, ) from app.infrastructure.metadata.acoustid import AcoustIdHttpClient from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter from app.infrastructure.metadata.lrclib import LrclibHttpClient from app.infrastructure.metadata.tags import MutagenTagReader from app.infrastructure.ml.recommender import NullRecommender from app.infrastructure.sources.registry import SourceRegistry, build_source_registry from app.infrastructure.storage.provider import get_file_storage from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize 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)] # -- stateless adapters (process-cached) --------------------------------------- @lru_cache def get_password_hasher() -> PasswordHasher: return Argon2PasswordHasher() @lru_cache def get_token_service() -> TokenService: return JwtTokenService(get_settings()) @lru_cache def get_subsonic_cipher() -> SubsonicCipher: return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value()) @lru_cache def get_source_registry() -> SourceRegistry: return build_source_registry(get_settings()) SourceRegistryDep = Annotated[SourceRegistry, Depends(get_source_registry)] # -- request-scoped services --------------------------------------------------- def get_auth_service(session: SessionDep) -> AuthService: return AuthService( users=SqlAlchemyUserRepository(session), refresh_tokens=SqlAlchemyRefreshTokenRepository(session), hasher=get_password_hasher(), tokens=get_token_service(), ) def get_user_service(session: SessionDep) -> UserService: return UserService( users=SqlAlchemyUserRepository(session), refresh_tokens=SqlAlchemyRefreshTokenRepository(session), hasher=get_password_hasher(), ) def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService: return SubsonicAuthService( users=SqlAlchemyUserRepository(session), cipher=get_subsonic_cipher(), ) def get_user_settings_service(session: SessionDep) -> UserSettingsService: return UserSettingsService( settings=SqlAlchemyUserSettingsRepository(session), cipher=get_subsonic_cipher(), ) AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] UserServiceDep = Annotated[UserService, Depends(get_user_service)] SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)] UserSettingsServiceDep = Annotated[UserSettingsService, Depends(get_user_settings_service)] # -- file storage (process-cached) --------------------------------------------- FileStorageDep = Annotated[FileStorage, Depends(get_file_storage)] def get_upload_service(session: SessionDep, storage: FileStorageDep) -> UploadService: settings = get_settings() return UploadService( tracks=SqlAlchemyTrackRepository(session), artists=SqlAlchemyArtistRepository(session), storage=storage, tmp_dir=settings.upload_tmp_dir, enqueue_enrich=enqueue_enrich, ) def get_streaming_service(session: SessionDep, storage: FileStorageDep) -> StreamingService: return StreamingService( tracks=SqlAlchemyTrackRepository(session), storage=storage, ) def get_metadata_service(session: SessionDep, storage: FileStorageDep) -> MetadataEnrichmentService: """Wires the §6.2 fingerprint/AcoustID adapters for read-only, inline use (the metadata editor's "find matches" — §A7). The full pipeline (incl. cover art) stays in the worker (`tasks/enrich_task.py`).""" settings = get_settings() api_key = settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None acoustid = AcoustIdHttpClient( api_key=api_key, user_agent=settings.musicbrainz_user_agent, api_url=settings.acoustid_api_url, ) return MetadataEnrichmentService( tracks=SqlAlchemyTrackRepository(session), artists=SqlAlchemyArtistRepository(session), albums=SqlAlchemyAlbumRepository(session), storage=storage, tag_reader=MutagenTagReader(), fingerprinter=FpcalcFingerprinter(settings.fpcalc_path), acoustid=acoustid, acoustid_trust_score=settings.acoustid_trust_score, ) def get_transcode_service(session: SessionDep) -> TranscodeService: """Request-side cache lookups for transcoded renditions (§6.6). Generation itself runs in the ``transcode_track`` worker, never here.""" return TranscodeService( tracks=SqlAlchemyTrackRepository(session), cache_root=get_settings().transcode_cache_path, ) def get_recommendation_service(session: SessionDep) -> RecommendationService: """Radio + similarity (§6.5). ML is optional and no service/contract exists yet, so we wire ``NullRecommender`` — the service then uses its metadata fallback. Swap in ``RemoteRecommender(ml_service_url)`` once ML lands.""" return RecommendationService( recommender=NullRecommender(), tracks=SqlAlchemyTrackRepository(session), artists=SqlAlchemyArtistRepository(session), likes=SqlAlchemyLikeRepository(session), ) def get_lyrics_service(session: SessionDep) -> LyricsService: """Wires the LRCLIB lyrics provider + cache repo (plan §6.7). LRCLIB is keyless, so this is always available; failures degrade to ``not_found``.""" settings = get_settings() return LyricsService( lyrics=SqlAlchemyLyricsRepository(session), tracks=SqlAlchemyTrackRepository(session), artists=SqlAlchemyArtistRepository(session), albums=SqlAlchemyAlbumRepository(session), provider=LrclibHttpClient(user_agent=settings.musicbrainz_user_agent), ) def get_download_service(session: SessionDep, storage: FileStorageDep) -> DownloadService: return DownloadService( jobs=SqlAlchemyDownloadJobRepository(session), tracks=SqlAlchemyTrackRepository(session), artists=SqlAlchemyArtistRepository(session), storage=storage, enqueue_download=enqueue_download, enqueue_enrich=enqueue_enrich, ) def get_remote_library_service(session: SessionDep) -> RemoteLibraryService: return RemoteLibraryService( tracks=SqlAlchemyTrackRepository(session), artists=SqlAlchemyArtistRepository(session), jobs=SqlAlchemyDownloadJobRepository(session), enqueue_materialize=enqueue_materialize, ) UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)] StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)] MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)] LyricsServiceDep = Annotated[LyricsService, Depends(get_lyrics_service)] TranscodeServiceDep = Annotated[TranscodeService, Depends(get_transcode_service)] RecommendationServiceDep = Annotated[ RecommendationService, Depends(get_recommendation_service) ] DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)] RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)] async def _db_now(session: AsyncSession) -> dt.datetime: """The database clock — the sync cursor watermark (avoids app/DB skew).""" return (await session.execute(select(func.now()))).scalar_one() def get_sync_service(session: SessionDep) -> SyncService: return SyncService( likes=SqlAlchemyLikeRepository(session), history=SqlAlchemyHistoryRepository(session), playlists=SqlAlchemyPlaylistRepository(session), tracks=SqlAlchemyTrackRepository(session), now=lambda: _db_now(session), ) SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)] # -- library repository deps --------------------------------------------------- def get_track_repository(session: SessionDep) -> SqlAlchemyTrackRepository: return SqlAlchemyTrackRepository(session) def get_artist_repository(session: SessionDep) -> SqlAlchemyArtistRepository: return SqlAlchemyArtistRepository(session) def get_album_repository(session: SessionDep) -> SqlAlchemyAlbumRepository: return SqlAlchemyAlbumRepository(session) def get_playlist_repository(session: SessionDep) -> SqlAlchemyPlaylistRepository: return SqlAlchemyPlaylistRepository(session) def get_like_repository(session: SessionDep) -> SqlAlchemyLikeRepository: return SqlAlchemyLikeRepository(session) def get_history_repository(session: SessionDep) -> SqlAlchemyHistoryRepository: return SqlAlchemyHistoryRepository(session) TrackRepoDep = Annotated[SqlAlchemyTrackRepository, Depends(get_track_repository)] ArtistRepoDep = Annotated[SqlAlchemyArtistRepository, Depends(get_artist_repository)] AlbumRepoDep = Annotated[SqlAlchemyAlbumRepository, Depends(get_album_repository)] PlaylistRepoDep = Annotated[SqlAlchemyPlaylistRepository, Depends(get_playlist_repository)] LikeRepoDep = Annotated[SqlAlchemyLikeRepository, Depends(get_like_repository)] HistoryRepoDep = Annotated[SqlAlchemyHistoryRepository, Depends(get_history_repository)] # -- current user / authorization ---------------------------------------------- # auto_error=False: we raise domain AuthenticationError (mapped to 401) so the # error envelope stays consistent with the rest of the API. _bearer = HTTPBearer(auto_error=False) BearerDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] async def get_current_user(credentials: BearerDep, auth: AuthServiceDep) -> User: if credentials is None: raise AuthenticationError("Missing bearer token.") return await auth.authenticate_access(credentials.credentials) CurrentUser = Annotated[User, Depends(get_current_user)] async def get_current_superuser(user: CurrentUser) -> User: if not user.is_superuser: raise PermissionDeniedError("Administrator privileges required.") return user SuperUser = Annotated[User, Depends(get_current_superuser)] async def get_streaming_user( auth: AuthServiceDep, credentials: BearerDep, token: str | None = None, ) -> User: """Authenticate a stream request. The browser ``