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:
@@ -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