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:
@@ -0,0 +1,45 @@
|
||||
"""Storage / library statistics response schemas (§A6)."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DiskUsageOut(BaseModel):
|
||||
total: int
|
||||
used: int
|
||||
free: int
|
||||
|
||||
|
||||
class FormatBreakdownOut(BaseModel):
|
||||
file_format: str
|
||||
track_count: int
|
||||
total_size: int
|
||||
|
||||
|
||||
class GenreCountOut(BaseModel):
|
||||
genre: str
|
||||
track_count: int
|
||||
|
||||
|
||||
class StorageStatsOut(BaseModel):
|
||||
"""Everything the Storage screen needs in a single call."""
|
||||
|
||||
# library catalogue
|
||||
total_tracks: int
|
||||
total_artists: int
|
||||
total_albums: int
|
||||
total_size: int
|
||||
total_duration_seconds: int
|
||||
largest_track_size: int
|
||||
earliest_added: dt.datetime | None
|
||||
latest_added: dt.datetime | None
|
||||
|
||||
# breakdowns
|
||||
by_format: list[FormatBreakdownOut]
|
||||
by_metadata_status: dict[str, int]
|
||||
by_source: dict[str, int]
|
||||
top_genres: list[GenreCountOut]
|
||||
|
||||
# backing volume (``None`` for object-store backends)
|
||||
disk: DiskUsageOut | None
|
||||
+60
-1
@@ -4,11 +4,70 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
FileStorageDep,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.storage import (
|
||||
DiskUsageOut,
|
||||
FormatBreakdownOut,
|
||||
GenreCountOut,
|
||||
StorageStatsOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/storage", tags=["storage"])
|
||||
|
||||
# How many of the most common genres the dashboard surfaces.
|
||||
_TOP_GENRES = 8
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_storage_stats() -> Any: ...
|
||||
async def get_storage_stats(
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
storage: FileStorageDep,
|
||||
_: CurrentUser,
|
||||
) -> StorageStatsOut:
|
||||
"""Library + disk statistics for the Storage dashboard (§A6).
|
||||
|
||||
Aggregates come from the catalogue (cheap GROUP BYs); ``disk`` reflects the
|
||||
real backing volume and is ``None`` for backends without a fixed-capacity
|
||||
disk (e.g. object stores)."""
|
||||
stats = await track_repo.library_stats()
|
||||
total_artists = await artist_repo.count(q=None)
|
||||
total_albums = await album_repo.count(artist_id=None, q=None)
|
||||
genres = await track_repo.genres()
|
||||
disk = await storage.disk_usage()
|
||||
|
||||
return StorageStatsOut(
|
||||
total_tracks=stats.total_tracks,
|
||||
total_artists=total_artists,
|
||||
total_albums=total_albums,
|
||||
total_size=stats.total_size,
|
||||
total_duration_seconds=stats.total_duration_seconds,
|
||||
largest_track_size=stats.largest_track_size,
|
||||
earliest_added=stats.earliest_added,
|
||||
latest_added=stats.latest_added,
|
||||
by_format=[
|
||||
FormatBreakdownOut(
|
||||
file_format=f.file_format,
|
||||
track_count=f.track_count,
|
||||
total_size=f.total_size,
|
||||
)
|
||||
for f in stats.by_format
|
||||
],
|
||||
by_metadata_status=stats.by_metadata_status,
|
||||
by_source=stats.by_source,
|
||||
top_genres=[
|
||||
GenreCountOut(genre=genre, track_count=count)
|
||||
for genre, count in genres[:_TOP_GENRES]
|
||||
],
|
||||
disk=DiskUsageOut(total=disk.total, used=disk.used, free=disk.free) if disk else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/duplicates")
|
||||
|
||||
@@ -6,7 +6,12 @@ from app.domain.entities.history import PlayHistoryEntry
|
||||
from app.domain.entities.like import Like
|
||||
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
|
||||
from app.domain.entities.playlist import Playlist
|
||||
from app.domain.entities.storage import ObjectStat
|
||||
from app.domain.entities.storage import (
|
||||
DiskUsage,
|
||||
FormatBreakdown,
|
||||
LibraryStats,
|
||||
ObjectStat,
|
||||
)
|
||||
from app.domain.entities.track import Artist, Track
|
||||
from app.domain.entities.user import Credentials, SubsonicCredentials, User
|
||||
|
||||
@@ -16,7 +21,10 @@ __all__ = [
|
||||
"AudioTags",
|
||||
"CoverArt",
|
||||
"Credentials",
|
||||
"DiskUsage",
|
||||
"Fingerprint",
|
||||
"FormatBreakdown",
|
||||
"LibraryStats",
|
||||
"Like",
|
||||
"ObjectStat",
|
||||
"PlayHistoryEntry",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Value objects for file storage."""
|
||||
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -7,3 +8,39 @@ from dataclasses import dataclass
|
||||
class ObjectStat:
|
||||
size: int
|
||||
content_type: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DiskUsage:
|
||||
"""Capacity of the volume backing the media store. ``None`` for backends
|
||||
(e.g. object stores) that expose no notion of total disk capacity."""
|
||||
|
||||
total: int
|
||||
used: int
|
||||
free: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormatBreakdown:
|
||||
"""Per-container-format slice of the library (e.g. ``flac`` → 312 tracks)."""
|
||||
|
||||
file_format: str
|
||||
track_count: int
|
||||
total_size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LibraryStats:
|
||||
"""Aggregate facts about everything the instance has stored. Computed from
|
||||
the catalogue (DB), not the filesystem — ``total_size`` is the sum of the
|
||||
recorded ``file_size`` of every track."""
|
||||
|
||||
total_tracks: int
|
||||
total_size: int
|
||||
total_duration_seconds: int
|
||||
by_format: list[FormatBreakdown]
|
||||
by_metadata_status: dict[str, int]
|
||||
by_source: dict[str, int]
|
||||
largest_track_size: int
|
||||
earliest_added: dt.datetime | None
|
||||
latest_added: dt.datetime | None
|
||||
|
||||
+10
-2
@@ -17,7 +17,9 @@ from app.domain.entities import (
|
||||
AudioTags,
|
||||
CoverArt,
|
||||
Credentials,
|
||||
DiskUsage,
|
||||
Fingerprint,
|
||||
LibraryStats,
|
||||
Like,
|
||||
ObjectStat,
|
||||
PlayHistoryEntry,
|
||||
@@ -98,6 +100,10 @@ class FileStorage(Protocol):
|
||||
async def exists(self, key: str) -> bool: ...
|
||||
async def delete(self, key: str) -> None: ...
|
||||
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]: ...
|
||||
async def disk_usage(self) -> DiskUsage | None:
|
||||
"""Capacity of the volume backing the store, or ``None`` when the
|
||||
backend has no addressable disk (e.g. an object store)."""
|
||||
...
|
||||
|
||||
|
||||
class ArtistRepository(Protocol):
|
||||
@@ -128,9 +134,11 @@ class TrackRepository(Protocol):
|
||||
added_by: uuid.UUID | None,
|
||||
) -> Track: ...
|
||||
async def delete(self, track_id: uuid.UUID) -> None: ...
|
||||
# genres must come before ``list`` — the method named ``list`` shadows the
|
||||
# builtin in later annotations (same pattern as AlbumRepository below).
|
||||
# genres / library_stats must come before ``list`` — the method named
|
||||
# ``list`` shadows the builtin in later annotations (same pattern as
|
||||
# AlbumRepository below).
|
||||
async def genres(self) -> list[tuple[str, int]]: ...
|
||||
async def library_stats(self) -> LibraryStats: ...
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -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