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
@@ -1,8 +1,15 @@
"""SQLAlchemy repository adapters implementing the domain ports."""
from app.infrastructure.db.repositories.artist_repository import SqlAlchemyArtistRepository
from app.infrastructure.db.repositories.refresh_token_repository import (
SqlAlchemyRefreshTokenRepository,
)
from app.infrastructure.db.repositories.track_repository import SqlAlchemyTrackRepository
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
__all__ = ["SqlAlchemyRefreshTokenRepository", "SqlAlchemyUserRepository"]
__all__ = [
"SqlAlchemyArtistRepository",
"SqlAlchemyRefreshTokenRepository",
"SqlAlchemyTrackRepository",
"SqlAlchemyUserRepository",
]
@@ -0,0 +1,32 @@
"""Artist repository — adapter over ``AsyncSession``."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.track import Artist
from app.infrastructure.db.models.artist import ArtistModel
def _to_entity(row: ArtistModel) -> Artist:
return Artist(
id=row.id,
name=row.name,
created_at=row.created_at,
updated_at=row.updated_at,
)
class SqlAlchemyArtistRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_or_create(self, name: str) -> Artist:
row = (
await self._session.execute(select(ArtistModel).where(ArtistModel.name == name))
).scalar_one_or_none()
if row is None:
row = ArtistModel(name=name)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
@@ -0,0 +1,83 @@
"""Track repository — adapter over ``AsyncSession``."""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.track import Track
from app.infrastructure.db.models.track import TrackModel
def _to_entity(row: TrackModel) -> Track:
return Track(
id=row.id,
title=row.title,
artist_id=row.artist_id,
file_path=row.file_path,
file_format=row.file_format,
file_size=row.file_size,
source=row.source,
source_id=row.source_id,
duration_seconds=row.duration_seconds,
metadata_status=row.metadata_status,
created_at=row.created_at,
updated_at=row.updated_at,
)
class SqlAlchemyTrackRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_id(self, track_id: uuid.UUID) -> Track | None:
row = await self._session.get(TrackModel, track_id)
return _to_entity(row) if row is not None else None
async def get_by_source(self, source: str, source_id: str) -> Track | None:
row = (
await self._session.execute(
select(TrackModel).where(
TrackModel.source == source,
TrackModel.source_id == source_id,
)
)
).scalar_one_or_none()
return _to_entity(row) if row is not None else None
async def add(
self,
*,
id: uuid.UUID,
title: str,
artist_id: uuid.UUID,
file_path: str,
file_format: str,
file_size: int,
source: str,
source_id: str,
metadata_status: str,
added_by: uuid.UUID | None,
) -> Track:
row = TrackModel(
id=id,
title=title,
artist_id=artist_id,
file_path=file_path,
file_format=file_format,
file_size=file_size,
source=source,
source_id=source_id,
metadata_status=metadata_status,
added_by=added_by,
)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def delete(self, track_id: uuid.UUID) -> None:
row = await self._session.get(TrackModel, track_id)
if row is not None:
await self._session.delete(row)
await self._session.flush()
+1
View File
@@ -0,0 +1 @@
"""File storage adapters."""
+86
View File
@@ -0,0 +1,86 @@
"""LocalFileStorage — stores files on the local filesystem."""
import os
import shutil
from collections.abc import AsyncGenerator, AsyncIterator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from pathlib import Path
import anyio
from app.domain.entities.storage import ObjectStat
from app.domain.errors import StorageError
_EXT_CONTENT_TYPE: dict[str, str] = {
"mp3": "audio/mpeg",
"flac": "audio/flac",
"m4a": "audio/mp4",
"aac": "audio/aac",
"ogg": "audio/ogg",
"opus": "audio/ogg",
"wav": "audio/wav",
"wma": "audio/x-ms-wma",
"aiff": "audio/aiff",
"aif": "audio/aiff",
}
class LocalFileStorage:
def __init__(self, media_path: Path) -> None:
self._media_path = media_path
async def save_file(self, key: str, src_path: Path) -> int:
dest = self._media_path / key
dest.parent.mkdir(parents=True, exist_ok=True)
part = dest.with_suffix(dest.suffix + ".part")
shutil.copyfile(str(src_path), str(part))
os.replace(str(part), str(dest))
return dest.stat().st_size
async def open_range(
self, key: str, start: int, end: int | None
) -> tuple[AsyncIterator[bytes], int]:
path = self._media_path / key
if not path.exists():
raise StorageError(f"Object not found: {key}")
total_size = path.stat().st_size
_start = start
_end = end
_total_size = total_size
_path = path
async def _iter() -> AsyncGenerator[bytes]:
async with await anyio.open_file(_path, "rb") as f:
await f.seek(_start)
remaining = (_end - _start + 1) if _end is not None else (_total_size - _start)
while remaining > 0:
chunk: bytes = await f.read(min(65536, remaining))
if not chunk:
break
yield chunk
remaining -= len(chunk)
aiter: AsyncIterator[bytes] = _iter()
return aiter, total_size
async def stat(self, key: str) -> ObjectStat:
path = self._media_path / key
if not path.exists():
raise StorageError(f"Object not found: {key}")
st = path.stat()
ext = path.suffix.lower().lstrip(".")
return ObjectStat(size=st.st_size, content_type=_EXT_CONTENT_TYPE.get(ext))
async def exists(self, key: str) -> bool:
return (self._media_path / key).exists()
async def delete(self, key: str) -> None:
(self._media_path / key).unlink(missing_ok=True)
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
return self._as_local_path_cm(key)
@asynccontextmanager
async def _as_local_path_cm(self, key: str) -> AsyncGenerator[Path]:
yield self._media_path / key
+16
View File
@@ -0,0 +1,16 @@
"""File storage provider — singleton factory."""
from app.core.config import get_settings
from app.infrastructure.storage.local import LocalFileStorage
_storage: LocalFileStorage | None = None
def get_file_storage() -> LocalFileStorage:
global _storage
if _storage is None:
settings = get_settings()
if settings.storage_backend == "s3":
raise NotImplementedError("S3 storage not yet implemented.")
_storage = LocalFileStorage(settings.media_path)
return _storage