feat: local storage logic & endpoints

This commit is contained in:
Senko-san
2026-06-07 15:34:06 +03:00
parent dfd512a13f
commit 81ea93c371
23 changed files with 945 additions and 18 deletions
+31 -1
View File
@@ -15,17 +15,22 @@ 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.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
from app.domain.entities import User
from app.domain.errors import AuthenticationError, PermissionDeniedError
from app.domain.ports import PasswordHasher, TokenService
from app.domain.ports import FileStorage, PasswordHasher, TokenService
from app.infrastructure.db import get_sessionmaker
from app.infrastructure.db.repositories import (
SqlAlchemyArtistRepository,
SqlAlchemyRefreshTokenRepository,
SqlAlchemyTrackRepository,
SqlAlchemyUserRepository,
)
from app.infrastructure.storage.provider import get_file_storage
async def get_session() -> AsyncIterator[AsyncSession]:
@@ -77,6 +82,31 @@ AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
UserServiceDep = Annotated[UserService, Depends(get_user_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)]
# -- 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.
+11
View File
@@ -12,6 +12,8 @@ from app.domain.errors import (
DomainError,
NotFoundError,
PermissionDeniedError,
RangeNotSatisfiableError,
StorageError,
ValidationError,
)
@@ -25,6 +27,7 @@ _STATUS_BY_ERROR: dict[type[DomainError], int] = {
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
}
@@ -33,6 +36,14 @@ def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(RangeNotSatisfiableError)
async def _handle_range_error(_request: Request, exc: RangeNotSatisfiableError) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
content=_error_body(exc.code, exc.message),
headers={"Content-Range": f"bytes */{exc.total_size}"},
)
@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)
+11
View File
@@ -0,0 +1,11 @@
"""Schemas for upload responses."""
import uuid
from pydantic import BaseModel
class UploadResponse(BaseModel):
track_id: uuid.UUID
title: str
already_exists: bool
+27 -9
View File
@@ -1,20 +1,38 @@
"""Audio streaming endpoints: direct stream and HLS."""
"""Audio streaming endpoint direct stream with Range support."""
import uuid
from typing import Any
from typing import Annotated
from fastapi import APIRouter
from fastapi import APIRouter, Header
from fastapi.responses import StreamingResponse
from app.api.deps import StreamingServiceDep
router = APIRouter(prefix="/stream", tags=["streaming"])
@router.get("/{track_id}")
async def stream_track(track_id: uuid.UUID) -> Any: ...
async def stream_track(
track_id: uuid.UUID,
service: StreamingServiceDep,
range_header: Annotated[str | None, Header(alias="Range")] = None,
) -> StreamingResponse:
result = await service.open_stream(track_id, range_header)
headers = {
"Accept-Ranges": "bytes",
"Content-Length": str(result.content_length),
}
@router.get("/{track_id}/hls/playlist.m3u8")
async def hls_playlist(track_id: uuid.UUID) -> Any: ...
if result.is_partial:
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
status_code = 206
else:
status_code = 200
@router.get("/{track_id}/hls/{segment}")
async def hls_segment(track_id: uuid.UUID, segment: str) -> Any: ...
return StreamingResponse(
result.stream,
status_code=status_code,
headers=headers,
media_type=result.content_type,
)
+17 -4
View File
@@ -1,11 +1,24 @@
"""Local file upload endpoint."""
from typing import Any
from typing import Annotated
from fastapi import APIRouter
from fastapi import APIRouter, File, UploadFile
from app.api.deps import CurrentUser, UploadServiceDep
from app.api.schemas.upload import UploadResponse
router = APIRouter(prefix="/upload", tags=["upload"])
@router.post("")
async def upload_file() -> Any: ...
@router.post("", response_model=UploadResponse)
async def upload_file(
file: Annotated[UploadFile, File()],
current_user: CurrentUser,
service: UploadServiceDep,
) -> UploadResponse:
result = await service.handle_upload(upload=file, user=current_user)
return UploadResponse(
track_id=result.track_id,
title=result.title,
already_exists=result.already_exists,
)