"""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. """ 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.ext.asyncio import AsyncSession from app.application.auth_service import AuthService from app.application.streaming_service import StreamingService from app.application.subsonic_auth_service import SubsonicAuthService from app.application.upload_service import UploadService from app.application.user_service import UserService 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, SqlAlchemyHistoryRepository, SqlAlchemyLikeRepository, SqlAlchemyPlaylistRepository, SqlAlchemyRefreshTokenRepository, SqlAlchemyTrackRepository, SqlAlchemyUserRepository, ) from app.infrastructure.sources.registry import SourceRegistry, build_source_registry from app.infrastructure.storage.provider import get_file_storage 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(), ) AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] UserServiceDep = Annotated[UserService, Depends(get_user_service)] SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_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, ) def get_streaming_service(session: SessionDep, storage: FileStorageDep) -> StreamingService: return StreamingService( tracks=SqlAlchemyTrackRepository(session), storage=storage, ) UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)] StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_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 ``