feat: local storage logic & endpoints
This commit is contained in:
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""File storage adapters."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user