0bb752f582
Resolve, store and serve album cover art.
Sources (tag-first, mirroring enrichment): embedded artwork extracted
offline via mutagen (ID3 APIC / FLAC+OGG Picture / MP4 covr), then Cover
Art Archive by release-group MBID as a network fallback. Resolution runs
inside MetadataEnrichmentService after album resolution, only when the
album has no cover yet (idempotent, never overwrites), and is best-effort
so a cover failure never affects enrichment status.
- CoverArt value object + CoverArtExtractor/CoverArtProvider ports
- MutagenCoverExtractor + CoverArtArchiveClient adapters
- AcoustID parser now captures release_group_mbid
- Covers stored via FileStorage at covers/{album_id}.{ext} (local + S3)
- AlbumRepository.set_cover_path
- Serve real covers: GET /api/v1/albums|tracks/{id}/cover (StreamUser,
?token=), Subsonic getCoverArt (placeholder fallback)
- has_cover flag on AlbumOut/TrackOut
- coverart_enabled / coverart_base_url settings
- tests: cover resolution units + release_group parse + DB-backed
test_cover_api.py (139 green via make test-api)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
5.3 KiB
Python
156 lines
5.3 KiB
Python
"""S3FileStorage — stores files in any S3-compatible object store."""
|
|
|
|
import tempfile
|
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aioboto3
|
|
import anyio
|
|
from botocore.exceptions import ClientError
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
def _not_found(key: str, exc: Exception) -> StorageError:
|
|
err = StorageError(f"Object not found: {key}")
|
|
err.__cause__ = exc
|
|
return err
|
|
|
|
|
|
def _is_404(exc: ClientError) -> bool:
|
|
return exc.response["Error"]["Code"] in ("404", "NoSuchKey")
|
|
|
|
|
|
class S3FileStorage:
|
|
def __init__(
|
|
self,
|
|
bucket: str,
|
|
*,
|
|
endpoint_url: str | None = None,
|
|
region_name: str | None = None,
|
|
access_key: str | None = None,
|
|
secret_key: str | None = None,
|
|
) -> None:
|
|
self._bucket = bucket
|
|
self._endpoint_url = endpoint_url
|
|
self._session: Any = aioboto3.Session(
|
|
aws_access_key_id=access_key,
|
|
aws_secret_access_key=secret_key,
|
|
region_name=region_name,
|
|
)
|
|
|
|
def _client(self) -> Any:
|
|
return self._session.client("s3", endpoint_url=self._endpoint_url)
|
|
|
|
async def save_file(self, key: str, src_path: Path) -> int:
|
|
async with await anyio.open_file(src_path, "rb") as f:
|
|
content: bytes = await f.read()
|
|
async with self._client() as s3:
|
|
await s3.put_object(Bucket=self._bucket, Key=key, Body=content)
|
|
return len(content)
|
|
|
|
async def open_range(
|
|
self, key: str, start: int, end: int | None
|
|
) -> tuple[AsyncIterator[bytes], int]:
|
|
async with self._client() as s3:
|
|
try:
|
|
head = await s3.head_object(Bucket=self._bucket, Key=key)
|
|
except ClientError as exc:
|
|
if _is_404(exc):
|
|
raise _not_found(key, exc) from exc
|
|
raise StorageError(str(exc)) from exc
|
|
total_size: int = head["ContentLength"]
|
|
|
|
range_header = f"bytes={start}-{end}" if end is not None else f"bytes={start}-"
|
|
_bucket = self._bucket
|
|
_key = key
|
|
|
|
async def _stream() -> AsyncGenerator[bytes]:
|
|
async with self._client() as s3:
|
|
try:
|
|
resp = await s3.get_object(Bucket=_bucket, Key=_key, Range=range_header)
|
|
except ClientError as exc:
|
|
raise StorageError(str(exc)) from exc
|
|
body = resp["Body"]
|
|
while True:
|
|
chunk: bytes = await body.read(65536)
|
|
if not chunk:
|
|
break
|
|
yield chunk
|
|
|
|
return _stream(), total_size
|
|
|
|
async def stat(self, key: str) -> ObjectStat:
|
|
async with self._client() as s3:
|
|
try:
|
|
head = await s3.head_object(Bucket=self._bucket, Key=key)
|
|
except ClientError as exc:
|
|
if _is_404(exc):
|
|
raise _not_found(key, exc) from exc
|
|
raise StorageError(str(exc)) from exc
|
|
ext = Path(key).suffix.lower().lstrip(".")
|
|
return ObjectStat(
|
|
size=head["ContentLength"],
|
|
content_type=head.get("ContentType") or _EXT_CONTENT_TYPE.get(ext),
|
|
)
|
|
|
|
async def exists(self, key: str) -> bool:
|
|
async with self._client() as s3:
|
|
try:
|
|
await s3.head_object(Bucket=self._bucket, Key=key)
|
|
return True
|
|
except ClientError as exc:
|
|
if _is_404(exc):
|
|
return False
|
|
raise StorageError(str(exc)) from exc
|
|
|
|
async def delete(self, key: str) -> None:
|
|
async with self._client() as s3:
|
|
try:
|
|
await s3.delete_object(Bucket=self._bucket, Key=key)
|
|
except ClientError as exc:
|
|
raise StorageError(str(exc)) from exc
|
|
|
|
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]:
|
|
suffix = Path(key).suffix
|
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
|
tmp_path = Path(f.name)
|
|
try:
|
|
async with self._client() as s3:
|
|
try:
|
|
resp = await s3.get_object(Bucket=self._bucket, Key=key)
|
|
except ClientError as exc:
|
|
if _is_404(exc):
|
|
raise _not_found(key, exc) from exc
|
|
raise StorageError(str(exc)) from exc
|
|
async with await anyio.open_file(tmp_path, "wb") as out:
|
|
body = resp["Body"]
|
|
while True:
|
|
chunk: bytes = await body.read(65536)
|
|
if not chunk:
|
|
break
|
|
await out.write(chunk)
|
|
yield tmp_path
|
|
finally:
|
|
await anyio.Path(tmp_path).unlink(missing_ok=True)
|