feat(storage): library + disk statistics endpoint (§A6)
Implement `GET /api/v1/storage`, replacing the stub. Returns aggregate library facts (track/artist/album counts, total footprint, playtime, per-format / per-source / metadata-status breakdowns, top genres) plus the real capacity of the backing volume. - domain: `LibraryStats`, `FormatBreakdown`, `DiskUsage` value objects - ports: `FileStorage.disk_usage()` (local = shutil.disk_usage walking up to the nearest existing ancestor; S3 returns None — no fixed disk) - repo: `TrackRepository.library_stats()` (single set of GROUP BYs) - tests: storage stats API (auth, empty library, upload counting) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import uuid
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.storage import FormatBreakdown, LibraryStats
|
||||
from app.domain.entities.track import Track
|
||||
from app.domain.errors import NotFoundError
|
||||
from app.infrastructure.db.models.artist import ArtistModel
|
||||
@@ -106,6 +107,63 @@ class SqlAlchemyTrackRepository:
|
||||
).all()
|
||||
return [(row.genre, row.cnt) for row in rows]
|
||||
|
||||
async def library_stats(self) -> LibraryStats:
|
||||
"""One-shot aggregate over the whole catalogue (no pagination). Defined
|
||||
before ``list`` for the same shadowing reason as ``genres``."""
|
||||
totals = (
|
||||
await self._session.execute(
|
||||
select(
|
||||
func.count(TrackModel.id),
|
||||
func.coalesce(func.sum(TrackModel.file_size), 0),
|
||||
func.coalesce(func.sum(TrackModel.duration_seconds), 0),
|
||||
func.coalesce(func.max(TrackModel.file_size), 0),
|
||||
func.min(TrackModel.created_at),
|
||||
func.max(TrackModel.created_at),
|
||||
)
|
||||
)
|
||||
).one()
|
||||
|
||||
fmt_rows = (
|
||||
await self._session.execute(
|
||||
select(
|
||||
TrackModel.file_format,
|
||||
func.count(TrackModel.id),
|
||||
func.coalesce(func.sum(TrackModel.file_size), 0),
|
||||
)
|
||||
.group_by(TrackModel.file_format)
|
||||
.order_by(func.sum(TrackModel.file_size).desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
status_rows = (
|
||||
await self._session.execute(
|
||||
select(TrackModel.metadata_status, func.count(TrackModel.id)).group_by(
|
||||
TrackModel.metadata_status
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
source_rows = (
|
||||
await self._session.execute(
|
||||
select(TrackModel.source, func.count(TrackModel.id)).group_by(TrackModel.source)
|
||||
)
|
||||
).all()
|
||||
|
||||
return LibraryStats(
|
||||
total_tracks=totals[0],
|
||||
total_size=totals[1],
|
||||
total_duration_seconds=totals[2],
|
||||
largest_track_size=totals[3],
|
||||
earliest_added=totals[4],
|
||||
latest_added=totals[5],
|
||||
by_format=[
|
||||
FormatBreakdown(file_format=fmt, track_count=cnt, total_size=size)
|
||||
for fmt, cnt, size in fmt_rows
|
||||
],
|
||||
by_metadata_status={status: cnt for status, cnt in status_rows},
|
||||
by_source={source: cnt for source, cnt in source_rows},
|
||||
)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.domain.entities.storage import ObjectStat
|
||||
from app.domain.entities.storage import DiskUsage, ObjectStat
|
||||
from app.domain.errors import StorageError
|
||||
|
||||
_EXT_CONTENT_TYPE: dict[str, str] = {
|
||||
@@ -78,6 +78,15 @@ class LocalFileStorage:
|
||||
async def delete(self, key: str) -> None:
|
||||
(self._media_path / key).unlink(missing_ok=True)
|
||||
|
||||
async def disk_usage(self) -> DiskUsage | None:
|
||||
# The media root may not exist yet on a fresh instance — walk up to the
|
||||
# nearest existing ancestor so we still report the underlying volume.
|
||||
path = self._media_path
|
||||
while not path.exists() and path != path.parent:
|
||||
path = path.parent
|
||||
usage = await anyio.to_thread.run_sync(shutil.disk_usage, str(path))
|
||||
return DiskUsage(total=usage.total, used=usage.used, free=usage.free)
|
||||
|
||||
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
|
||||
return self._as_local_path_cm(key)
|
||||
|
||||
|
||||
@@ -127,6 +127,10 @@ class S3FileStorage:
|
||||
except ClientError as exc:
|
||||
raise StorageError(str(exc)) from exc
|
||||
|
||||
async def disk_usage(self) -> None:
|
||||
# Object stores have no fixed-capacity volume to report.
|
||||
return None
|
||||
|
||||
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
|
||||
return self._as_local_path_cm(key)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user