Compare commits
24 Commits
87b48e941e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e45e578f54 | |||
| 58b98ab5ed | |||
| 78007461e1 | |||
| ea880edd57 | |||
| fa23568214 | |||
| 636820afb8 | |||
| 63c7d05eca | |||
| 73d7da440f | |||
| 30cb8901f2 | |||
| 0bb752f582 | |||
| c7e078d758 | |||
| 356cd00772 | |||
| 14c1bc16e0 | |||
| c72d19599a | |||
| 48e3418c7f | |||
| 551afbab13 | |||
| b975164fc2 | |||
| 7a17e3babd | |||
| 4ade6939b6 | |||
| 5c5df5d3cc | |||
| a8348e145a | |||
| 7c920f38f6 | |||
| 81ea93c371 | |||
| dfd512a13f |
+14
-1
@@ -16,14 +16,27 @@ REDIS_URL=redis://localhost:6379/0
|
||||
JWT_SECRET=change-me-in-prod
|
||||
ACCESS_TOKEN_TTL_SECONDS=900
|
||||
REFRESH_TOKEN_TTL_SECONDS=2592000
|
||||
# Public self-service sign-up (POST /auth/register). Set to false to make
|
||||
# accounts admin-only. Registered users are never superusers.
|
||||
ALLOW_REGISTRATION=true
|
||||
|
||||
# subsonic — key that encrypts per-user Subsonic app-passwords at rest.
|
||||
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
|
||||
# invalidates all stored app-passwords. NOTE: /rest must be served over HTTPS.
|
||||
SUBSONIC_SECRET_KEY=change-me-subsonic-key
|
||||
|
||||
# media / storage
|
||||
MEDIA_PATH=/data/media
|
||||
TRANSCODE_CACHE_PATH=/data/transcode-cache
|
||||
MAX_PARALLEL_DOWNLOADS=2
|
||||
|
||||
# sources — mounted folder the `local` source indexes (copies into MEDIA_PATH).
|
||||
# Unset → the local source is not registered. Mount read-only in compose.
|
||||
# LOCAL_MEDIA_IMPORT_PATH=/import
|
||||
|
||||
# external services (all optional — backend degrades gracefully if unset)
|
||||
# ML_SERVICE_URL=http://ml:9000
|
||||
# ACOUSTID_API_KEY=
|
||||
MUSICBRAINZ_USER_AGENT=mcma-backend/0.1.0 ( https://github.com/your/repo )
|
||||
# Sent to MusicBrainz/AcoustID as part of the User-Agent (MCMA/<version> ( <email> )).
|
||||
# MUSICBRAINZ_OWNER_EMAIL=you@example.com
|
||||
# YOUTUBE_COOKIES_PATH=/data/cookies.txt
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Docker Build & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Number of tagged (non-latest) versions to keep per image name.
|
||||
KEEP_VERSIONS: "5"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
host: ${{ steps.meta.outputs.host }}
|
||||
image: ${{ steps.meta.outputs.image }}
|
||||
sha: ${{ steps.meta.outputs.sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve registry metadata
|
||||
id: meta
|
||||
run: |
|
||||
host=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||; s|/$||')
|
||||
repo_lc=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
echo "host=$host" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$host/$repo_lc" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: dockerfiles/Dockerfile.prod
|
||||
push: false
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:latest
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
|
||||
outputs: type=docker,dest=/tmp/image.tar
|
||||
|
||||
- name: Upload image artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp/image.tar
|
||||
retention-days: 1
|
||||
|
||||
push:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download image artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
|
||||
- name: Load image
|
||||
run: docker load < /tmp/image.tar
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ needs.build.outputs.host }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
docker push ${{ needs.build.outputs.image }}:latest
|
||||
docker push ${{ needs.build.outputs.image }}:${{ needs.build.outputs.sha }}
|
||||
|
||||
cleanup:
|
||||
name: Prune old image versions
|
||||
needs: push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete versions beyond KEEP_VERSIONS
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
OWNER: ${{ gitea.repository_owner }}
|
||||
IMAGE: ${{ gitea.event.repository.name }}
|
||||
TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
image=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
response=$(curl -sf \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"${GITEA_URL}/api/v1/packages/${OWNER}?type=container&limit=50&q=${image}")
|
||||
|
||||
to_delete=$(printf '%s' "$response" \
|
||||
| jq -r \
|
||||
--arg name "$image" \
|
||||
--argjson keep "$KEEP_VERSIONS" \
|
||||
'[.[] | select(.name == $name and .version != "latest")]
|
||||
| sort_by(.created) | reverse
|
||||
| .[$keep:][].version')
|
||||
|
||||
if [ -z "$to_delete" ]; then
|
||||
echo "Nothing to prune."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
while IFS= read -r version; do
|
||||
echo "Deleting ${image}:${version}"
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"${GITEA_URL}/api/v1/packages/${OWNER}/container/${image}/${version}" \
|
||||
&& echo " ok" || echo " failed (may already be gone, continuing)"
|
||||
done <<< "$to_delete"
|
||||
@@ -70,3 +70,57 @@ The DB URL is injected from app settings — never hardcoded in `alembic.ini`.
|
||||
All settings come from environment variables (or `.env` in dev). See
|
||||
[`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz)
|
||||
are **optional** — the backend degrades gracefully when they are absent.
|
||||
|
||||
## Sources & importing music
|
||||
|
||||
Music enters the library through **source backends** (`app/infrastructure/sources`),
|
||||
selected via a registry. The first backend is **`local`** — it indexes a mounted
|
||||
folder, copying each audio file into managed storage and creating a track
|
||||
(`metadata_status=pending`; real metadata is filled later by enrichment).
|
||||
|
||||
```bash
|
||||
# point the instance at an existing library (mount read-only in compose)
|
||||
LOCAL_MEDIA_IMPORT_PATH=/import
|
||||
|
||||
GET /api/v1/sources # list configured sources + availability
|
||||
POST /api/v1/sources/local/scan # admin: enqueue an import (runs in the worker)
|
||||
GET /api/v1/sources/local/health # availability check
|
||||
```
|
||||
|
||||
Scanning is a background job (arq worker) — the endpoint only enqueues it; the
|
||||
walk + file copies never run in the request cycle. Re-scans are idempotent
|
||||
(dedup on `(source, source_id)`, where `source_id` is the path within the root).
|
||||
|
||||
## Subsonic API (`/rest`)
|
||||
|
||||
A Subsonic-compatible API is mounted at `/rest`, so standard clients (Symfonium,
|
||||
DSub, play:Sub, …) can browse the library and stream. It is a thin adapter over
|
||||
the native services — it adds no business logic of its own.
|
||||
|
||||
**HTTPS is mandatory.** Subsonic authentication puts the credential in the URL
|
||||
(`t=md5(password+salt)&s=…`, or the legacy `p=`), so `/rest` must only ever be
|
||||
exposed behind TLS (terminate at the reverse proxy). Never serve it over plain
|
||||
HTTP.
|
||||
|
||||
### App-passwords
|
||||
|
||||
Subsonic auth needs a recoverable secret, but login passwords are stored as a
|
||||
one-way argon2 hash. So Subsonic clients authenticate against a separate,
|
||||
per-user **app-password** — high-entropy, random, and encrypted at rest with a
|
||||
key derived from `SUBSONIC_SECRET_KEY` (set this to a strong random string in
|
||||
prod; rotating it invalidates all stored app-passwords).
|
||||
|
||||
Self-service lifecycle (native API, needs a normal JWT login):
|
||||
|
||||
```bash
|
||||
GET /api/v1/users/me/subsonic-password # reveal (generated lazily on first read)
|
||||
POST /api/v1/users/me/subsonic-password # rotate
|
||||
# admin, for any user:
|
||||
POST /api/v1/admin/users/{user_id}/subsonic-password
|
||||
```
|
||||
|
||||
Point the client at the instance URL, use your **username** + the revealed
|
||||
**app-password** (not your login password).
|
||||
|
||||
> **Cover art** (`getCoverArt`) currently returns a placeholder — the cover
|
||||
> pipeline (`/api/v1/.../cover` endpoints) is not implemented yet.
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""music schema
|
||||
|
||||
Revision ID: e670d6c41d0c
|
||||
Revises: 0001_auth_users
|
||||
Create Date: 2026-06-07 11:37:41.420644
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "e670d6c41d0c"
|
||||
down_revision: str | None = "0001_auth_users"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"artists",
|
||||
sa.Column("name", sa.String(length=512), nullable=False),
|
||||
sa.Column("musicbrainz_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_artists")),
|
||||
)
|
||||
op.create_index(op.f("ix_artists_musicbrainz_id"), "artists", ["musicbrainz_id"], unique=False)
|
||||
op.create_index(op.f("ix_artists_name"), "artists", ["name"], unique=False)
|
||||
op.create_table(
|
||||
"albums",
|
||||
sa.Column("title", sa.String(length=1024), nullable=False),
|
||||
sa.Column("artist_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
sa.Column("cover_path", sa.String(length=1024), nullable=True),
|
||||
sa.Column("musicbrainz_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"],
|
||||
["artists.id"],
|
||||
name=op.f("fk_albums_artist_id_artists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_albums")),
|
||||
)
|
||||
op.create_index(op.f("ix_albums_artist_id"), "albums", ["artist_id"], unique=False)
|
||||
op.create_index(op.f("ix_albums_musicbrainz_id"), "albums", ["musicbrainz_id"], unique=False)
|
||||
op.create_index(op.f("ix_albums_title"), "albums", ["title"], unique=False)
|
||||
op.create_table(
|
||||
"download_jobs",
|
||||
sa.Column("source", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=512), nullable=True),
|
||||
sa.Column("query", sa.String(length=1024), nullable=True),
|
||||
sa.Column("requested_by", sa.Uuid(), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("progress", sa.Float(), nullable=False),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["requested_by"],
|
||||
["users.id"],
|
||||
name=op.f("fk_download_jobs_requested_by_users"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_download_jobs")),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_download_jobs_requested_by"), "download_jobs", ["requested_by"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_download_jobs_status"), "download_jobs", ["status"], unique=False)
|
||||
op.create_table(
|
||||
"playlists",
|
||||
sa.Column("name", sa.String(length=512), nullable=False),
|
||||
sa.Column("description", sa.String(length=2048), nullable=True),
|
||||
sa.Column("owner_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("cover_path", sa.String(length=1024), nullable=True),
|
||||
sa.Column("version", sa.Integer(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["owner_id"], ["users.id"], name=op.f("fk_playlists_owner_id_users"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_playlists")),
|
||||
)
|
||||
op.create_index(op.f("ix_playlists_owner_id"), "playlists", ["owner_id"], unique=False)
|
||||
op.create_table(
|
||||
"tracks",
|
||||
sa.Column("title", sa.String(length=1024), nullable=False),
|
||||
sa.Column("artist_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("album_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("track_number", sa.Integer(), nullable=True),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("genre", sa.String(length=255), nullable=True),
|
||||
sa.Column("year", sa.Integer(), nullable=True),
|
||||
sa.Column("file_path", sa.String(length=2048), nullable=False),
|
||||
sa.Column("file_format", sa.String(length=32), nullable=False),
|
||||
sa.Column("file_size", sa.Integer(), nullable=False),
|
||||
sa.Column("bitrate", sa.Integer(), nullable=True),
|
||||
sa.Column("acoustid_fingerprint", sa.String(length=64), nullable=True),
|
||||
sa.Column("musicbrainz_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("source", sa.String(length=32), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=512), nullable=False),
|
||||
sa.Column("is_replaceable", sa.Boolean(), nullable=False),
|
||||
sa.Column("storage_policy", sa.String(length=16), nullable=False),
|
||||
sa.Column("metadata_status", sa.String(length=16), nullable=False),
|
||||
sa.Column("added_by", sa.Uuid(), nullable=True),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["added_by"], ["users.id"], name=op.f("fk_tracks_added_by_users"), ondelete="SET NULL"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["album_id"], ["albums.id"], name=op.f("fk_tracks_album_id_albums"), ondelete="SET NULL"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["artist_id"],
|
||||
["artists.id"],
|
||||
name=op.f("fk_tracks_artist_id_artists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_tracks")),
|
||||
sa.UniqueConstraint("source", "source_id", name="uq_tracks_source_source_id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_tracks_acoustid_fingerprint"), "tracks", ["acoustid_fingerprint"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_tracks_added_by"), "tracks", ["added_by"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_album_id"), "tracks", ["album_id"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_artist_id"), "tracks", ["artist_id"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_genre"), "tracks", ["genre"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_musicbrainz_id"), "tracks", ["musicbrainz_id"], unique=False)
|
||||
op.create_index(op.f("ix_tracks_title"), "tracks", ["title"], unique=False)
|
||||
op.create_table(
|
||||
"likes",
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("value", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"], ["tracks.id"], name=op.f("fk_likes_track_id_tracks"), ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"], name=op.f("fk_likes_user_id_users"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_likes")),
|
||||
)
|
||||
op.create_index("ix_likes_user_id_track_id", "likes", ["user_id", "track_id"], unique=False)
|
||||
op.create_table(
|
||||
"lyrics",
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("synced", sa.Text(), nullable=True),
|
||||
sa.Column("plain", sa.Text(), nullable=True),
|
||||
sa.Column("source", sa.String(length=64), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column(
|
||||
"fetched_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"], ["tracks.id"], name=op.f("fk_lyrics_track_id_tracks"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_lyrics")),
|
||||
sa.UniqueConstraint("track_id", name=op.f("uq_lyrics_track_id")),
|
||||
)
|
||||
op.create_table(
|
||||
"play_history",
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"played_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
|
||||
),
|
||||
sa.Column("play_duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("completed", sa.Boolean(), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"],
|
||||
["tracks.id"],
|
||||
name=op.f("fk_play_history_track_id_tracks"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_play_history_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_play_history")),
|
||||
)
|
||||
op.create_index(op.f("ix_play_history_track_id"), "play_history", ["track_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_play_history_user_id_played_at", "play_history", ["user_id", "played_at"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"playlist_tracks",
|
||||
sa.Column("playlist_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("track_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("position", sa.Float(), nullable=False),
|
||||
sa.Column(
|
||||
"added_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
|
||||
),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["playlist_id"],
|
||||
["playlists.id"],
|
||||
name=op.f("fk_playlist_tracks_playlist_id_playlists"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["track_id"],
|
||||
["tracks.id"],
|
||||
name=op.f("fk_playlist_tracks_track_id_tracks"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_playlist_tracks")),
|
||||
sa.UniqueConstraint(
|
||||
"playlist_id", "track_id", name="uq_playlist_tracks_playlist_id_track_id"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_playlist_tracks_playlist_id"), "playlist_tracks", ["playlist_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_playlist_tracks_track_id"), "playlist_tracks", ["track_id"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_playlist_tracks_track_id"), table_name="playlist_tracks")
|
||||
op.drop_index(op.f("ix_playlist_tracks_playlist_id"), table_name="playlist_tracks")
|
||||
op.drop_table("playlist_tracks")
|
||||
op.drop_index("ix_play_history_user_id_played_at", table_name="play_history")
|
||||
op.drop_index(op.f("ix_play_history_track_id"), table_name="play_history")
|
||||
op.drop_table("play_history")
|
||||
op.drop_table("lyrics")
|
||||
op.drop_index("ix_likes_user_id_track_id", table_name="likes")
|
||||
op.drop_table("likes")
|
||||
op.drop_index(op.f("ix_tracks_title"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_musicbrainz_id"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_genre"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_artist_id"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_album_id"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_added_by"), table_name="tracks")
|
||||
op.drop_index(op.f("ix_tracks_acoustid_fingerprint"), table_name="tracks")
|
||||
op.drop_table("tracks")
|
||||
op.drop_index(op.f("ix_playlists_owner_id"), table_name="playlists")
|
||||
op.drop_table("playlists")
|
||||
op.drop_index(op.f("ix_download_jobs_status"), table_name="download_jobs")
|
||||
op.drop_index(op.f("ix_download_jobs_requested_by"), table_name="download_jobs")
|
||||
op.drop_table("download_jobs")
|
||||
op.drop_index(op.f("ix_albums_title"), table_name="albums")
|
||||
op.drop_index(op.f("ix_albums_musicbrainz_id"), table_name="albums")
|
||||
op.drop_index(op.f("ix_albums_artist_id"), table_name="albums")
|
||||
op.drop_table("albums")
|
||||
op.drop_index(op.f("ix_artists_name"), table_name="artists")
|
||||
op.drop_index(op.f("ix_artists_musicbrainz_id"), table_name="artists")
|
||||
op.drop_table("artists")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,25 @@
|
||||
"""rename track file_path to storage_uri
|
||||
|
||||
Revision ID: 20260608_storage_uri
|
||||
Revises: e670d6c41d0c
|
||||
Create Date: 2026-06-08 11:32:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260608_storage_uri"
|
||||
down_revision: str | None = "e670d6c41d0c"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column("tracks", "file_path", new_column_name="storage_uri")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("tracks", "storage_uri", new_column_name="file_path")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""subsonic: per-user encrypted app-password
|
||||
|
||||
Revision ID: 20260608_subsonic_pw
|
||||
Revises: 20260608_storage_uri
|
||||
Create Date: 2026-06-08 12:00:00.000000
|
||||
|
||||
Adds ``users.subsonic_password_enc`` — the recoverable, Fernet-encrypted
|
||||
Subsonic app-password (plan §7). NULL until the user generates one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260608_subsonic_pw"
|
||||
down_revision: str | None = "20260608_storage_uri"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("subsonic_password_enc", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "subsonic_password_enc")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""tracks: enrichment outcome (error reason + completion time)
|
||||
|
||||
Revision ID: 20260613_enrich_outcome
|
||||
Revises: 20260608_subsonic_pw
|
||||
Create Date: 2026-06-13 13:00:00.000000
|
||||
|
||||
Adds ``tracks.metadata_error`` and ``tracks.enriched_at`` so a finished
|
||||
enrichment run records *why* it failed and *when* it completed. Lets the UI
|
||||
distinguish a still-pending/running track from one that is done or failed, and
|
||||
surface an actionable reason instead of a silent spinner (plan §6.2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260613_enrich_outcome"
|
||||
down_revision: str | None = "20260608_subsonic_pw"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tracks",
|
||||
sa.Column("metadata_error", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"tracks",
|
||||
sa.Column("enriched_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tracks", "enriched_at")
|
||||
op.drop_column("tracks", "metadata_error")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""download_jobs: link finished job to its imported track
|
||||
|
||||
Revision ID: 20260614_dl_track_id
|
||||
Revises: 20260613_enrich_outcome
|
||||
Create Date: 2026-06-14 10:00:00.000000
|
||||
|
||||
Adds ``download_jobs.track_id`` (nullable FK → ``tracks.id``) so a completed
|
||||
download can point at the library track it produced — the §A5 download manager
|
||||
links a "done" job to the track, and re-runs can tell a job already imported
|
||||
(plan §6.1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260614_dl_track_id"
|
||||
down_revision: str | None = "20260613_enrich_outcome"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"download_jobs",
|
||||
sa.Column("track_id", sa.Uuid(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
op.f("fk_download_jobs_track_id_tracks"),
|
||||
"download_jobs",
|
||||
"tracks",
|
||||
["track_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
op.f("fk_download_jobs_track_id_tracks"),
|
||||
"download_jobs",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("download_jobs", "track_id")
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""remote placeholders: track availability, album/artist remote ids
|
||||
|
||||
Revision ID: dc126696f5a6
|
||||
Revises: 20260614_dl_track_id
|
||||
Create Date: 2026-06-14 11:25:30.643588
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'dc126696f5a6'
|
||||
down_revision: str | None = '20260614_dl_track_id'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('albums', sa.Column('source', sa.String(length=32), nullable=True))
|
||||
op.add_column('albums', sa.Column('source_id', sa.String(length=512), nullable=True))
|
||||
op.create_unique_constraint('uq_albums_source_source_id', 'albums', ['source', 'source_id'])
|
||||
op.add_column('artists', sa.Column('source', sa.String(length=32), nullable=True))
|
||||
op.add_column('artists', sa.Column('source_id', sa.String(length=512), nullable=True))
|
||||
op.create_unique_constraint('uq_artists_source_source_id', 'artists', ['source', 'source_id'])
|
||||
op.add_column(
|
||||
'tracks',
|
||||
sa.Column('availability', sa.String(length=16), nullable=False, server_default='local'),
|
||||
)
|
||||
op.alter_column('tracks', 'availability', server_default=None)
|
||||
op.alter_column('tracks', 'storage_uri',
|
||||
existing_type=sa.VARCHAR(length=2048),
|
||||
nullable=True)
|
||||
op.alter_column('tracks', 'file_format',
|
||||
existing_type=sa.VARCHAR(length=32),
|
||||
nullable=True)
|
||||
op.alter_column('tracks', 'file_size',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('tracks', 'file_size',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
op.alter_column('tracks', 'file_format',
|
||||
existing_type=sa.VARCHAR(length=32),
|
||||
nullable=False)
|
||||
op.alter_column('tracks', 'storage_uri',
|
||||
existing_type=sa.VARCHAR(length=2048),
|
||||
nullable=False)
|
||||
op.drop_column('tracks', 'availability')
|
||||
op.drop_constraint('uq_artists_source_source_id', 'artists', type_='unique')
|
||||
op.drop_column('artists', 'source_id')
|
||||
op.drop_column('artists', 'source')
|
||||
op.drop_constraint('uq_albums_source_source_id', 'albums', type_='unique')
|
||||
op.drop_column('albums', 'source_id')
|
||||
op.drop_column('albums', 'source')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Shared cover-art serving helper (presentation).
|
||||
|
||||
Streams a stored cover image from the :class:`FileStorage` port. Used by the
|
||||
native ``/api/v1`` cover endpoints and the Subsonic ``getCoverArt`` adapter so
|
||||
the streaming/content-type logic lives in one place.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.errors import NotFoundError, StorageError
|
||||
from app.domain.ports import AlbumRepository, FileStorage, TrackRepository
|
||||
|
||||
_CONTENT_TYPE_BY_EXT: dict[str, str] = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"webp": "image/webp",
|
||||
"gif": "image/gif",
|
||||
}
|
||||
|
||||
# Covers are immutable for a given album (a new cover means a new key), so let
|
||||
# clients cache aggressively.
|
||||
_CACHE_CONTROL = "public, max-age=86400"
|
||||
|
||||
|
||||
def _content_type_for(key: str) -> str:
|
||||
ext = key.rsplit(".", 1)[-1].lower() if "." in key else ""
|
||||
return _CONTENT_TYPE_BY_EXT.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
async def stream_cover(storage: FileStorage, cover_path: str) -> StreamingResponse:
|
||||
"""Stream a stored cover by its storage key. Raises ``NotFoundError`` if the
|
||||
object is missing (a dangling ``cover_path`` reads as "no cover")."""
|
||||
try:
|
||||
stream, total = await storage.open_range(cover_path, 0, None)
|
||||
except StorageError as exc:
|
||||
raise NotFoundError("Cover not found.") from exc
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=_content_type_for(cover_path),
|
||||
headers={"Content-Length": str(total), "Cache-Control": _CACHE_CONTROL},
|
||||
)
|
||||
|
||||
|
||||
async def resolve_album_for_track(
|
||||
track_repo: TrackRepository,
|
||||
album_repo: AlbumRepository,
|
||||
track_id: uuid.UUID,
|
||||
) -> Album | None:
|
||||
"""The album that owns a track (cover lives on the album), or ``None``."""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None or track.album_id is None:
|
||||
return None
|
||||
return await album_repo.get_by_id(track.album_id)
|
||||
+194
-3
@@ -10,22 +10,41 @@ from collections.abc import AsyncIterator
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.auth_service import AuthService
|
||||
from app.application.download_service import DownloadService
|
||||
from app.application.metadata_service import MetadataEnrichmentService
|
||||
from app.application.remote_library_service import RemoteLibraryService
|
||||
from app.application.streaming_service import StreamingService
|
||||
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||
from app.application.upload_service import UploadService
|
||||
from app.application.user_service import UserService
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import Argon2PasswordHasher, JwtTokenService
|
||||
from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
|
||||
from app.domain.entities import User
|
||||
from app.domain.errors import AuthenticationError, PermissionDeniedError
|
||||
from app.domain.ports import PasswordHasher, TokenService
|
||||
from app.domain.ports import FileStorage, PasswordHasher, SubsonicCipher, TokenService
|
||||
from app.infrastructure.db import get_sessionmaker
|
||||
from app.infrastructure.db.repositories import (
|
||||
SqlAlchemyAlbumRepository,
|
||||
SqlAlchemyArtistRepository,
|
||||
SqlAlchemyDownloadJobRepository,
|
||||
SqlAlchemyHistoryRepository,
|
||||
SqlAlchemyLikeRepository,
|
||||
SqlAlchemyPlaylistRepository,
|
||||
SqlAlchemyRefreshTokenRepository,
|
||||
SqlAlchemyTrackRepository,
|
||||
SqlAlchemyUserRepository,
|
||||
)
|
||||
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
|
||||
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
|
||||
from app.infrastructure.metadata.tags import MutagenTagReader
|
||||
from app.infrastructure.sources.registry import SourceRegistry, build_source_registry
|
||||
from app.infrastructure.storage.provider import get_file_storage
|
||||
from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
@@ -55,6 +74,19 @@ def get_token_service() -> TokenService:
|
||||
return JwtTokenService(get_settings())
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_subsonic_cipher() -> SubsonicCipher:
|
||||
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_source_registry() -> SourceRegistry:
|
||||
return build_source_registry(get_settings())
|
||||
|
||||
|
||||
SourceRegistryDep = Annotated[SourceRegistry, Depends(get_source_registry)]
|
||||
|
||||
|
||||
# -- request-scoped services ---------------------------------------------------
|
||||
def get_auth_service(session: SessionDep) -> AuthService:
|
||||
return AuthService(
|
||||
@@ -73,8 +105,121 @@ def get_user_service(session: SessionDep) -> UserService:
|
||||
)
|
||||
|
||||
|
||||
def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService:
|
||||
return SubsonicAuthService(
|
||||
users=SqlAlchemyUserRepository(session),
|
||||
cipher=get_subsonic_cipher(),
|
||||
)
|
||||
|
||||
|
||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
|
||||
|
||||
|
||||
# -- file storage (process-cached) ---------------------------------------------
|
||||
FileStorageDep = Annotated[FileStorage, Depends(get_file_storage)]
|
||||
|
||||
|
||||
def get_upload_service(session: SessionDep, storage: FileStorageDep) -> UploadService:
|
||||
settings = get_settings()
|
||||
return UploadService(
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
artists=SqlAlchemyArtistRepository(session),
|
||||
storage=storage,
|
||||
tmp_dir=settings.upload_tmp_dir,
|
||||
enqueue_enrich=enqueue_enrich,
|
||||
)
|
||||
|
||||
|
||||
def get_streaming_service(session: SessionDep, storage: FileStorageDep) -> StreamingService:
|
||||
return StreamingService(
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
|
||||
def get_metadata_service(session: SessionDep, storage: FileStorageDep) -> MetadataEnrichmentService:
|
||||
"""Wires the §6.2 fingerprint/AcoustID adapters for read-only, inline use
|
||||
(the metadata editor's "find matches" — §A7). The full pipeline (incl.
|
||||
cover art) stays in the worker (`tasks/enrich_task.py`)."""
|
||||
settings = get_settings()
|
||||
api_key = settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None
|
||||
acoustid = AcoustIdHttpClient(
|
||||
api_key=api_key,
|
||||
user_agent=settings.musicbrainz_user_agent,
|
||||
api_url=settings.acoustid_api_url,
|
||||
)
|
||||
return MetadataEnrichmentService(
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
artists=SqlAlchemyArtistRepository(session),
|
||||
albums=SqlAlchemyAlbumRepository(session),
|
||||
storage=storage,
|
||||
tag_reader=MutagenTagReader(),
|
||||
fingerprinter=FpcalcFingerprinter(settings.fpcalc_path),
|
||||
acoustid=acoustid,
|
||||
acoustid_trust_score=settings.acoustid_trust_score,
|
||||
)
|
||||
|
||||
|
||||
def get_download_service(session: SessionDep, storage: FileStorageDep) -> DownloadService:
|
||||
return DownloadService(
|
||||
jobs=SqlAlchemyDownloadJobRepository(session),
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
artists=SqlAlchemyArtistRepository(session),
|
||||
storage=storage,
|
||||
enqueue_download=enqueue_download,
|
||||
enqueue_enrich=enqueue_enrich,
|
||||
)
|
||||
|
||||
|
||||
def get_remote_library_service(session: SessionDep) -> RemoteLibraryService:
|
||||
return RemoteLibraryService(
|
||||
tracks=SqlAlchemyTrackRepository(session),
|
||||
artists=SqlAlchemyArtistRepository(session),
|
||||
jobs=SqlAlchemyDownloadJobRepository(session),
|
||||
enqueue_materialize=enqueue_materialize,
|
||||
)
|
||||
|
||||
|
||||
UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
|
||||
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
|
||||
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
|
||||
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
||||
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
|
||||
|
||||
|
||||
# -- library repository deps ---------------------------------------------------
|
||||
def get_track_repository(session: SessionDep) -> SqlAlchemyTrackRepository:
|
||||
return SqlAlchemyTrackRepository(session)
|
||||
|
||||
|
||||
def get_artist_repository(session: SessionDep) -> SqlAlchemyArtistRepository:
|
||||
return SqlAlchemyArtistRepository(session)
|
||||
|
||||
|
||||
def get_album_repository(session: SessionDep) -> SqlAlchemyAlbumRepository:
|
||||
return SqlAlchemyAlbumRepository(session)
|
||||
|
||||
|
||||
def get_playlist_repository(session: SessionDep) -> SqlAlchemyPlaylistRepository:
|
||||
return SqlAlchemyPlaylistRepository(session)
|
||||
|
||||
|
||||
def get_like_repository(session: SessionDep) -> SqlAlchemyLikeRepository:
|
||||
return SqlAlchemyLikeRepository(session)
|
||||
|
||||
|
||||
def get_history_repository(session: SessionDep) -> SqlAlchemyHistoryRepository:
|
||||
return SqlAlchemyHistoryRepository(session)
|
||||
|
||||
|
||||
TrackRepoDep = Annotated[SqlAlchemyTrackRepository, Depends(get_track_repository)]
|
||||
ArtistRepoDep = Annotated[SqlAlchemyArtistRepository, Depends(get_artist_repository)]
|
||||
AlbumRepoDep = Annotated[SqlAlchemyAlbumRepository, Depends(get_album_repository)]
|
||||
PlaylistRepoDep = Annotated[SqlAlchemyPlaylistRepository, Depends(get_playlist_repository)]
|
||||
LikeRepoDep = Annotated[SqlAlchemyLikeRepository, Depends(get_like_repository)]
|
||||
HistoryRepoDep = Annotated[SqlAlchemyHistoryRepository, Depends(get_history_repository)]
|
||||
|
||||
|
||||
# -- current user / authorization ----------------------------------------------
|
||||
@@ -100,3 +245,49 @@ async def get_current_superuser(user: CurrentUser) -> User:
|
||||
|
||||
|
||||
SuperUser = Annotated[User, Depends(get_current_superuser)]
|
||||
|
||||
|
||||
async def get_streaming_user(
|
||||
auth: AuthServiceDep,
|
||||
credentials: BearerDep,
|
||||
token: str | None = None,
|
||||
) -> User:
|
||||
"""Authenticate a stream request.
|
||||
|
||||
The browser ``<audio>`` element cannot send an ``Authorization`` header, so
|
||||
the access token is accepted as a ``?token=`` query param; native clients may
|
||||
still use a bearer header. Either way it's the same access token.
|
||||
"""
|
||||
raw = token or (credentials.credentials if credentials else None)
|
||||
if not raw:
|
||||
raise AuthenticationError("Missing access token.")
|
||||
return await auth.authenticate_access(raw)
|
||||
|
||||
|
||||
StreamUser = Annotated[User, Depends(get_streaming_user)]
|
||||
|
||||
|
||||
# -- subsonic (/rest) authentication -------------------------------------------
|
||||
# Subsonic puts credentials in the query string: u + (t & s) | p, plus c/v/f.
|
||||
# The dep extracts them and delegates verification to the service; domain errors
|
||||
# propagate to the rest-aware exception handler, which renders the Subsonic
|
||||
# error envelope (HTTP 200). HTTPS is mandatory — the secret rides in the URL.
|
||||
async def get_subsonic_user(
|
||||
service: SubsonicAuthServiceDep,
|
||||
u: Annotated[str | None, Query()] = None,
|
||||
t: Annotated[str | None, Query()] = None,
|
||||
s: Annotated[str | None, Query()] = None,
|
||||
p: Annotated[str | None, Query()] = None,
|
||||
) -> User:
|
||||
return await service.authenticate(username=u, token=t, salt=s, password=p)
|
||||
|
||||
|
||||
SubsonicUser = Annotated[User, Depends(get_subsonic_user)]
|
||||
|
||||
|
||||
async def get_subsonic_format(f: Annotated[str | None, Query()] = None) -> str | None:
|
||||
"""The requested response format (``f``): ``xml`` (default) or ``json``."""
|
||||
return f
|
||||
|
||||
|
||||
SubsonicFormat = Annotated[str | None, Depends(get_subsonic_format)]
|
||||
|
||||
+44
-4
@@ -1,8 +1,15 @@
|
||||
"""Maps domain exceptions to HTTP responses. The only place that knows both."""
|
||||
"""Maps domain exceptions to HTTP responses. The only place that knows both.
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
Two surfaces share this mapping: the native ``/api/v1`` API answers with a JSON
|
||||
error body and an HTTP status code, while the Subsonic ``/rest`` layer answers
|
||||
with its own envelope and **always HTTP 200** (the status lives in the body). A
|
||||
request is routed to the Subsonic renderer by path prefix.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.rest.envelope import subsonic_error
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.errors import (
|
||||
AlreadyExistsError,
|
||||
@@ -12,6 +19,8 @@ from app.domain.errors import (
|
||||
DomainError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
RangeNotSatisfiableError,
|
||||
StorageError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
@@ -25,16 +34,43 @@ _STATUS_BY_ERROR: dict[type[DomainError], int] = {
|
||||
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
||||
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
|
||||
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
|
||||
# Subsonic error codes (subsonic.org/restapi): 10 missing param, 40 wrong
|
||||
# credentials, 50 not authorized, 70 not found, 0 generic.
|
||||
_SUBSONIC_CODE_BY_ERROR: dict[type[DomainError], int] = {
|
||||
ValidationError: 10,
|
||||
AuthenticationError: 40,
|
||||
PermissionDeniedError: 50,
|
||||
NotFoundError: 70,
|
||||
}
|
||||
|
||||
_SUBSONIC_PREFIX = "/rest"
|
||||
|
||||
|
||||
def _is_subsonic(request: Request) -> bool:
|
||||
return request.url.path.startswith(_SUBSONIC_PREFIX)
|
||||
|
||||
|
||||
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
|
||||
return {"error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(RangeNotSatisfiableError)
|
||||
async def _handle_range_error(_request: Request, exc: RangeNotSatisfiableError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
content=_error_body(exc.code, exc.message),
|
||||
headers={"Content-Range": f"bytes */{exc.total_size}"},
|
||||
)
|
||||
|
||||
@app.exception_handler(DomainError)
|
||||
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse:
|
||||
async def _handle_domain_error(request: Request, exc: DomainError) -> Response:
|
||||
if _is_subsonic(request):
|
||||
code = _SUBSONIC_CODE_BY_ERROR.get(type(exc), 0)
|
||||
return subsonic_error(code, exc.message, fmt=request.query_params.get("f"))
|
||||
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||
return JSONResponse(
|
||||
status_code=http_status,
|
||||
@@ -42,8 +78,12 @@ def register_exception_handlers(app: FastAPI) -> None:
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
|
||||
async def _handle_unexpected(request: Request, exc: Exception) -> Response:
|
||||
log.error("unhandled_exception", exc_info=exc)
|
||||
if _is_subsonic(request):
|
||||
return subsonic_error(
|
||||
0, "An unexpected error occurred.", fmt=request.query_params.get("f")
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=_error_body("internal_error", "An unexpected error occurred."),
|
||||
|
||||
+89
-11
@@ -1,23 +1,101 @@
|
||||
"""Subsonic annotation endpoints: star, rating, scrobble."""
|
||||
"""Subsonic annotation endpoints: star/unstar, rating, scrobble.
|
||||
|
||||
from typing import Any
|
||||
* ``star``/``unstar`` map to the **append-only** like event-log (a new event per
|
||||
call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are
|
||||
accepted but not persisted (no album/artist likes yet).
|
||||
* ``scrobble`` appends to play history.
|
||||
* ``setRating`` has no backing store yet — it's accepted as a clean no-op.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
import datetime as dt
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
from app.api.rest.ids import decode_track
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/star")
|
||||
async def star() -> Any: ...
|
||||
@router.api_route("/star", methods=["GET", "POST"])
|
||||
@router.api_route("/star.view", methods=["GET", "POST"])
|
||||
async def star(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
like_repo: LikeRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[list[str] | None, Query()] = None,
|
||||
albumId: Annotated[list[str] | None, Query()] = None,
|
||||
artistId: Annotated[list[str] | None, Query()] = None,
|
||||
) -> Response:
|
||||
# albumId/artistId are accepted for client compatibility but not persisted.
|
||||
for raw in id or []:
|
||||
track_id = decode_track(raw)
|
||||
if await track_repo.get_by_id(track_id) is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
await like_repo.add(user_id=user.id, track_id=track_id, value="like")
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/unstar")
|
||||
async def unstar() -> Any: ...
|
||||
@router.api_route("/unstar", methods=["GET", "POST"])
|
||||
@router.api_route("/unstar.view", methods=["GET", "POST"])
|
||||
async def unstar(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
like_repo: LikeRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[list[str] | None, Query()] = None,
|
||||
albumId: Annotated[list[str] | None, Query()] = None,
|
||||
artistId: Annotated[list[str] | None, Query()] = None,
|
||||
) -> Response:
|
||||
for raw in id or []:
|
||||
track_id = decode_track(raw)
|
||||
if await track_repo.get_by_id(track_id) is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
await like_repo.add(user_id=user.id, track_id=track_id, value="neutral")
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/setRating")
|
||||
async def set_rating() -> Any: ...
|
||||
@router.api_route("/setRating", methods=["GET", "POST"])
|
||||
@router.api_route("/setRating.view", methods=["GET", "POST"])
|
||||
async def set_rating(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
id: Annotated[str, Query()],
|
||||
rating: Annotated[int, Query(ge=0, le=5)],
|
||||
) -> Response:
|
||||
# No rating store yet — accept cleanly so clients don't error.
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/scrobble")
|
||||
async def scrobble() -> Any: ...
|
||||
@router.api_route("/scrobble", methods=["GET", "POST"])
|
||||
@router.api_route("/scrobble.view", methods=["GET", "POST"])
|
||||
async def scrobble(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
history_repo: HistoryRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[list[str] | None, Query()] = None,
|
||||
time: Annotated[list[int] | None, Query()] = None,
|
||||
submission: Annotated[bool, Query()] = True,
|
||||
) -> Response:
|
||||
times = time or []
|
||||
for index, raw in enumerate(id or []):
|
||||
track_id = decode_track(raw)
|
||||
if await track_repo.get_by_id(track_id) is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
if index < len(times):
|
||||
played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC)
|
||||
else:
|
||||
played_at = dt.datetime.now(dt.UTC)
|
||||
await history_repo.add(
|
||||
user_id=user.id,
|
||||
track_id=track_id,
|
||||
played_at=played_at,
|
||||
play_duration_seconds=None,
|
||||
completed=submission,
|
||||
)
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
+254
-24
@@ -1,47 +1,277 @@
|
||||
"""Subsonic browsing endpoints."""
|
||||
"""Subsonic browsing endpoints — thin adapters over the library repositories.
|
||||
|
||||
from typing import Any
|
||||
A single synthetic music folder (id ``0``) is exposed; this is a homelab, not a
|
||||
multi-library server. Heavy lifting stays in the repositories; these handlers
|
||||
only fan out queries and reshape rows into the Subsonic element dicts.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
from app.api.rest.ids import IdKind, encode_album, encode_artist, parse
|
||||
from app.api.rest.serializers import album_dict, artist_dict, iso, song_dict
|
||||
from app.domain.entities import Album, Artist
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/getMusicFolders")
|
||||
async def get_music_folders() -> Any: ...
|
||||
_IGNORED_ARTICLES = "The El La Los Las Le Les"
|
||||
_MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
|
||||
|
||||
|
||||
@router.get("/getIndexes")
|
||||
async def get_indexes() -> Any: ...
|
||||
async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]:
|
||||
"""Group artists into Subsonic A-Z index buckets, each with an album count."""
|
||||
artists = await artist_repo.list(q=None, limit=_MAX_ARTISTS, offset=0)
|
||||
buckets: dict[str, list[dict[str, Any]]] = {}
|
||||
for artist in artists:
|
||||
album_count = await artist_repo.album_count(artist.id)
|
||||
letter = artist.name[:1].upper()
|
||||
if not letter.isalpha():
|
||||
letter = "#"
|
||||
buckets.setdefault(letter, []).append(artist_dict(artist, album_count=album_count))
|
||||
return [{"name": name, "artist": buckets[name]} for name in sorted(buckets)]
|
||||
|
||||
|
||||
@router.get("/getMusicDirectory")
|
||||
async def get_music_directory() -> Any: ...
|
||||
async def _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]:
|
||||
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
return [album_dict(a, artist, song_count=counts.get(a.id, 0)) for a in albums]
|
||||
|
||||
|
||||
@router.get("/getArtists")
|
||||
async def get_artists() -> Any: ...
|
||||
@router.api_route("/getMusicFolders", methods=["GET", "POST"])
|
||||
@router.api_route("/getMusicFolders.view", methods=["GET", "POST"])
|
||||
async def get_music_folders(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||
return subsonic_response(
|
||||
{"musicFolders": {"musicFolder": [{"id": 0, "name": "Music"}]}}, fmt=fmt
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getArtist")
|
||||
async def get_artist() -> Any: ...
|
||||
@router.api_route("/getIndexes", methods=["GET", "POST"])
|
||||
@router.api_route("/getIndexes.view", methods=["GET", "POST"])
|
||||
async def get_indexes(
|
||||
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
|
||||
) -> Response:
|
||||
index = await _artists_index(artist_repo)
|
||||
return subsonic_response(
|
||||
{"indexes": {"ignoredArticles": _IGNORED_ARTICLES, "lastModified": 0, "index": index}},
|
||||
fmt=fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getAlbum")
|
||||
async def get_album() -> Any: ...
|
||||
@router.api_route("/getArtists", methods=["GET", "POST"])
|
||||
@router.api_route("/getArtists.view", methods=["GET", "POST"])
|
||||
async def get_artists(
|
||||
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
|
||||
) -> Response:
|
||||
index = await _artists_index(artist_repo)
|
||||
return subsonic_response(
|
||||
{"artists": {"ignoredArticles": _IGNORED_ARTICLES, "index": index}}, fmt=fmt
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getAlbumList")
|
||||
async def get_album_list() -> Any: ...
|
||||
@router.api_route("/getArtist", methods=["GET", "POST"])
|
||||
@router.api_route("/getArtist.view", methods=["GET", "POST"])
|
||||
async def get_artist(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
_, artist_id = parse(id)
|
||||
artist = await artist_repo.get_by_id(artist_id)
|
||||
if artist is None:
|
||||
raise NotFoundError("Artist not found.")
|
||||
albums = await _albums_for_artist(artist, album_repo)
|
||||
payload = {
|
||||
**artist_dict(artist, album_count=len(albums)),
|
||||
"album": albums,
|
||||
}
|
||||
return subsonic_response({"artist": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getAlbumList2")
|
||||
async def get_album_list2() -> Any: ...
|
||||
@router.api_route("/getAlbum", methods=["GET", "POST"])
|
||||
@router.api_route("/getAlbum.view", methods=["GET", "POST"])
|
||||
async def get_album(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
_, album_id = parse(id)
|
||||
album = await album_repo.get_by_id(album_id)
|
||||
if album is None:
|
||||
raise NotFoundError("Album not found.")
|
||||
artist = await artist_repo.get_by_id(album.artist_id)
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=album_id,
|
||||
q=None,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
duration = sum(t.duration_seconds or 0 for t in tracks)
|
||||
songs = [song_dict(t, artist, album) for t in tracks]
|
||||
payload = {
|
||||
**album_dict(album, artist, song_count=len(songs), duration=duration),
|
||||
"song": songs,
|
||||
}
|
||||
return subsonic_response({"album": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getSong")
|
||||
async def get_song() -> Any: ...
|
||||
@router.api_route("/getAlbumList", methods=["GET", "POST"])
|
||||
@router.api_route("/getAlbumList.view", methods=["GET", "POST"])
|
||||
async def get_album_list(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
type: Annotated[str, Query()] = "newest",
|
||||
size: Annotated[int, Query(ge=1, le=500)] = 10,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Response:
|
||||
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
|
||||
return subsonic_response({"albumList": {"album": albums}}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getGenres")
|
||||
async def get_genres() -> Any: ...
|
||||
@router.api_route("/getAlbumList2", methods=["GET", "POST"])
|
||||
@router.api_route("/getAlbumList2.view", methods=["GET", "POST"])
|
||||
async def get_album_list2(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
type: Annotated[str, Query()] = "newest",
|
||||
size: Annotated[int, Query(ge=1, le=500)] = 10,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Response:
|
||||
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
|
||||
return subsonic_response({"albumList2": {"album": albums}}, fmt=fmt)
|
||||
|
||||
|
||||
async def _list_albums(
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
type_: str,
|
||||
size: int,
|
||||
offset: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
if type_ == "alphabeticalByName":
|
||||
sort_by, order = "title", "asc"
|
||||
elif type_ == "random":
|
||||
sort_by, order = "title", "random"
|
||||
else: # newest / recent / frequent → newest (no play stats yet)
|
||||
sort_by, order = "created", "desc"
|
||||
albums = await album_repo.list(
|
||||
artist_id=None, q=None, limit=size, offset=offset, sort_by=sort_by, order=order
|
||||
)
|
||||
return await _decorate_albums(albums, album_repo, artist_repo)
|
||||
|
||||
|
||||
async def _decorate_albums(
|
||||
albums: list[Album], album_repo: AlbumRepoDep, artist_repo: ArtistRepoDep
|
||||
) -> list[dict[str, Any]]:
|
||||
artist_ids = list({a.artist_id for a in albums})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
return [album_dict(a, artists.get(a.artist_id), song_count=counts.get(a.id, 0)) for a in albums]
|
||||
|
||||
|
||||
@router.api_route("/getSong", methods=["GET", "POST"])
|
||||
@router.api_route("/getSong.view", methods=["GET", "POST"])
|
||||
async def get_song(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
_, track_id = parse(id)
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
artist = await artist_repo.get_by_id(track.artist_id)
|
||||
album = await album_repo.get_by_id(track.album_id) if track.album_id else None
|
||||
return subsonic_response({"song": song_dict(track, artist, album)}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/getGenres", methods=["GET", "POST"])
|
||||
@router.api_route("/getGenres.view", methods=["GET", "POST"])
|
||||
async def get_genres(
|
||||
_user: SubsonicUser, fmt: SubsonicFormat, track_repo: TrackRepoDep
|
||||
) -> Response:
|
||||
genres = [
|
||||
{"value": name, "songCount": count, "albumCount": 0}
|
||||
for name, count in await track_repo.genres()
|
||||
]
|
||||
return subsonic_response({"genres": {"genre": genres}}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/getMusicDirectory", methods=["GET", "POST"])
|
||||
@router.api_route("/getMusicDirectory.view", methods=["GET", "POST"])
|
||||
async def get_music_directory(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
kind, entity_id = parse(id)
|
||||
if kind is IdKind.ARTIST:
|
||||
artist = await artist_repo.get_by_id(entity_id)
|
||||
if artist is None:
|
||||
raise NotFoundError("Artist not found.")
|
||||
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
children = [
|
||||
{
|
||||
"id": encode_album(a.id),
|
||||
"parent": encode_artist(artist.id),
|
||||
"isDir": True,
|
||||
"title": a.title,
|
||||
"name": a.title,
|
||||
"artist": artist.name,
|
||||
"artistId": encode_artist(artist.id),
|
||||
"coverArt": encode_album(a.id),
|
||||
"songCount": counts.get(a.id, 0),
|
||||
"created": iso(a.created_at),
|
||||
"year": a.year,
|
||||
}
|
||||
for a in albums
|
||||
]
|
||||
directory = {"id": id, "name": artist.name, "child": children}
|
||||
return subsonic_response({"directory": directory}, fmt=fmt)
|
||||
|
||||
if kind is IdKind.ALBUM:
|
||||
album = await album_repo.get_by_id(entity_id)
|
||||
if album is None:
|
||||
raise NotFoundError("Album not found.")
|
||||
artist = await artist_repo.get_by_id(album.artist_id)
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=album.id,
|
||||
q=None,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
children = [song_dict(t, artist, album) for t in tracks]
|
||||
directory = {
|
||||
"id": id,
|
||||
"parent": encode_artist(album.artist_id),
|
||||
"name": album.title,
|
||||
"child": children,
|
||||
}
|
||||
return subsonic_response({"directory": directory}, fmt=fmt)
|
||||
|
||||
raise NotFoundError("Directory not found.")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""The Subsonic response envelope — one serializer, two wire formats.
|
||||
|
||||
Every Subsonic endpoint answers with a ``<subsonic-response>`` wrapper carrying
|
||||
``status`` / ``version`` / ``type`` / ``serverVersion``, in XML (default) or JSON
|
||||
(``f=json``). All handlers return through :func:`subsonic_response`; errors go
|
||||
through the rest-aware exception handler (see ``app.api.errors``).
|
||||
|
||||
Payload data model (shared by both formats):
|
||||
|
||||
* a scalar value → an XML attribute / a JSON field
|
||||
* a nested dict → a single child element / nested object
|
||||
* a list of dicts → repeated child elements / a JSON array
|
||||
* the key ``"value"`` → element text content (used by e.g. lyrics)
|
||||
|
||||
``None`` values are dropped. Subsonic always replies with **HTTP 200**, even for
|
||||
errors — the status lives inside the envelope — so clients parse the body.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
SUBSONIC_API_VERSION = "1.16.1"
|
||||
SERVER_TYPE = "mcma"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
|
||||
_XML_NS = "http://subsonic.org/restapi"
|
||||
_XML_MEDIA_TYPE = "application/xml; charset=utf-8"
|
||||
_JSON_MEDIA_TYPE = "application/json; charset=utf-8"
|
||||
|
||||
|
||||
def _is_json(fmt: str | None) -> bool:
|
||||
return fmt in ("json", "jsonp")
|
||||
|
||||
|
||||
def _scalar(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _build_xml(parent: ET.Element, data: Mapping[str, Any]) -> None:
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if key == "value":
|
||||
parent.text = _scalar(value)
|
||||
elif isinstance(value, Mapping):
|
||||
_build_xml(ET.SubElement(parent, key), value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_build_xml(ET.SubElement(parent, key), item)
|
||||
else:
|
||||
parent.set(key, _scalar(value))
|
||||
|
||||
|
||||
def _strip_none(value: Any) -> Any:
|
||||
"""Recursively drop ``None`` values so JSON output matches XML (no empty attrs)."""
|
||||
if isinstance(value, Mapping):
|
||||
return {k: _strip_none(v) for k, v in value.items() if v is not None}
|
||||
if isinstance(value, list):
|
||||
return [_strip_none(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _render(body: Mapping[str, Any], fmt: str | None) -> Response:
|
||||
envelope: dict[str, Any] = {
|
||||
"status": body["status"],
|
||||
"version": SUBSONIC_API_VERSION,
|
||||
"type": SERVER_TYPE,
|
||||
"serverVersion": SERVER_VERSION,
|
||||
"openSubsonic": True,
|
||||
**{k: v for k, v in body.items() if k != "status"},
|
||||
}
|
||||
|
||||
if _is_json(fmt):
|
||||
payload = json.dumps({"subsonic-response": _strip_none(envelope)})
|
||||
return Response(content=payload, media_type=_JSON_MEDIA_TYPE)
|
||||
|
||||
root = ET.Element("subsonic-response", {"xmlns": _XML_NS})
|
||||
_build_xml(root, envelope)
|
||||
xml = b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="utf-8")
|
||||
return Response(content=xml, media_type=_XML_MEDIA_TYPE)
|
||||
|
||||
|
||||
def subsonic_response(
|
||||
payload: Mapping[str, Any] | None = None, *, fmt: str | None = None
|
||||
) -> Response:
|
||||
"""A successful ``status="ok"`` envelope wrapping ``payload``."""
|
||||
body: dict[str, Any] = {"status": "ok"}
|
||||
if payload:
|
||||
body.update(payload)
|
||||
return _render(body, fmt)
|
||||
|
||||
|
||||
def subsonic_error(code: int, message: str, *, fmt: str | None = None) -> Response:
|
||||
"""A ``status="failed"`` envelope carrying a Subsonic ``<error>``."""
|
||||
body = {"status": "failed", "error": {"code": code, "message": message}}
|
||||
return _render(body, fmt)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Stable, reversible mapping between Subsonic opaque string ids and our UUIDs.
|
||||
|
||||
Subsonic ids are opaque strings; ours are UUIDs. We use a type-prefixed,
|
||||
human-debuggable convention (``tr-<uuid>`` track, ``al-<uuid>`` album,
|
||||
``ar-<uuid>`` artist, ``pl-<uuid>`` playlist). Cover-art ids reuse the entity's
|
||||
own id (an album cover is ``al-<uuid>``, a track cover ``tr-<uuid>``). Centralize
|
||||
encode/decode here so the convention lives in exactly one place.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
|
||||
class IdKind(StrEnum):
|
||||
TRACK = "tr"
|
||||
ALBUM = "al"
|
||||
ARTIST = "ar"
|
||||
PLAYLIST = "pl"
|
||||
|
||||
|
||||
def encode(kind: IdKind, value: uuid.UUID) -> str:
|
||||
return f"{kind.value}-{value}"
|
||||
|
||||
|
||||
def encode_track(value: uuid.UUID) -> str:
|
||||
return encode(IdKind.TRACK, value)
|
||||
|
||||
|
||||
def encode_album(value: uuid.UUID) -> str:
|
||||
return encode(IdKind.ALBUM, value)
|
||||
|
||||
|
||||
def encode_artist(value: uuid.UUID) -> str:
|
||||
return encode(IdKind.ARTIST, value)
|
||||
|
||||
|
||||
def encode_playlist(value: uuid.UUID) -> str:
|
||||
return encode(IdKind.PLAYLIST, value)
|
||||
|
||||
|
||||
def parse(raw: str) -> tuple[IdKind, uuid.UUID]:
|
||||
"""Decode any prefixed id into its kind + UUID. Raises ``NotFoundError`` on a
|
||||
malformed id (an unknown id is, from the client's view, simply not found)."""
|
||||
prefix, _, rest = raw.partition("-")
|
||||
try:
|
||||
kind = IdKind(prefix)
|
||||
value = uuid.UUID(rest)
|
||||
except ValueError as exc:
|
||||
raise NotFoundError(f"Unknown id {raw!r}.") from exc
|
||||
return kind, value
|
||||
|
||||
|
||||
def _decode_as(raw: str, expected: IdKind) -> uuid.UUID:
|
||||
kind, value = parse(raw)
|
||||
if kind is not expected:
|
||||
raise NotFoundError(f"Expected a {expected.name.lower()} id, got {raw!r}.")
|
||||
return value
|
||||
|
||||
|
||||
def decode_track(raw: str) -> uuid.UUID:
|
||||
return _decode_as(raw, IdKind.TRACK)
|
||||
|
||||
|
||||
def decode_album(raw: str) -> uuid.UUID:
|
||||
return _decode_as(raw, IdKind.ALBUM)
|
||||
|
||||
|
||||
def decode_artist(raw: str) -> uuid.UUID:
|
||||
return _decode_as(raw, IdKind.ARTIST)
|
||||
|
||||
|
||||
def decode_playlist(raw: str) -> uuid.UUID:
|
||||
return _decode_as(raw, IdKind.PLAYLIST)
|
||||
+95
-10
@@ -1,19 +1,104 @@
|
||||
"""Subsonic media endpoints: stream, download, cover art."""
|
||||
"""Subsonic media endpoints: stream, download, cover art.
|
||||
|
||||
from typing import Any
|
||||
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
|
||||
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
|
||||
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
|
||||
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
|
||||
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
import base64
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from app.api.covers import resolve_album_for_track, stream_cover
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
FileStorageDep,
|
||||
StreamingServiceDep,
|
||||
SubsonicUser,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.rest.ids import IdKind, decode_track, parse
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.errors import NotFoundError, StorageError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream() -> Any: ...
|
||||
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
|
||||
_PLACEHOLDER_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download() -> Any: ...
|
||||
@router.api_route("/stream", methods=["GET", "POST"])
|
||||
@router.api_route("/stream.view", methods=["GET", "POST"])
|
||||
async def stream(
|
||||
_user: SubsonicUser,
|
||||
service: StreamingServiceDep,
|
||||
id: Annotated[str, Query()],
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> StreamingResponse:
|
||||
result = await service.open_stream(decode_track(id), range_header)
|
||||
headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)}
|
||||
status_code = 200
|
||||
if result.is_partial:
|
||||
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
||||
status_code = 206
|
||||
return StreamingResponse(
|
||||
result.stream, status_code=status_code, headers=headers, media_type=result.content_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/getCoverArt")
|
||||
async def get_cover_art() -> Any: ...
|
||||
@router.api_route("/download", methods=["GET", "POST"])
|
||||
@router.api_route("/download.view", methods=["GET", "POST"])
|
||||
async def download(
|
||||
_user: SubsonicUser,
|
||||
service: StreamingServiceDep,
|
||||
track_repo: TrackRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> StreamingResponse:
|
||||
track_id = decode_track(id)
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError("Song not found.")
|
||||
result = await service.open_stream(track_id, None)
|
||||
filename = f"{track.title}.{track.file_format or 'bin'}"
|
||||
headers = {
|
||||
"Content-Length": str(result.content_length),
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
}
|
||||
return StreamingResponse(result.stream, headers=headers, media_type=result.content_type)
|
||||
|
||||
|
||||
@router.api_route("/getCoverArt", methods=["GET", "POST"])
|
||||
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
|
||||
async def get_cover_art(
|
||||
_user: SubsonicUser,
|
||||
album_repo: AlbumRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
storage: FileStorageDep,
|
||||
id: Annotated[str, Query()],
|
||||
size: Annotated[int | None, Query()] = None,
|
||||
) -> Response:
|
||||
# Cover ids reuse the entity id: ``al-<uuid>`` (album) or ``tr-<uuid>``
|
||||
# (track → its album). Unlike the native API, Subsonic clients expect an
|
||||
# image either way, so a missing cover falls back to a placeholder rather
|
||||
# than 404. ``size`` is accepted but ignored (we serve the stored image).
|
||||
kind, value = parse(id)
|
||||
album: Album | None
|
||||
if kind is IdKind.ALBUM:
|
||||
album = await album_repo.get_by_id(value)
|
||||
elif kind is IdKind.TRACK:
|
||||
album = await resolve_album_for_track(track_repo, album_repo, value)
|
||||
else:
|
||||
album = None
|
||||
|
||||
if album is not None and album.cover_path:
|
||||
try:
|
||||
return await stream_cover(storage, album.cover_path)
|
||||
except NotFoundError, StorageError:
|
||||
pass
|
||||
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
|
||||
|
||||
+168
-13
@@ -1,27 +1,182 @@
|
||||
"""Subsonic playlist endpoints."""
|
||||
"""Subsonic playlist endpoints — adapters over the playlist repository.
|
||||
|
||||
from typing import Any
|
||||
Playlists are private to their owner (no public-playlist concept yet), so every
|
||||
read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a
|
||||
full replace when given a ``playlistId`` (Subsonic overloads it that way).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
PlaylistRepoDep,
|
||||
SubsonicFormat,
|
||||
SubsonicUser,
|
||||
)
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
from app.api.rest.ids import decode_playlist, decode_track, encode_playlist
|
||||
from app.api.rest.serializers import iso, song_dict
|
||||
from app.domain.entities import Playlist, User
|
||||
from app.domain.errors import NotFoundError, PermissionDeniedError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/getPlaylists")
|
||||
async def get_playlists() -> Any: ...
|
||||
def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": encode_playlist(playlist.id),
|
||||
"name": playlist.name,
|
||||
"comment": playlist.description,
|
||||
"owner": owner,
|
||||
"public": False,
|
||||
"songCount": song_count,
|
||||
"created": iso(playlist.created_at),
|
||||
"changed": iso(playlist.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/getPlaylist")
|
||||
async def get_playlist() -> Any: ...
|
||||
async def _owned_playlist(
|
||||
playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User
|
||||
) -> Playlist:
|
||||
playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw))
|
||||
if playlist is None:
|
||||
raise NotFoundError("Playlist not found.")
|
||||
if playlist.owner_id != user.id:
|
||||
raise PermissionDeniedError("You don't own this playlist.")
|
||||
return playlist
|
||||
|
||||
|
||||
@router.get("/createPlaylist")
|
||||
async def create_playlist() -> Any: ...
|
||||
async def _playlist_songs(
|
||||
playlist_id: str,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
) -> list[dict[str, Any]]:
|
||||
tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0)
|
||||
artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))}
|
||||
album_map = {
|
||||
a.id: a
|
||||
for a in await album_repo.get_many(
|
||||
list({t.album_id for t in tracks if t.album_id is not None})
|
||||
)
|
||||
}
|
||||
return [
|
||||
song_dict(
|
||||
t,
|
||||
artist_map.get(t.artist_id),
|
||||
album_map.get(t.album_id) if t.album_id is not None else None,
|
||||
)
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
|
||||
@router.get("/updatePlaylist")
|
||||
async def update_playlist() -> Any: ...
|
||||
@router.api_route("/getPlaylists", methods=["GET", "POST"])
|
||||
@router.api_route("/getPlaylists.view", methods=["GET", "POST"])
|
||||
async def get_playlists(
|
||||
user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep
|
||||
) -> Response:
|
||||
playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0)
|
||||
counts = await playlist_repo.track_count_many([p.id for p in playlists])
|
||||
items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists]
|
||||
return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/deletePlaylist")
|
||||
async def delete_playlist() -> Any: ...
|
||||
@router.api_route("/getPlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/getPlaylist.view", methods=["GET", "POST"])
|
||||
async def get_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
playlist = await _owned_playlist(id, playlist_repo, user)
|
||||
songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo)
|
||||
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
|
||||
return subsonic_response({"playlist": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/createPlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/createPlaylist.view", methods=["GET", "POST"])
|
||||
async def create_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
name: Annotated[str | None, Query()] = None,
|
||||
playlistId: Annotated[str | None, Query()] = None,
|
||||
songId: Annotated[list[str] | None, Query()] = None,
|
||||
) -> Response:
|
||||
song_ids = [decode_track(s) for s in (songId or [])]
|
||||
|
||||
if playlistId is not None:
|
||||
# Overloaded form: replace the existing playlist's tracks (and name).
|
||||
playlist = await _owned_playlist(playlistId, playlist_repo, user)
|
||||
if name is not None:
|
||||
playlist = await playlist_repo.update(playlist.id, name=name, description=None)
|
||||
existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
|
||||
for t in existing:
|
||||
await playlist_repo.remove_track(playlist.id, t.id)
|
||||
else:
|
||||
playlist = await playlist_repo.add(
|
||||
name=name or "Untitled", description=None, owner_id=user.id
|
||||
)
|
||||
|
||||
for position, track_id in enumerate(song_ids, start=1):
|
||||
await playlist_repo.add_track(playlist.id, track_id, position=float(position))
|
||||
|
||||
encoded = encode_playlist(playlist.id)
|
||||
songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo)
|
||||
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
|
||||
return subsonic_response({"playlist": payload}, fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/updatePlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/updatePlaylist.view", methods=["GET", "POST"])
|
||||
async def update_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
playlistId: Annotated[str, Query()],
|
||||
name: Annotated[str | None, Query()] = None,
|
||||
comment: Annotated[str | None, Query()] = None,
|
||||
songIdToAdd: Annotated[list[str] | None, Query()] = None,
|
||||
songIndexToRemove: Annotated[list[int] | None, Query()] = None,
|
||||
) -> Response:
|
||||
playlist = await _owned_playlist(playlistId, playlist_repo, user)
|
||||
|
||||
if name is not None or comment is not None:
|
||||
playlist = await playlist_repo.update(playlist.id, name=name, description=comment)
|
||||
|
||||
# Removals are by index into the current ordered track list — resolve first.
|
||||
if songIndexToRemove:
|
||||
current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
|
||||
for index in sorted(set(songIndexToRemove)):
|
||||
if 0 <= index < len(current):
|
||||
await playlist_repo.remove_track(playlist.id, current[index].id)
|
||||
|
||||
if songIdToAdd:
|
||||
position = await playlist_repo.max_position(playlist.id)
|
||||
for raw in songIdToAdd:
|
||||
position += 1.0
|
||||
await playlist_repo.add_track(playlist.id, decode_track(raw), position=position)
|
||||
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.api_route("/deletePlaylist", methods=["GET", "POST"])
|
||||
@router.api_route("/deletePlaylist.view", methods=["GET", "POST"])
|
||||
async def delete_playlist(
|
||||
user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
id: Annotated[str, Query()],
|
||||
) -> Response:
|
||||
playlist = await _owned_playlist(id, playlist_repo, user)
|
||||
await playlist_repo.delete(playlist.id)
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
+80
-5
@@ -1,11 +1,86 @@
|
||||
"""Subsonic search endpoints."""
|
||||
"""Subsonic search endpoints — search3 over the library repositories.
|
||||
|
||||
from typing import Any
|
||||
Mirrors the native ``/api/v1/search/library`` fan-out (tracks/albums/artists),
|
||||
reshaped into the Subsonic ``searchResult3`` element. An empty query returns
|
||||
results so clients can use search3 to browse.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
from app.api.rest.serializers import album_dict, artist_dict, song_dict
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/search3")
|
||||
async def search3() -> Any: ...
|
||||
@router.api_route("/search3", methods=["GET", "POST"])
|
||||
@router.api_route("/search3.view", methods=["GET", "POST"])
|
||||
async def search3(
|
||||
_user: SubsonicUser,
|
||||
fmt: SubsonicFormat,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
query: Annotated[str, Query()] = "",
|
||||
artistCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
||||
artistOffset: Annotated[int, Query(ge=0)] = 0,
|
||||
albumCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
||||
albumOffset: Annotated[int, Query(ge=0)] = 0,
|
||||
songCount: Annotated[int, Query(ge=0, le=500)] = 20,
|
||||
songOffset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> Response:
|
||||
# Subsonic sends "" (and some clients '""') to mean "everything".
|
||||
q: str | None = query.strip().strip('"') or None
|
||||
|
||||
artists_out: list[dict[str, Any]] = []
|
||||
if artistCount:
|
||||
artists = await artist_repo.list(q=q, limit=artistCount, offset=artistOffset)
|
||||
for a in artists:
|
||||
album_count = await artist_repo.album_count(a.id)
|
||||
artists_out.append(artist_dict(a, album_count=album_count))
|
||||
|
||||
albums_out: list[dict[str, Any]] = []
|
||||
if albumCount:
|
||||
albums = await album_repo.list(artist_id=None, q=q, limit=albumCount, offset=albumOffset)
|
||||
album_artist_ids = list({a.artist_id for a in albums})
|
||||
album_artist_map = {a.id: a for a in await artist_repo.get_many(album_artist_ids)}
|
||||
counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
albums_out = [
|
||||
album_dict(a, album_artist_map.get(a.artist_id), song_count=counts.get(a.id, 0))
|
||||
for a in albums
|
||||
]
|
||||
|
||||
songs_out: list[dict[str, Any]] = []
|
||||
if songCount:
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=None,
|
||||
q=q,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=songCount,
|
||||
offset=songOffset,
|
||||
)
|
||||
song_artist_map = {
|
||||
a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))
|
||||
}
|
||||
song_album_map = {
|
||||
a.id: a
|
||||
for a in await album_repo.get_many(
|
||||
list({t.album_id for t in tracks if t.album_id is not None})
|
||||
)
|
||||
}
|
||||
songs_out = [
|
||||
song_dict(
|
||||
t,
|
||||
song_artist_map.get(t.artist_id),
|
||||
song_album_map.get(t.album_id) if t.album_id is not None else None,
|
||||
)
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
payload = {"searchResult3": {"artist": artists_out, "album": albums_out, "song": songs_out}}
|
||||
return subsonic_response(payload, fmt=fmt)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Entity → Subsonic child-dict mappers (presentation only).
|
||||
|
||||
Pure functions turning domain entities into the attribute dicts the envelope
|
||||
serializer renders as ``<artist>`` / ``<album>`` / ``<song>`` elements (or their
|
||||
JSON equivalents). No business logic — they only reshape and rename.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from app.api.rest.ids import encode_album, encode_artist, encode_track
|
||||
from app.domain.entities import Album, Artist, Track
|
||||
|
||||
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
|
||||
# presentation detail (mirrors StreamingService's content-type negotiation).
|
||||
_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",
|
||||
"aiff": "audio/aiff",
|
||||
"aif": "audio/aiff",
|
||||
}
|
||||
|
||||
|
||||
def iso(value: dt.datetime) -> str:
|
||||
return value.astimezone(dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
|
||||
|
||||
def content_type_for(file_format: str) -> str:
|
||||
return _CONTENT_TYPE.get(file_format.lower(), "application/octet-stream")
|
||||
|
||||
|
||||
def artist_dict(artist: Artist, *, album_count: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": encode_artist(artist.id),
|
||||
"name": artist.name,
|
||||
"albumCount": album_count,
|
||||
"coverArt": encode_artist(artist.id),
|
||||
}
|
||||
|
||||
|
||||
def album_dict(
|
||||
album: Album,
|
||||
artist: Artist | None,
|
||||
*,
|
||||
song_count: int,
|
||||
duration: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": encode_album(album.id),
|
||||
"name": album.title,
|
||||
"title": album.title,
|
||||
"artist": artist.name if artist is not None else None,
|
||||
"artistId": encode_artist(album.artist_id),
|
||||
"coverArt": encode_album(album.id),
|
||||
"songCount": song_count,
|
||||
"duration": duration,
|
||||
"created": iso(album.created_at),
|
||||
"year": album.year,
|
||||
}
|
||||
|
||||
|
||||
def song_dict(
|
||||
track: Track,
|
||||
artist: Artist | None,
|
||||
album: Album | None,
|
||||
) -> dict[str, Any]:
|
||||
cover = encode_album(track.album_id) if track.album_id is not None else encode_track(track.id)
|
||||
return {
|
||||
"id": encode_track(track.id),
|
||||
"parent": encode_album(track.album_id) if track.album_id is not None else None,
|
||||
"isDir": False,
|
||||
"title": track.title,
|
||||
"album": album.title if album is not None else None,
|
||||
"artist": artist.name if artist is not None else None,
|
||||
"albumId": encode_album(track.album_id) if track.album_id is not None else None,
|
||||
"artistId": encode_artist(track.artist_id),
|
||||
"coverArt": cover,
|
||||
"size": track.file_size or 0,
|
||||
"contentType": content_type_for(track.file_format or ""),
|
||||
"suffix": track.file_format,
|
||||
"duration": track.duration_seconds,
|
||||
"year": track.year,
|
||||
"genre": track.genre,
|
||||
"created": iso(track.created_at),
|
||||
"type": "music",
|
||||
"isVideo": False,
|
||||
}
|
||||
+13
-6
@@ -1,15 +1,22 @@
|
||||
"""Subsonic system endpoints: ping and license."""
|
||||
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Response
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.deps import SubsonicFormat, SubsonicUser
|
||||
from app.api.rest.envelope import subsonic_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping() -> Any: ...
|
||||
@router.api_route("/ping", methods=["GET", "POST"])
|
||||
@router.api_route("/ping.view", methods=["GET", "POST"])
|
||||
async def ping(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||
# Requiring auth makes ping a credential check — exactly how clients use it.
|
||||
return subsonic_response(fmt=fmt)
|
||||
|
||||
|
||||
@router.get("/getLicense")
|
||||
async def get_license() -> Any: ...
|
||||
@router.api_route("/getLicense", methods=["GET", "POST"])
|
||||
@router.api_route("/getLicense.view", methods=["GET", "POST"])
|
||||
async def get_license(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
|
||||
# Self-hosted and free — the license is always valid.
|
||||
return subsonic_response({"license": {"valid": True}}, fmt=fmt)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Album request/response schemas."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AlbumOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
artist_id: uuid.UUID
|
||||
artist_name: str
|
||||
year: int | None
|
||||
track_count: int
|
||||
has_cover: bool
|
||||
created_at: dt.datetime
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Artist request/response schemas."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ArtistOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
album_count: int
|
||||
track_count: int
|
||||
created_at: dt.datetime
|
||||
@@ -10,6 +10,11 @@ class LoginRequest(BaseModel):
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Schemas for the download job endpoints (§A5 download manager)."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domain.entities.download import DownloadJob
|
||||
|
||||
|
||||
class DownloadCreate(BaseModel):
|
||||
"""Request to download an item discovered on a fetch source."""
|
||||
|
||||
source: str
|
||||
source_id: str = Field(min_length=1)
|
||||
# Optional free-text the result came from — stored for display only.
|
||||
query: str | None = None
|
||||
|
||||
|
||||
class DownloadJobOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
source: str
|
||||
source_id: str | None
|
||||
query: str | None
|
||||
status: str
|
||||
progress: float
|
||||
error_message: str | None
|
||||
retry_count: int
|
||||
track_id: uuid.UUID | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, job: DownloadJob) -> DownloadJobOut:
|
||||
return cls(
|
||||
id=job.id,
|
||||
source=job.source,
|
||||
source_id=job.source_id,
|
||||
query=job.query,
|
||||
status=job.status,
|
||||
progress=job.progress,
|
||||
error_message=job.error_message,
|
||||
retry_count=job.retry_count,
|
||||
track_id=job.track_id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class DownloadCreateResponse(BaseModel):
|
||||
"""Result of requesting a download.
|
||||
|
||||
``already_in_library`` → the item was already imported (``track_id`` set, no
|
||||
job). Otherwise ``job`` describes the queued (or already in-flight) download.
|
||||
"""
|
||||
|
||||
already_in_library: bool
|
||||
track_id: uuid.UUID | None
|
||||
job: DownloadJobOut | None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for searching external (fetch) sources — the §A4 discover screen."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.entities.track import Track
|
||||
from app.domain.sources import SearchResult
|
||||
|
||||
|
||||
class ExternalSearchResultOut(BaseModel):
|
||||
source: str
|
||||
source_id: str
|
||||
title: str
|
||||
artist: str | None
|
||||
album: str | None
|
||||
duration_seconds: int | None
|
||||
thumbnail_url: str | None
|
||||
# Remote browse (plan: Model C) — set when this hit is already saved in the
|
||||
# library, so the UI can show "Play"/"Saved" instead of "Save to library".
|
||||
in_library: bool
|
||||
track_id: uuid.UUID | None
|
||||
availability: str | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(
|
||||
cls, r: SearchResult, *, existing: Track | None = None
|
||||
) -> ExternalSearchResultOut:
|
||||
return cls(
|
||||
source=r.source,
|
||||
source_id=r.source_id,
|
||||
title=r.title,
|
||||
artist=r.artist,
|
||||
album=r.album,
|
||||
duration_seconds=r.duration_seconds,
|
||||
thumbnail_url=r.thumbnail_url,
|
||||
in_library=existing is not None,
|
||||
track_id=existing.id if existing is not None else None,
|
||||
availability=existing.availability if existing is not None else None,
|
||||
)
|
||||
|
||||
|
||||
class ExternalSearchResponse(BaseModel):
|
||||
"""Flat list of hits across one or more searchable sources, plus the names of
|
||||
sources that were unavailable (so the UI can show a soft warning)."""
|
||||
|
||||
results: list[ExternalSearchResultOut]
|
||||
searched_sources: list[str]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Play history request/response schemas."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HistoryIn(BaseModel):
|
||||
track_id: uuid.UUID
|
||||
played_at: dt.datetime
|
||||
play_duration_seconds: int | None = None
|
||||
completed: bool = False
|
||||
|
||||
|
||||
class HistoryOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
track_id: uuid.UUID
|
||||
played_at: dt.datetime
|
||||
play_duration_seconds: int | None
|
||||
completed: bool
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Like request/response schemas."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LikeEvent(BaseModel):
|
||||
track_id: uuid.UUID
|
||||
value: Literal["like", "dislike", "neutral"]
|
||||
|
||||
|
||||
class LikeState(BaseModel):
|
||||
track_id: uuid.UUID
|
||||
value: str
|
||||
updated_at: dt.datetime
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Shared pagination envelope for all paged list responses."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PagedResponse[T](BaseModel):
|
||||
items: list[T]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Playlist request/response schemas."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PlaylistOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
version: int
|
||||
track_count: int
|
||||
created_at: dt.datetime
|
||||
|
||||
|
||||
class PlaylistCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PlaylistUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PlaylistAddTrack(BaseModel):
|
||||
track_id: uuid.UUID
|
||||
position: float | None = None
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Search response schemas."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.schemas.album import AlbumOut
|
||||
from app.api.schemas.artist import ArtistOut
|
||||
from app.api.schemas.track import TrackOut
|
||||
|
||||
|
||||
class LibrarySearchResponse(BaseModel):
|
||||
tracks: list[TrackOut]
|
||||
albums: list[AlbumOut]
|
||||
artists: list[ArtistOut]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Schemas for the source endpoints."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.sources import SourceInfo
|
||||
|
||||
|
||||
class SourceInfoOut(BaseModel):
|
||||
name: str
|
||||
label: str
|
||||
kind: str
|
||||
available: bool
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, info: SourceInfo) -> SourceInfoOut:
|
||||
return cls(name=info.name, label=info.label, kind=info.kind, available=info.available)
|
||||
|
||||
|
||||
class ScanResponse(BaseModel):
|
||||
"""Result of enqueuing a source scan."""
|
||||
|
||||
source: str
|
||||
job_id: str
|
||||
status: str = "queued"
|
||||
|
||||
|
||||
class SourceHealthOut(BaseModel):
|
||||
name: str
|
||||
available: bool
|
||||
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Schemas for Subsonic app-password self-service (native /api/v1 surface).
|
||||
|
||||
The Subsonic /rest layer itself returns its own XML/JSON envelope, not these
|
||||
pydantic models — these only back the lifecycle endpoints that reveal/rotate the
|
||||
recoverable app-password."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SubsonicPasswordResponse(BaseModel):
|
||||
"""The plaintext Subsonic app-password, for pasting into a client."""
|
||||
|
||||
password: str
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Track request/response schemas."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.schemas.download import DownloadJobOut
|
||||
|
||||
|
||||
class TrackOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
artist_id: uuid.UUID
|
||||
artist_name: str
|
||||
album_id: uuid.UUID | None
|
||||
album_title: str | None
|
||||
duration_seconds: int | None
|
||||
file_format: str | None
|
||||
file_size: int | None
|
||||
genre: str | None
|
||||
year: int | None
|
||||
track_number: int | None
|
||||
metadata_status: str
|
||||
metadata_error: str | None
|
||||
enriched_at: dt.datetime | None
|
||||
availability: str
|
||||
source: str
|
||||
has_cover: bool
|
||||
created_at: dt.datetime
|
||||
|
||||
|
||||
class TrackUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
genre: str | None = None
|
||||
year: int | None = None
|
||||
|
||||
|
||||
class MetadataMatch(BaseModel):
|
||||
"""One AcoustID candidate for the metadata editor's match picker (§A7)."""
|
||||
|
||||
acoustid: str
|
||||
score: float
|
||||
recording_mbid: str | None
|
||||
release_group_mbid: str | None
|
||||
title: str | None
|
||||
artist: str | None
|
||||
album: str | None
|
||||
year: int | None
|
||||
|
||||
|
||||
class MetadataMatchesOut(BaseModel):
|
||||
items: list[MetadataMatch]
|
||||
|
||||
|
||||
class MetadataApply(BaseModel):
|
||||
"""Manual edits / accepted match applied via ``PUT /tracks/{id}/metadata``.
|
||||
|
||||
Sets ``metadata_status = manual`` (never overwritten by auto-enrichment)."""
|
||||
|
||||
title: str | None = None
|
||||
artist_name: str | None = None
|
||||
album_title: str | None = None
|
||||
year: int | None = None
|
||||
genre: str | None = None
|
||||
track_number: int | None = None
|
||||
|
||||
|
||||
class RemoteTrackSave(BaseModel):
|
||||
"""Save a remote browse hit (§A4 discover) as a library placeholder —
|
||||
``availability="remote"``, no audio until first play (plan: Model C)."""
|
||||
|
||||
source: str
|
||||
source_id: str = Field(min_length=1)
|
||||
title: str
|
||||
artist: str | None = None
|
||||
|
||||
|
||||
class MaterializeResponse(BaseModel):
|
||||
"""Result of requesting that a placeholder track's audio be fetched.
|
||||
|
||||
``job`` is ``None`` when the track is already ``local`` — nothing to wait
|
||||
for, the caller can stream immediately. Otherwise it's the (new or
|
||||
already in-flight) job; poll ``GET /downloads/{job.id}`` until ``done``."""
|
||||
|
||||
track: TrackOut
|
||||
job: DownloadJobOut | None
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Schemas for upload responses."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
track_id: uuid.UUID
|
||||
title: str
|
||||
already_exists: bool
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.health import router as health_router
|
||||
from app.api.v1.admin import router as admin_router
|
||||
from app.api.v1.albums import router as albums_router
|
||||
from app.api.v1.artists import router as artists_router
|
||||
@@ -22,6 +23,9 @@ from app.api.v1.user_settings import router as user_settings_router
|
||||
from app.api.v1.users import router as users_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
# Also expose health under /api/v1 (root /health stays in main.py for compose/nginx
|
||||
# probes); the webui pings ${apiBase}/health and apiBase is /api/v1.
|
||||
api_v1_router.include_router(health_router)
|
||||
api_v1_router.include_router(auth_router)
|
||||
api_v1_router.include_router(users_router)
|
||||
api_v1_router.include_router(tracks_router)
|
||||
|
||||
+10
-1
@@ -9,7 +9,8 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.api.deps import SuperUser, UserServiceDep
|
||||
from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
||||
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||
from app.api.schemas.user import (
|
||||
CreateUserRequest,
|
||||
ResetPasswordRequest,
|
||||
@@ -81,6 +82,14 @@ async def deactivate_user(
|
||||
return UserResponse.from_entity(await users.deactivate(user_id))
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||
async def rotate_user_subsonic_password(
|
||||
user_id: uuid.UUID, _admin: SuperUser, subsonic: SubsonicAuthServiceDep
|
||||
) -> SubsonicPasswordResponse:
|
||||
"""Rotate any user's Subsonic app-password and return the new plaintext."""
|
||||
return SubsonicPasswordResponse(password=await subsonic.rotate(user_id))
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def list_services(_admin: SuperUser) -> Any: ...
|
||||
|
||||
|
||||
+113
-6
@@ -1,24 +1,131 @@
|
||||
"""Album endpoints."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.covers import stream_cover
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
FileStorageDep,
|
||||
StreamUser,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.album import AlbumOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.track import Artist
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/albums", tags=["albums"])
|
||||
|
||||
|
||||
async def _build_album_out(
|
||||
albums: list[Album],
|
||||
artists: dict[uuid.UUID, Artist],
|
||||
track_counts: dict[uuid.UUID, int],
|
||||
) -> list[AlbumOut]:
|
||||
return [
|
||||
AlbumOut(
|
||||
id=a.id,
|
||||
title=a.title,
|
||||
artist_id=a.artist_id,
|
||||
artist_name=artists[a.artist_id].name if a.artist_id in artists else "Unknown Artist",
|
||||
year=a.year,
|
||||
track_count=track_counts.get(a.id, 0),
|
||||
has_cover=bool(a.cover_path),
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in albums
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_albums() -> Any: ...
|
||||
async def list_albums(
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
_: CurrentUser,
|
||||
artist_id: uuid.UUID | None = None,
|
||||
q: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[AlbumOut]:
|
||||
albums = await album_repo.list(artist_id=artist_id, q=q, limit=limit, offset=offset)
|
||||
total = await album_repo.count(artist_id=artist_id, q=q)
|
||||
|
||||
artist_ids = list({a.artist_id for a in albums})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
track_counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
|
||||
items = await _build_album_out(albums, artists, track_counts)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{album_id}")
|
||||
async def get_album(album_id: uuid.UUID) -> Any: ...
|
||||
async def get_album(
|
||||
album_id: uuid.UUID,
|
||||
album_repo: AlbumRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> AlbumOut:
|
||||
album = await album_repo.get_by_id(album_id)
|
||||
if album is None:
|
||||
raise NotFoundError(f"Album {album_id} not found.")
|
||||
|
||||
artists = {a.id: a for a in await artist_repo.get_many([album.artist_id])}
|
||||
track_counts = await album_repo.track_count_many([album.id])
|
||||
|
||||
items = await _build_album_out([album], artists, track_counts)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.get("/{album_id}/tracks")
|
||||
async def get_album_tracks(album_id: uuid.UUID) -> Any: ...
|
||||
async def get_album_tracks(
|
||||
album_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[TrackOut]:
|
||||
album = await album_repo.get_by_id(album_id)
|
||||
if album is None:
|
||||
raise NotFoundError(f"Album {album_id} not found.")
|
||||
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=album_id,
|
||||
q=None,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = await track_repo.count(artist_id=None, album_id=album_id, q=None)
|
||||
|
||||
artist_ids = list({t.artist_id for t in tracks})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {album.id: album}
|
||||
|
||||
items = await _build_track_out(tracks, artists, albums)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{album_id}/cover")
|
||||
async def get_album_cover(album_id: uuid.UUID) -> Any: ...
|
||||
async def get_album_cover(
|
||||
album_id: uuid.UUID,
|
||||
album_repo: AlbumRepoDep,
|
||||
storage: FileStorageDep,
|
||||
_: StreamUser,
|
||||
) -> StreamingResponse:
|
||||
# ``<img>`` can't send a bearer header → StreamUser accepts ``?token=``.
|
||||
album = await album_repo.get_by_id(album_id)
|
||||
if album is None or not album.cover_path:
|
||||
raise NotFoundError("Cover not found.")
|
||||
return await stream_cover(storage, album.cover_path)
|
||||
|
||||
+105
-6
@@ -3,26 +3,125 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep
|
||||
from app.api.schemas.album import AlbumOut
|
||||
from app.api.schemas.artist import ArtistOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.albums import _build_album_out
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/artists", tags=["artists"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_artists() -> Any: ...
|
||||
async def list_artists(
|
||||
artist_repo: ArtistRepoDep,
|
||||
_: CurrentUser,
|
||||
q: str | None = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[ArtistOut]:
|
||||
artists = await artist_repo.list(q=q, limit=limit, offset=offset)
|
||||
total = await artist_repo.count(q=q)
|
||||
|
||||
items = []
|
||||
for a in artists:
|
||||
album_cnt = await artist_repo.album_count(a.id)
|
||||
track_cnt = await artist_repo.track_count(a.id)
|
||||
items.append(
|
||||
ArtistOut(
|
||||
id=a.id,
|
||||
name=a.name,
|
||||
album_count=album_cnt,
|
||||
track_count=track_cnt,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{artist_id}")
|
||||
async def get_artist(artist_id: uuid.UUID) -> Any: ...
|
||||
async def get_artist(
|
||||
artist_id: uuid.UUID,
|
||||
artist_repo: ArtistRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> ArtistOut:
|
||||
artist = await artist_repo.get_by_id(artist_id)
|
||||
if artist is None:
|
||||
raise NotFoundError(f"Artist {artist_id} not found.")
|
||||
|
||||
album_cnt = await artist_repo.album_count(artist_id)
|
||||
track_cnt = await artist_repo.track_count(artist_id)
|
||||
|
||||
return ArtistOut(
|
||||
id=artist.id,
|
||||
name=artist.name,
|
||||
album_count=album_cnt,
|
||||
track_count=track_cnt,
|
||||
created_at=artist.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{artist_id}/albums")
|
||||
async def get_artist_albums(artist_id: uuid.UUID) -> Any: ...
|
||||
async def get_artist_albums(
|
||||
artist_id: uuid.UUID,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[AlbumOut]:
|
||||
artist = await artist_repo.get_by_id(artist_id)
|
||||
if artist is None:
|
||||
raise NotFoundError(f"Artist {artist_id} not found.")
|
||||
|
||||
albums = await album_repo.list(artist_id=artist_id, q=None, limit=limit, offset=offset)
|
||||
total = await album_repo.count(artist_id=artist_id, q=None)
|
||||
|
||||
artists_map = {artist.id: artist}
|
||||
track_counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
|
||||
items = await _build_album_out(albums, artists_map, track_counts)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{artist_id}/tracks")
|
||||
async def get_artist_tracks(artist_id: uuid.UUID) -> Any: ...
|
||||
async def get_artist_tracks(
|
||||
artist_id: uuid.UUID,
|
||||
artist_repo: ArtistRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[TrackOut]:
|
||||
artist = await artist_repo.get_by_id(artist_id)
|
||||
if artist is None:
|
||||
raise NotFoundError(f"Artist {artist_id} not found.")
|
||||
|
||||
tracks = await track_repo.list(
|
||||
artist_id=artist_id,
|
||||
album_id=None,
|
||||
q=None,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = await track_repo.count(artist_id=artist_id, album_id=None, q=None)
|
||||
|
||||
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
|
||||
artists_map = {artist.id: artist}
|
||||
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out(tracks, artists_map, albums_map)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{artist_id}/similar")
|
||||
async def get_similar_artists(artist_id: uuid.UUID) -> Any: ...
|
||||
async def get_similar_artists(artist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
|
||||
+25
-2
@@ -2,9 +2,16 @@
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import AuthServiceDep, CurrentUser
|
||||
from app.api.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||
from app.api.deps import AuthServiceDep, CurrentUser, UserServiceDep
|
||||
from app.api.schemas.auth import (
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.api.schemas.user import UserResponse
|
||||
from app.core.config import get_settings
|
||||
from app.domain.errors import PermissionDeniedError
|
||||
from app.domain.tokens import TokenPair
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -23,6 +30,22 @@ async def login(body: LoginRequest, auth: AuthServiceDep) -> TokenResponse:
|
||||
return _to_token_response(pair)
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
body: RegisterRequest, users: UserServiceDep, auth: AuthServiceDep
|
||||
) -> TokenResponse:
|
||||
"""Public self-service sign-up (gated by ``ALLOW_REGISTRATION``).
|
||||
|
||||
Registered accounts are always regular users — superusers are created
|
||||
admin-only. On success the new account is logged straight in.
|
||||
"""
|
||||
if not get_settings().allow_registration:
|
||||
raise PermissionDeniedError("Registration is disabled on this instance.")
|
||||
await users.create_user(username=body.username, password=body.password, is_superuser=False)
|
||||
pair = await auth.login(body.username, body.password)
|
||||
return _to_token_response(pair)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse:
|
||||
pair = await auth.refresh(body.refresh_token)
|
||||
|
||||
+60
-18
@@ -1,36 +1,78 @@
|
||||
"""Download job endpoints. Heavy work is dispatched to arq workers."""
|
||||
"""Download job endpoints (§A5). Heavy work is dispatched to arq workers — these
|
||||
handlers only create/inspect/cancel/retry job records."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import CurrentUser, DownloadServiceDep
|
||||
from app.api.schemas.download import DownloadCreate, DownloadCreateResponse, DownloadJobOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
|
||||
router = APIRouter(prefix="/downloads", tags=["downloads"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_downloads() -> Any: ...
|
||||
async def list_downloads(
|
||||
service: DownloadServiceDep,
|
||||
user: CurrentUser,
|
||||
status: str | None = Query(default=None),
|
||||
mine: bool = Query(default=False),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[DownloadJobOut]:
|
||||
jobs, total = await service.list(
|
||||
requested_by=user.id if mine else None,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return PagedResponse(
|
||||
items=[DownloadJobOut.from_entity(j) for j in jobs],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_download() -> Any: ...
|
||||
@router.post("", status_code=202)
|
||||
async def create_download(
|
||||
body: DownloadCreate,
|
||||
service: DownloadServiceDep,
|
||||
user: CurrentUser,
|
||||
) -> DownloadCreateResponse:
|
||||
result = await service.request(
|
||||
source=body.source,
|
||||
source_id=body.source_id,
|
||||
query=body.query,
|
||||
requested_by=user.id,
|
||||
)
|
||||
return DownloadCreateResponse(
|
||||
already_in_library=result.already_in_library,
|
||||
track_id=result.track_id,
|
||||
job=DownloadJobOut.from_entity(result.job) if result.job is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
async def get_download(job_id: uuid.UUID) -> Any: ...
|
||||
async def get_download(
|
||||
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
|
||||
) -> DownloadJobOut:
|
||||
job = await service.get(job_id)
|
||||
return DownloadJobOut.from_entity(job)
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def cancel_download(job_id: uuid.UUID) -> Any: ...
|
||||
@router.delete("/{job_id}", status_code=204)
|
||||
async def cancel_download(
|
||||
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
|
||||
) -> Response:
|
||||
await service.cancel(job_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/{job_id}/retry")
|
||||
async def retry_download(job_id: uuid.UUID) -> Any: ...
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
async def pause_downloads() -> Any: ...
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
async def resume_downloads() -> Any: ...
|
||||
async def retry_download(
|
||||
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
|
||||
) -> DownloadJobOut:
|
||||
job = await service.retry(job_id)
|
||||
return DownloadJobOut.from_entity(job)
|
||||
|
||||
+44
-7
@@ -1,15 +1,52 @@
|
||||
"""Playback history endpoints."""
|
||||
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.deps import CurrentUser, HistoryRepoDep, TrackRepoDep
|
||||
from app.api.schemas.history import HistoryIn, HistoryOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/history", tags=["history"])
|
||||
|
||||
|
||||
@router.post("", status_code=204)
|
||||
async def record_history(
|
||||
body: HistoryIn,
|
||||
history_repo: HistoryRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> Response:
|
||||
track = await track_repo.get_by_id(body.track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {body.track_id} not found.")
|
||||
await history_repo.add(
|
||||
user_id=user.id,
|
||||
track_id=body.track_id,
|
||||
played_at=body.played_at,
|
||||
play_duration_seconds=body.play_duration_seconds,
|
||||
completed=body.completed,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_history() -> Any: ...
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def record_history() -> Any: ...
|
||||
async def get_history(
|
||||
history_repo: HistoryRepoDep,
|
||||
user: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[HistoryOut]:
|
||||
entries = await history_repo.list(user_id=user.id, limit=limit, offset=offset)
|
||||
total = await history_repo.count(user_id=user.id)
|
||||
items = [
|
||||
HistoryOut(
|
||||
id=e.id,
|
||||
track_id=e.track_id,
|
||||
played_at=e.played_at,
|
||||
play_duration_seconds=e.play_duration_seconds,
|
||||
completed=e.completed,
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
+50
-6
@@ -1,19 +1,63 @@
|
||||
"""Like endpoints. Likes are an append-only event-log — never updated in place."""
|
||||
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, LikeRepoDep
|
||||
from app.api.schemas.like import LikeEvent, LikeState
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
|
||||
router = APIRouter(prefix="/likes", tags=["likes"])
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def add_like(
|
||||
body: LikeEvent,
|
||||
like_repo: LikeRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> LikeState:
|
||||
like = await like_repo.add(user_id=user.id, track_id=body.track_id, value=body.value)
|
||||
return LikeState(track_id=like.track_id, value=like.value, updated_at=like.created_at)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_likes() -> Any: ...
|
||||
async def get_likes(
|
||||
like_repo: LikeRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
user: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[TrackOut]:
|
||||
tracks = await like_repo.list_liked_tracks(user_id=user.id, limit=limit, offset=offset)
|
||||
total = await like_repo.count_liked_tracks(user_id=user.id)
|
||||
|
||||
artist_ids = list({t.artist_id for t in tracks})
|
||||
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
|
||||
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
@router.post("")
|
||||
async def add_like() -> Any: ...
|
||||
items = await _build_track_out(tracks, artists_map, albums_map)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/state")
|
||||
async def get_likes_state() -> Any: ...
|
||||
async def get_likes_state(
|
||||
like_repo: LikeRepoDep,
|
||||
user: CurrentUser,
|
||||
track_ids: str = Query(default=""),
|
||||
) -> list[LikeState]:
|
||||
ids: list[uuid.UUID] = []
|
||||
if track_ids:
|
||||
try:
|
||||
ids = [uuid.UUID(tid.strip()) for tid in track_ids.split(",") if tid.strip()]
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
likes = await like_repo.get_latest_state(user_id=user.id, track_ids=ids)
|
||||
return [
|
||||
LikeState(track_id=lk.track_id, value=lk.value, updated_at=lk.created_at) for lk in likes
|
||||
]
|
||||
|
||||
+156
-15
@@ -3,46 +3,187 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
PlaylistRepoDep,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.playlist import PlaylistAddTrack, PlaylistCreate, PlaylistOut, PlaylistUpdate
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
from app.domain.entities.playlist import Playlist
|
||||
from app.domain.errors import NotFoundError, PermissionDeniedError
|
||||
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
|
||||
|
||||
async def _build_playlist_out(
|
||||
playlists: list[Playlist], playlist_repo: SqlAlchemyPlaylistRepository
|
||||
) -> list[PlaylistOut]:
|
||||
ids = [p.id for p in playlists]
|
||||
counts = await playlist_repo.track_count_many(ids)
|
||||
return [
|
||||
PlaylistOut(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
owner_id=p.owner_id,
|
||||
version=p.version,
|
||||
track_count=counts.get(p.id, 0),
|
||||
created_at=p.created_at,
|
||||
)
|
||||
for p in playlists
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_playlists() -> Any: ...
|
||||
async def list_playlists(
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
user: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[PlaylistOut]:
|
||||
playlists = await playlist_repo.list(owner_id=user.id, limit=limit, offset=offset)
|
||||
total = await playlist_repo.count(owner_id=user.id)
|
||||
items = await _build_playlist_out(playlists, playlist_repo)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_playlist() -> Any: ...
|
||||
@router.post("", status_code=201)
|
||||
async def create_playlist(
|
||||
body: PlaylistCreate,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> PlaylistOut:
|
||||
playlist = await playlist_repo.add(
|
||||
name=body.name, description=body.description, owner_id=user.id
|
||||
)
|
||||
items = await _build_playlist_out([playlist], playlist_repo)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
async def get_playlist(playlist_id: uuid.UUID) -> Any: ...
|
||||
async def get_playlist(
|
||||
playlist_id: uuid.UUID,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> PlaylistOut:
|
||||
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
items = await _build_playlist_out([playlist], playlist_repo)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.patch("/{playlist_id}")
|
||||
async def update_playlist(playlist_id: uuid.UUID) -> Any: ...
|
||||
async def update_playlist(
|
||||
playlist_id: uuid.UUID,
|
||||
body: PlaylistUpdate,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> PlaylistOut:
|
||||
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
if playlist.owner_id != user.id:
|
||||
raise PermissionDeniedError("You don't own this playlist.")
|
||||
updated = await playlist_repo.update(playlist_id, name=body.name, description=body.description)
|
||||
items = await _build_playlist_out([updated], playlist_repo)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}")
|
||||
async def delete_playlist(playlist_id: uuid.UUID) -> Any: ...
|
||||
@router.delete("/{playlist_id}", status_code=204)
|
||||
async def delete_playlist(
|
||||
playlist_id: uuid.UUID,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> Response:
|
||||
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
if playlist.owner_id != user.id:
|
||||
raise PermissionDeniedError("You don't own this playlist.")
|
||||
await playlist_repo.delete(playlist_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}/tracks")
|
||||
async def get_playlist_tracks(playlist_id: uuid.UUID) -> Any: ...
|
||||
async def get_playlist_tracks(
|
||||
playlist_id: uuid.UUID,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[TrackOut]:
|
||||
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
|
||||
tracks = await playlist_repo.get_tracks(playlist_id, limit=limit, offset=offset)
|
||||
total = await playlist_repo.get_track_total(playlist_id)
|
||||
|
||||
artist_ids = list({t.artist_id for t in tracks})
|
||||
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
|
||||
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out(tracks, artists_map, albums_map)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/tracks")
|
||||
async def add_playlist_tracks(playlist_id: uuid.UUID) -> Any: ...
|
||||
@router.post("/{playlist_id}/tracks", status_code=204)
|
||||
async def add_playlist_track(
|
||||
playlist_id: uuid.UUID,
|
||||
body: PlaylistAddTrack,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
track_repo: TrackRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> Response:
|
||||
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
if playlist.owner_id != user.id:
|
||||
raise PermissionDeniedError("You don't own this playlist.")
|
||||
|
||||
track = await track_repo.get_by_id(body.track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {body.track_id} not found.")
|
||||
|
||||
position = body.position
|
||||
if position is None:
|
||||
position = await playlist_repo.max_position(playlist_id) + 1.0
|
||||
|
||||
await playlist_repo.add_track(playlist_id, body.track_id, position=position)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}/tracks/{track_id}")
|
||||
async def remove_playlist_track(playlist_id: uuid.UUID, track_id: uuid.UUID) -> Any: ...
|
||||
@router.delete("/{playlist_id}/tracks/{track_id}", status_code=204)
|
||||
async def remove_playlist_track(
|
||||
playlist_id: uuid.UUID,
|
||||
track_id: uuid.UUID,
|
||||
playlist_repo: PlaylistRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> Response:
|
||||
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||
if playlist is None:
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
if playlist.owner_id != user.id:
|
||||
raise PermissionDeniedError("You don't own this playlist.")
|
||||
await playlist_repo.remove_track(playlist_id, track_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.put("/{playlist_id}/tracks/reorder")
|
||||
async def reorder_playlist_tracks(playlist_id: uuid.UUID) -> Any: ...
|
||||
async def reorder_playlist_tracks(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
|
||||
|
||||
@router.get("/{playlist_id}/cover")
|
||||
async def get_playlist_cover(playlist_id: uuid.UUID) -> Any: ...
|
||||
async def get_playlist_cover(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
|
||||
+90
-4
@@ -1,15 +1,101 @@
|
||||
"""Search endpoints: global and library-scoped."""
|
||||
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, SourceRegistryDep, TrackRepoDep
|
||||
from app.api.schemas.album import AlbumOut
|
||||
from app.api.schemas.artist import ArtistOut
|
||||
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
|
||||
from app.api.schemas.search import LibrarySearchResponse
|
||||
from app.api.schemas.track import TrackOut
|
||||
from app.api.v1.albums import _build_album_out
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def search() -> Any: ...
|
||||
async def search(
|
||||
_: CurrentUser,
|
||||
registry: SourceRegistryDep,
|
||||
track_repo: TrackRepoDep,
|
||||
q: str = Query(min_length=1),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
) -> ExternalSearchResponse:
|
||||
"""Search every available fetch source and merge the hits (§A4 discover).
|
||||
|
||||
A source that is down contributes nothing rather than failing the whole
|
||||
request (graceful degradation); only available sources are reported as
|
||||
searched. Each hit is checked against the library by ``(source,
|
||||
source_id)`` so the UI can show "Saved"/"Play" instead of "Save to
|
||||
library" without a separate round-trip (remote browse, plan: Model C)."""
|
||||
results: list[ExternalSearchResultOut] = []
|
||||
searched: list[str] = []
|
||||
for backend in registry.searchables():
|
||||
if not backend.is_available():
|
||||
continue
|
||||
searched.append(backend.name)
|
||||
hits = await backend.search(q, limit=limit)
|
||||
for h in hits:
|
||||
existing = await track_repo.get_by_source(h.source, h.source_id)
|
||||
results.append(ExternalSearchResultOut.from_entity(h, existing=existing))
|
||||
return ExternalSearchResponse(results=results, searched_sources=searched)
|
||||
|
||||
|
||||
@router.get("/library")
|
||||
async def search_library() -> Any: ...
|
||||
async def search_library(
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
q: str = Query(min_length=1),
|
||||
types: str = Query(default="tracks,albums,artists"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> LibrarySearchResponse:
|
||||
requested = {t.strip() for t in types.split(",")}
|
||||
|
||||
tracks_out: list[TrackOut] = []
|
||||
albums_out: list[AlbumOut] = []
|
||||
artists_out: list[ArtistOut] = []
|
||||
|
||||
if "tracks" in requested:
|
||||
tracks = await track_repo.list(
|
||||
artist_id=None,
|
||||
album_id=None,
|
||||
q=q,
|
||||
sort_by="title",
|
||||
order="asc",
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
if tracks:
|
||||
artist_ids = list({t.artist_id for t in tracks})
|
||||
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
|
||||
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums_map = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
tracks_out = await _build_track_out(tracks, artists_map, albums_map)
|
||||
|
||||
if "albums" in requested:
|
||||
albums = await album_repo.list(artist_id=None, q=q, limit=limit, offset=0)
|
||||
if albums:
|
||||
artist_ids = list({a.artist_id for a in albums})
|
||||
artists_map = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
track_counts = await album_repo.track_count_many([a.id for a in albums])
|
||||
albums_out = await _build_album_out(albums, artists_map, track_counts)
|
||||
|
||||
if "artists" in requested:
|
||||
raw_artists = await artist_repo.list(q=q, limit=limit, offset=0)
|
||||
for a in raw_artists:
|
||||
album_cnt = await artist_repo.album_count(a.id)
|
||||
track_cnt = await artist_repo.track_count(a.id)
|
||||
artists_out.append(
|
||||
ArtistOut(
|
||||
id=a.id,
|
||||
name=a.name,
|
||||
album_count=album_cnt,
|
||||
track_count=track_cnt,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return LibrarySearchResponse(tracks=tracks_out, albums=albums_out, artists=artists_out)
|
||||
|
||||
+45
-7
@@ -1,19 +1,57 @@
|
||||
"""External source endpoints (yt-dlp etc.)."""
|
||||
"""External source endpoints: enumerate sources, search, and trigger imports.
|
||||
|
||||
from typing import Any
|
||||
Listing/health/search are read-only (any authenticated user). Scanning a source
|
||||
is an admin action and runs in a worker — the endpoint only enqueues it.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser, TrackRepoDep
|
||||
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
|
||||
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
|
||||
from app.domain.errors import DependencyUnavailableError
|
||||
from app.workers.queue import enqueue
|
||||
|
||||
router = APIRouter(prefix="/sources", tags=["sources"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_sources() -> Any: ...
|
||||
async def list_sources(_: CurrentUser, registry: SourceRegistryDep) -> list[SourceInfoOut]:
|
||||
return [SourceInfoOut.from_entity(info) for info in registry.infos()]
|
||||
|
||||
|
||||
@router.get("/{source}/search")
|
||||
async def search_source(source: str) -> Any: ...
|
||||
@router.post("/{source}/scan")
|
||||
async def scan_source(source: str, user: SuperUser, registry: SourceRegistryDep) -> ScanResponse:
|
||||
backend = registry.indexable(source) # 404 if unknown, 422 if not indexable
|
||||
if not backend.is_available():
|
||||
raise DependencyUnavailableError(f"Source {source!r} is not available.")
|
||||
job_id = await enqueue("scan_local_folder", source=source, added_by=str(user.id))
|
||||
return ScanResponse(source=source, job_id=job_id)
|
||||
|
||||
|
||||
@router.get("/{source}/health")
|
||||
async def source_health(source: str) -> Any: ...
|
||||
async def source_health(
|
||||
source: str, _: CurrentUser, registry: SourceRegistryDep
|
||||
) -> SourceHealthOut:
|
||||
backend = registry.get(source) # 404 if unknown
|
||||
return SourceHealthOut(name=backend.name, available=backend.is_available())
|
||||
|
||||
|
||||
@router.get("/{source}/search")
|
||||
async def search_source(
|
||||
source: str,
|
||||
_: CurrentUser,
|
||||
registry: SourceRegistryDep,
|
||||
track_repo: TrackRepoDep,
|
||||
q: str = Query(min_length=1),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
) -> ExternalSearchResponse:
|
||||
backend = registry.searchable(source) # 404 if unknown, 422 if not searchable
|
||||
if not backend.is_available():
|
||||
raise DependencyUnavailableError(f"Source {source!r} is not available.")
|
||||
results = await backend.search(q, limit=limit)
|
||||
out: list[ExternalSearchResultOut] = []
|
||||
for r in results:
|
||||
existing = await track_repo.get_by_source(r.source, r.source_id)
|
||||
out.append(ExternalSearchResultOut.from_entity(r, existing=existing))
|
||||
return ExternalSearchResponse(results=out, searched_sources=[source])
|
||||
|
||||
+59
-1
@@ -4,11 +4,69 @@ 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")
|
||||
|
||||
+28
-9
@@ -1,20 +1,39 @@
|
||||
"""Audio streaming endpoints: direct stream and HLS."""
|
||||
"""Audio streaming endpoint — direct stream with Range support."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Header
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.deps import StreamingServiceDep, StreamUser
|
||||
|
||||
router = APIRouter(prefix="/stream", tags=["streaming"])
|
||||
|
||||
|
||||
@router.get("/{track_id}")
|
||||
async def stream_track(track_id: uuid.UUID) -> Any: ...
|
||||
async def stream_track(
|
||||
track_id: uuid.UUID,
|
||||
service: StreamingServiceDep,
|
||||
_user: StreamUser,
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> StreamingResponse:
|
||||
result = await service.open_stream(track_id, range_header)
|
||||
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(result.content_length),
|
||||
}
|
||||
|
||||
@router.get("/{track_id}/hls/playlist.m3u8")
|
||||
async def hls_playlist(track_id: uuid.UUID) -> Any: ...
|
||||
if result.is_partial:
|
||||
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
||||
status_code = 206
|
||||
else:
|
||||
status_code = 200
|
||||
|
||||
|
||||
@router.get("/{track_id}/hls/{segment}")
|
||||
async def hls_segment(track_id: uuid.UUID, segment: str) -> Any: ...
|
||||
return StreamingResponse(
|
||||
result.stream,
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
media_type=result.content_type,
|
||||
)
|
||||
|
||||
+303
-13
@@ -1,48 +1,338 @@
|
||||
"""Track endpoints (library CRUD, similarity, optimization, cover, metadata, streaming)."""
|
||||
"""Track endpoints."""
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.covers import resolve_album_for_track, stream_cover
|
||||
from app.api.deps import (
|
||||
AlbumRepoDep,
|
||||
ArtistRepoDep,
|
||||
CurrentUser,
|
||||
FileStorageDep,
|
||||
MetadataServiceDep,
|
||||
RemoteLibraryServiceDep,
|
||||
StreamUser,
|
||||
TrackRepoDep,
|
||||
)
|
||||
from app.api.schemas.download import DownloadJobOut
|
||||
from app.api.schemas.pagination import PagedResponse
|
||||
from app.api.schemas.track import (
|
||||
MaterializeResponse,
|
||||
MetadataApply,
|
||||
MetadataMatch,
|
||||
MetadataMatchesOut,
|
||||
RemoteTrackSave,
|
||||
TrackOut,
|
||||
TrackUpdate,
|
||||
)
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.track import Artist, Track
|
||||
from app.domain.errors import NotFoundError
|
||||
from app.workers.queue import enqueue
|
||||
|
||||
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
||||
|
||||
|
||||
async def _build_track_out(
|
||||
tracks: list[Track],
|
||||
artists: dict[uuid.UUID, Artist],
|
||||
albums: dict[uuid.UUID, Album],
|
||||
) -> list[TrackOut]:
|
||||
return [
|
||||
TrackOut(
|
||||
id=t.id,
|
||||
title=t.title,
|
||||
artist_id=t.artist_id,
|
||||
artist_name=artists[t.artist_id].name if t.artist_id in artists else "Unknown Artist",
|
||||
album_id=t.album_id,
|
||||
album_title=albums[t.album_id].title if t.album_id and t.album_id in albums else None,
|
||||
duration_seconds=t.duration_seconds,
|
||||
file_format=t.file_format,
|
||||
file_size=t.file_size,
|
||||
genre=t.genre,
|
||||
year=t.year,
|
||||
track_number=t.track_number,
|
||||
metadata_status=t.metadata_status,
|
||||
metadata_error=t.metadata_error,
|
||||
enriched_at=t.enriched_at,
|
||||
availability=t.availability,
|
||||
source=t.source,
|
||||
has_cover=bool(t.album_id and albums.get(t.album_id) and albums[t.album_id].cover_path),
|
||||
created_at=t.created_at,
|
||||
)
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tracks() -> Any: ...
|
||||
async def list_tracks(
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
artist_id: uuid.UUID | None = None,
|
||||
album_id: uuid.UUID | None = None,
|
||||
q: str | None = None,
|
||||
source: str | None = Query(None, max_length=32),
|
||||
sort_by: str = Query("created_at", pattern="^(title|created_at|artist)$"),
|
||||
order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PagedResponse[TrackOut]:
|
||||
tracks = await track_repo.list(
|
||||
artist_id=artist_id,
|
||||
album_id=album_id,
|
||||
q=q,
|
||||
source=source,
|
||||
sort_by=sort_by,
|
||||
order=order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = await track_repo.count(artist_id=artist_id, album_id=album_id, q=q, source=source)
|
||||
|
||||
artist_ids = list({t.artist_id for t in tracks})
|
||||
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out(tracks, artists, albums)
|
||||
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/remote", status_code=201)
|
||||
async def save_remote_track(
|
||||
body: RemoteTrackSave,
|
||||
service: RemoteLibraryServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> TrackOut:
|
||||
"""Save a remote browse hit (§A4 discover) as a library placeholder —
|
||||
no audio is fetched yet (plan: Model C). Idempotent on ``(source,
|
||||
source_id)``: saving an already-saved hit returns the existing track."""
|
||||
track = await service.save_remote(
|
||||
source=body.source,
|
||||
source_id=body.source_id,
|
||||
title=body.title,
|
||||
artist=body.artist,
|
||||
added_by=user.id,
|
||||
)
|
||||
|
||||
artists = {a.id: a for a in await artist_repo.get_many([track.artist_id])}
|
||||
album_ids = [track.album_id] if track.album_id else []
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
items = await _build_track_out([track], artists, albums)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.post("/{track_id}/materialize")
|
||||
async def materialize_track(
|
||||
track_id: uuid.UUID,
|
||||
service: RemoteLibraryServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
user: CurrentUser,
|
||||
) -> MaterializeResponse:
|
||||
"""Fetch a placeholder track's audio on demand (plan: Model C lazy
|
||||
materialization). Already-local tracks return ``job=None`` — nothing to
|
||||
wait for. Otherwise poll ``GET /downloads/{job.id}`` until ``done``, then
|
||||
stream as usual."""
|
||||
outcome = await service.request_materialize(track_id, requested_by=user.id)
|
||||
|
||||
artists = {a.id: a for a in await artist_repo.get_many([outcome.track.artist_id])}
|
||||
album_ids = [outcome.track.album_id] if outcome.track.album_id else []
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
track_out = (await _build_track_out([outcome.track], artists, albums))[0]
|
||||
|
||||
return MaterializeResponse(
|
||||
track=track_out,
|
||||
job=DownloadJobOut.from_entity(outcome.job) if outcome.job is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{track_id}")
|
||||
async def get_track(track_id: uuid.UUID) -> Any: ...
|
||||
async def get_track(
|
||||
track_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> TrackOut:
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
|
||||
artist_ids = [track.artist_id]
|
||||
album_ids = [track.album_id] if track.album_id else []
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out([track], artists, albums)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.patch("/{track_id}")
|
||||
async def update_track(track_id: uuid.UUID) -> Any: ...
|
||||
async def update_track(
|
||||
track_id: uuid.UUID,
|
||||
body: TrackUpdate,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> TrackOut:
|
||||
track = await track_repo.update(
|
||||
track_id,
|
||||
title=body.title,
|
||||
genre=body.genre,
|
||||
year=body.year,
|
||||
)
|
||||
|
||||
artist_ids = [track.artist_id]
|
||||
album_ids = [track.album_id] if track.album_id else []
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out([track], artists, albums)
|
||||
return items[0]
|
||||
|
||||
|
||||
@router.delete("/{track_id}")
|
||||
async def delete_track(track_id: uuid.UUID) -> Any: ...
|
||||
@router.delete("/{track_id}", status_code=204)
|
||||
async def delete_track(
|
||||
track_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
storage: FileStorageDep,
|
||||
_: CurrentUser,
|
||||
) -> Response:
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
await track_repo.delete(track_id)
|
||||
if track.storage_uri is not None:
|
||||
await storage.delete(track.storage_uri)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/{track_id}/similar")
|
||||
async def get_similar_tracks(track_id: uuid.UUID) -> Any: ...
|
||||
async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
|
||||
|
||||
@router.post("/{track_id}/optimize")
|
||||
async def optimize_track(track_id: uuid.UUID) -> Any: ...
|
||||
async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
||||
|
||||
|
||||
@router.get("/{track_id}/cover")
|
||||
async def get_track_cover(track_id: uuid.UUID) -> Any: ...
|
||||
async def get_track_cover(
|
||||
track_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
storage: FileStorageDep,
|
||||
_: StreamUser,
|
||||
) -> StreamingResponse:
|
||||
# A track's cover is its album's cover. ``<img>`` can't send a bearer
|
||||
# header → StreamUser accepts ``?token=``.
|
||||
album = await resolve_album_for_track(track_repo, album_repo, track_id)
|
||||
if album is None or not album.cover_path:
|
||||
raise NotFoundError("Cover not found.")
|
||||
return await stream_cover(storage, album.cover_path)
|
||||
|
||||
|
||||
@router.post("/{track_id}/metadata/enrich")
|
||||
async def enrich_metadata(track_id: uuid.UUID) -> Any: ...
|
||||
async def enrich_metadata(
|
||||
track_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> dict[str, str]:
|
||||
"""Re-run metadata enrichment for a track (admin/user-triggered). The work
|
||||
happens in a worker; this only enqueues it. 503 if the queue is down."""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
job_id = await enqueue("enrich_track", track_id=str(track_id))
|
||||
return {"track_id": str(track_id), "job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/{track_id}/metadata/matches")
|
||||
async def get_metadata_matches(track_id: uuid.UUID) -> Any: ...
|
||||
async def get_metadata_matches(
|
||||
track_id: uuid.UUID,
|
||||
track_repo: TrackRepoDep,
|
||||
metadata_service: MetadataServiceDep,
|
||||
_: CurrentUser,
|
||||
) -> MetadataMatchesOut:
|
||||
"""AcoustID candidates for the metadata editor's match picker (§A7).
|
||||
|
||||
Runs the fingerprint lookup inline (single track, user-triggered) and
|
||||
never mutates the track. Degrades to an empty list if fpcalc/AcoustID are
|
||||
unavailable or no match is found.
|
||||
"""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
matches = await metadata_service.find_matches(track_id)
|
||||
return MetadataMatchesOut(
|
||||
items=[
|
||||
MetadataMatch(
|
||||
acoustid=m.acoustid,
|
||||
score=m.score,
|
||||
recording_mbid=m.recording_mbid,
|
||||
release_group_mbid=m.release_group_mbid,
|
||||
title=m.title,
|
||||
artist=m.artist,
|
||||
album=m.album,
|
||||
year=m.year,
|
||||
)
|
||||
for m in matches
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{track_id}/metadata")
|
||||
async def set_metadata(track_id: uuid.UUID) -> Any: ...
|
||||
async def set_metadata(
|
||||
track_id: uuid.UUID,
|
||||
body: MetadataApply,
|
||||
track_repo: TrackRepoDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
_: CurrentUser,
|
||||
) -> TrackOut:
|
||||
"""Apply manual edits or an accepted AcoustID match (§A7). Sets
|
||||
``metadata_status = manual`` — never overwritten by auto-enrichment."""
|
||||
track = await track_repo.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
|
||||
artist_id: uuid.UUID | None = None
|
||||
if body.artist_name:
|
||||
artist = await artist_repo.get_or_create(body.artist_name)
|
||||
artist_id = artist.id
|
||||
|
||||
album_id: uuid.UUID | None = None
|
||||
if body.album_title:
|
||||
album = await album_repo.get_or_create(
|
||||
title=body.album_title,
|
||||
artist_id=artist_id or track.artist_id,
|
||||
year=body.year,
|
||||
musicbrainz_id=None,
|
||||
)
|
||||
album_id = album.id
|
||||
|
||||
track = await track_repo.update(
|
||||
track_id,
|
||||
title=body.title,
|
||||
genre=body.genre,
|
||||
year=body.year,
|
||||
artist_id=artist_id,
|
||||
album_id=album_id,
|
||||
track_number=body.track_number,
|
||||
)
|
||||
|
||||
artist_ids = [track.artist_id]
|
||||
album_ids = [track.album_id] if track.album_id else []
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
|
||||
items = await _build_track_out([track], artists, albums)
|
||||
return items[0]
|
||||
|
||||
+17
-4
@@ -1,11 +1,24 @@
|
||||
"""Local file upload endpoint."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, UploadServiceDep
|
||||
from app.api.schemas.upload import UploadResponse
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["upload"])
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def upload_file() -> Any: ...
|
||||
@router.post("", response_model=UploadResponse)
|
||||
async def upload_file(
|
||||
file: Annotated[UploadFile, File()],
|
||||
current_user: CurrentUser,
|
||||
service: UploadServiceDep,
|
||||
) -> UploadResponse:
|
||||
result = await service.handle_upload(upload=file, user=current_user)
|
||||
return UploadResponse(
|
||||
track_id=result.track_id,
|
||||
title=result.title,
|
||||
already_exists=result.already_exists,
|
||||
)
|
||||
|
||||
+21
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, UserServiceDep
|
||||
from app.api.deps import CurrentUser, SubsonicAuthServiceDep, UserServiceDep
|
||||
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||
from app.api.schemas.user import ChangePasswordRequest
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
@@ -17,3 +18,22 @@ async def change_my_password(
|
||||
current_password=body.current_password,
|
||||
new_password=body.new_password,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||
async def reveal_my_subsonic_password(
|
||||
user: CurrentUser, subsonic: SubsonicAuthServiceDep
|
||||
) -> SubsonicPasswordResponse:
|
||||
"""Reveal the caller's Subsonic app-password for copying into a client.
|
||||
|
||||
It's recoverable, so it can be read on demand; one is generated lazily on
|
||||
first access. Paste it (with the username) into Symfonium/DSub."""
|
||||
return SubsonicPasswordResponse(password=await subsonic.reveal(user.id))
|
||||
|
||||
|
||||
@router.post("/me/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||
async def rotate_my_subsonic_password(
|
||||
user: CurrentUser, subsonic: SubsonicAuthServiceDep
|
||||
) -> SubsonicPasswordResponse:
|
||||
"""Rotate the caller's Subsonic app-password (invalidates the previous one)."""
|
||||
return SubsonicPasswordResponse(password=await subsonic.rotate(user.id))
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""DownloadService — request external downloads and import their results.
|
||||
|
||||
Two roles (plan §6.1):
|
||||
|
||||
* **Request side** (HTTP): validate + dedup a download request, create a
|
||||
``queued`` job, and enqueue the worker. Dedup is on ``(source, source_id)``
|
||||
against both the library (already imported) and in-flight jobs (a double-click
|
||||
must not queue twice) — idempotency per CLAUDE.md.
|
||||
* **Worker side**: ``store_result`` turns a backend's :class:`DownloadResult`
|
||||
into a managed file + minimal ``pending`` track (sibling of
|
||||
:class:`~app.application.import_service.LibraryImportService`); enrichment
|
||||
(§6.2) fills the rest.
|
||||
|
||||
The fingerprint-level dedup (a different id that turns out to be the same audio)
|
||||
happens later in enrichment, where the fingerprint is computed.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import anyio
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.download import DownloadJob
|
||||
from app.domain.errors import NotFoundError, ValidationError
|
||||
from app.domain.ports import (
|
||||
ArtistRepository,
|
||||
DownloadJobRepository,
|
||||
FileStorage,
|
||||
TrackRepository,
|
||||
)
|
||||
from app.domain.sources import DownloadResult
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_UNKNOWN_ARTIST = "Unknown Artist"
|
||||
|
||||
# (job_id) -> None — enqueue the download worker, deferred so the job row is
|
||||
# committed before the worker reads it (same pattern as enrich).
|
||||
DownloadEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
|
||||
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadRequest:
|
||||
"""Outcome of asking for a download.
|
||||
|
||||
Exactly one of the three states holds: the item is already in the library
|
||||
(``track_id`` set, ``already_in_library``), a job already covers it / was
|
||||
just created (``job`` set), so the UI can route to the download manager.
|
||||
"""
|
||||
|
||||
job: DownloadJob | None
|
||||
track_id: uuid.UUID | None
|
||||
already_in_library: bool
|
||||
|
||||
|
||||
class DownloadService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
jobs: DownloadJobRepository,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
storage: FileStorage,
|
||||
enqueue_download: DownloadEnqueuer | None = None,
|
||||
enqueue_enrich: EnrichEnqueuer | None = None,
|
||||
) -> None:
|
||||
self._jobs = jobs
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._storage = storage
|
||||
self._enqueue_download = enqueue_download
|
||||
self._enqueue_enrich = enqueue_enrich
|
||||
|
||||
# -- request side ---------------------------------------------------------
|
||||
async def request(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str,
|
||||
query: str | None,
|
||||
requested_by: uuid.UUID | None,
|
||||
) -> DownloadRequest:
|
||||
source_id = source_id.strip()
|
||||
if not source_id:
|
||||
raise ValidationError("A source_id is required to download.")
|
||||
|
||||
existing = await self._tracks.get_by_source(source, source_id)
|
||||
if existing is not None:
|
||||
return DownloadRequest(job=None, track_id=existing.id, already_in_library=True)
|
||||
|
||||
active = await self._jobs.get_active_for_source(source, source_id)
|
||||
if active is not None:
|
||||
return DownloadRequest(job=active, track_id=None, already_in_library=False)
|
||||
|
||||
job = await self._jobs.add(
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
query=query,
|
||||
requested_by=requested_by,
|
||||
)
|
||||
if self._enqueue_download is not None:
|
||||
await self._enqueue_download(job.id)
|
||||
return DownloadRequest(job=job, track_id=None, already_in_library=False)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
requested_by: uuid.UUID | None,
|
||||
status: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[DownloadJob], int]:
|
||||
jobs = await self._jobs.list(
|
||||
requested_by=requested_by, status=status, limit=limit, offset=offset
|
||||
)
|
||||
total = await self._jobs.count(requested_by=requested_by, status=status)
|
||||
return jobs, total
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> DownloadJob:
|
||||
job = await self._jobs.get_by_id(job_id)
|
||||
if job is None:
|
||||
raise NotFoundError(f"Download job {job_id} not found.")
|
||||
return job
|
||||
|
||||
async def cancel(self, job_id: uuid.UUID) -> None:
|
||||
"""Remove the job record. True mid-flight cancellation of an in-progress
|
||||
yt-dlp download is out of scope (MVP); the worker tolerates a vanished
|
||||
job row (its status writes become no-ops)."""
|
||||
job = await self._jobs.get_by_id(job_id)
|
||||
if job is None:
|
||||
raise NotFoundError(f"Download job {job_id} not found.")
|
||||
await self._jobs.delete(job_id)
|
||||
|
||||
async def retry(self, job_id: uuid.UUID) -> DownloadJob:
|
||||
job = await self.get(job_id)
|
||||
await self._jobs.set_status(job_id, status="queued", error_message=None)
|
||||
if self._enqueue_download is not None:
|
||||
await self._enqueue_download(job_id)
|
||||
refreshed = await self._jobs.get_by_id(job_id)
|
||||
return refreshed if refreshed is not None else job
|
||||
|
||||
# -- worker side ----------------------------------------------------------
|
||||
async def store_result(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
result: DownloadResult,
|
||||
requested_by: uuid.UUID | None,
|
||||
) -> uuid.UUID:
|
||||
"""Store a freshly downloaded file and create a minimal ``pending`` track.
|
||||
|
||||
Returns the new track id (the caller enqueues enrichment after commit).
|
||||
The temp file produced by the backend is always removed."""
|
||||
track_id = uuid.uuid4()
|
||||
key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
|
||||
try:
|
||||
await self._storage.save_file(key, result.path)
|
||||
try:
|
||||
artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
|
||||
await self._tracks.add(
|
||||
id=track_id,
|
||||
title=result.suggested_title,
|
||||
artist_id=artist.id,
|
||||
storage_uri=key,
|
||||
file_format=result.file_format,
|
||||
file_size=result.file_size,
|
||||
source=source,
|
||||
source_id=result.source_id,
|
||||
metadata_status="pending",
|
||||
added_by=requested_by,
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._storage.delete(key)
|
||||
raise
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await anyio.Path(result.path).unlink(missing_ok=True)
|
||||
return track_id
|
||||
@@ -0,0 +1,106 @@
|
||||
"""LibraryImportService — imports files discovered by an indexable source.
|
||||
|
||||
Batch sibling of :class:`UploadService`: for each discovered file it dedups on
|
||||
``(source, source_id)``, copies the file into managed storage, creates a minimal
|
||||
track (artist ``Unknown Artist``, ``metadata_status=pending``), and leaves the
|
||||
rest to enrichment (plan §6.2). Per-file failures are isolated — one bad file
|
||||
must not abort the whole scan (graceful degradation).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.ports import ArtistRepository, FileStorage, IndexableSource, TrackRepository
|
||||
from app.domain.sources import SourceFile
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_UNKNOWN_ARTIST = "Unknown Artist"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportSummary:
|
||||
source: str
|
||||
seen: int
|
||||
imported: int
|
||||
skipped: int
|
||||
failed: int
|
||||
# IDs of freshly imported tracks, for the caller to enqueue enrichment
|
||||
# *after* its transaction commits (enqueuing mid-scan would race the worker).
|
||||
imported_ids: list[uuid.UUID] = field(default_factory=list)
|
||||
|
||||
|
||||
class LibraryImportService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
storage: FileStorage,
|
||||
) -> None:
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._storage = storage
|
||||
|
||||
async def scan_and_import(
|
||||
self, source: IndexableSource, *, added_by: uuid.UUID | None
|
||||
) -> ImportSummary:
|
||||
seen = skipped = failed = 0
|
||||
imported_ids: list[uuid.UUID] = []
|
||||
for file in source.scan():
|
||||
seen += 1
|
||||
try:
|
||||
existing = await self._tracks.get_by_source(source.name, file.source_id)
|
||||
if existing is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
track_id = await self._import_one(source.name, file, added_by)
|
||||
imported_ids.append(track_id)
|
||||
except Exception:
|
||||
failed += 1
|
||||
log.warning("import_file_failed", source=source.name, source_id=file.source_id)
|
||||
summary = ImportSummary(
|
||||
source=source.name,
|
||||
seen=seen,
|
||||
imported=len(imported_ids),
|
||||
skipped=skipped,
|
||||
failed=failed,
|
||||
imported_ids=imported_ids,
|
||||
)
|
||||
log.info(
|
||||
"import_complete",
|
||||
source=summary.source,
|
||||
seen=summary.seen,
|
||||
imported=summary.imported,
|
||||
skipped=summary.skipped,
|
||||
failed=summary.failed,
|
||||
)
|
||||
return summary
|
||||
|
||||
async def _import_one(
|
||||
self, source_name: str, file: SourceFile, added_by: uuid.UUID | None
|
||||
) -> uuid.UUID:
|
||||
track_id = uuid.uuid4()
|
||||
key = f"tracks/{str(track_id)[:2]}/{track_id}.{file.file_format}"
|
||||
await self._storage.save_file(key, file.path)
|
||||
try:
|
||||
artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
|
||||
await self._tracks.add(
|
||||
id=track_id,
|
||||
title=file.suggested_title,
|
||||
artist_id=artist.id,
|
||||
storage_uri=key,
|
||||
file_format=file.file_format,
|
||||
file_size=file.file_size,
|
||||
source=source_name,
|
||||
source_id=file.source_id,
|
||||
metadata_status="pending",
|
||||
added_by=added_by,
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._storage.delete(key)
|
||||
raise
|
||||
return track_id
|
||||
@@ -0,0 +1,306 @@
|
||||
"""MetadataEnrichmentService — the §6.2 pipeline orchestrator.
|
||||
|
||||
Order (tag-first): embedded tags → Chromaprint fingerprint → AcoustID lookup.
|
||||
Tags fix the common well-tagged case offline; AcoustID identifies the rest and
|
||||
supplies a MusicBrainz id. The result updates the track and sets
|
||||
``metadata_status`` to ``enriched`` (identity found) or ``failed`` (nothing).
|
||||
|
||||
Invariants (plan §6.2, CLAUDE.md):
|
||||
- **Never touch ``manual``** — a user-edited track is returned untouched.
|
||||
- **Graceful degradation** — every external step is wrapped; one failure (no
|
||||
fpcalc, no API key, service down) degrades the result, never crashes.
|
||||
- **Idempotent** — re-running only fills gaps; ``apply_enrichment`` never erases.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.cover import CoverArt
|
||||
from app.domain.entities.metadata import AudioTags, RecordingMatch
|
||||
from app.domain.ports import (
|
||||
AcoustIdClient,
|
||||
AlbumRepository,
|
||||
ArtistRepository,
|
||||
AudioFingerprinter,
|
||||
AudioTagReader,
|
||||
CoverArtExtractor,
|
||||
CoverArtProvider,
|
||||
FileStorage,
|
||||
TrackRepository,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_UNKNOWN_ARTIST = "Unknown Artist"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnrichmentResult:
|
||||
track_id: uuid.UUID
|
||||
status: str # "enriched" | "failed" | "skipped"
|
||||
matched_mbid: str | None = None
|
||||
|
||||
|
||||
class MetadataEnrichmentService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
albums: AlbumRepository,
|
||||
storage: FileStorage,
|
||||
tag_reader: AudioTagReader,
|
||||
fingerprinter: AudioFingerprinter,
|
||||
acoustid: AcoustIdClient,
|
||||
cover_extractor: CoverArtExtractor | None = None,
|
||||
cover_provider: CoverArtProvider | None = None,
|
||||
acoustid_trust_score: float = 0.85,
|
||||
) -> None:
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._albums = albums
|
||||
self._storage = storage
|
||||
self._tag_reader = tag_reader
|
||||
self._fingerprinter = fingerprinter
|
||||
self._acoustid = acoustid
|
||||
self._cover_extractor = cover_extractor
|
||||
self._cover_provider = cover_provider
|
||||
self._acoustid_trust_score = acoustid_trust_score
|
||||
|
||||
async def enrich(self, track_id: uuid.UUID) -> EnrichmentResult:
|
||||
track = await self._tracks.get_by_id(track_id)
|
||||
if track is None:
|
||||
log.info("enrich_track_missing", track_id=str(track_id))
|
||||
return EnrichmentResult(track_id=track_id, status="skipped")
|
||||
if track.metadata_status == "manual":
|
||||
log.info("enrich_skip_manual", track_id=str(track_id))
|
||||
return EnrichmentResult(track_id=track_id, status="skipped")
|
||||
storage_uri = track.storage_uri
|
||||
if storage_uri is None:
|
||||
log.info("enrich_skip_remote", track_id=str(track_id))
|
||||
return EnrichmentResult(track_id=track_id, status="skipped")
|
||||
|
||||
tags = await self._read_local(storage_uri)
|
||||
match = await self._identify(storage_uri)
|
||||
|
||||
# Merge order is tag-first by default — embedded tags fix the common
|
||||
# well-tagged offline case. But a *high-confidence* AcoustID match is the
|
||||
# more trustworthy identity (downloaded files routinely carry junk tags
|
||||
# like "Music Track"/"Sound_12345"), so above the trust threshold the
|
||||
# acoustic match wins for the identity fields and tags become fallback.
|
||||
tag_title = tags.title if tags else None
|
||||
tag_artist = tags.artist if tags else None
|
||||
tag_album = tags.album if tags else None
|
||||
match_title = match.title if match else None
|
||||
match_artist = match.artist if match else None
|
||||
match_album = match.album if match else None
|
||||
match_year = match.year if match else None
|
||||
tag_year = tags.year if tags else None
|
||||
trust_match = match is not None and match.score >= self._acoustid_trust_score
|
||||
|
||||
if trust_match:
|
||||
title = _opt_str(match_title, tag_title) or track.title
|
||||
artist_name = _opt_str(match_artist, tag_artist)
|
||||
album_title = _opt_str(match_album, tag_album)
|
||||
year = _first_int(match_year, tag_year)
|
||||
else:
|
||||
title = _opt_str(tag_title, match_title) or track.title
|
||||
artist_name = _opt_str(tag_artist, match_artist)
|
||||
album_title = _opt_str(tag_album, match_album)
|
||||
year = _first_int(tag_year, match_year)
|
||||
genre = tags.genre if tags else None
|
||||
track_number = tags.track_number if tags else None
|
||||
duration = _first_int(
|
||||
tags.duration_seconds if tags else None,
|
||||
track.duration_seconds,
|
||||
)
|
||||
bitrate = tags.bitrate if tags else None
|
||||
mbid = match.recording_mbid if match else None
|
||||
acoustid_id = match.acoustid if match else None
|
||||
|
||||
artist_id = await self._resolve_artist(artist_name, fallback=track.artist_id)
|
||||
album = await self._resolve_album(album_title, artist_id=artist_id, year=year, mbid=mbid)
|
||||
album_id = album.id if album is not None else None
|
||||
|
||||
if album is not None:
|
||||
await self._resolve_cover(
|
||||
album,
|
||||
storage_uri=storage_uri,
|
||||
release_group_mbid=match.release_group_mbid if match else None,
|
||||
)
|
||||
|
||||
identified = bool(artist_name) or album_id is not None or mbid is not None
|
||||
status = "enriched" if identified else "failed"
|
||||
# On a clean "no identity" outcome, record *why* so the UI shows a reason
|
||||
# rather than a bare "failed". A successful run clears any prior error.
|
||||
metadata_error = None if identified else self._no_match_reason()
|
||||
|
||||
await self._tracks.apply_enrichment(
|
||||
track_id,
|
||||
title=title,
|
||||
artist_id=artist_id,
|
||||
album_id=album_id,
|
||||
genre=genre,
|
||||
year=year,
|
||||
track_number=track_number,
|
||||
duration_seconds=duration,
|
||||
bitrate=bitrate,
|
||||
acoustid_fingerprint=acoustid_id,
|
||||
musicbrainz_id=mbid,
|
||||
metadata_status=status,
|
||||
metadata_error=metadata_error,
|
||||
)
|
||||
log.info("enrich_complete", track_id=str(track_id), status=status, mbid=mbid)
|
||||
return EnrichmentResult(track_id=track_id, status=status, matched_mbid=mbid)
|
||||
|
||||
def _no_match_reason(self) -> str:
|
||||
"""Explain a ``failed`` (no-identity) run in terms a user can act on:
|
||||
which optional identification step was unavailable, if any."""
|
||||
if not self._fingerprinter.is_available():
|
||||
return "No metadata match: audio fingerprinting (fpcalc) is unavailable."
|
||||
if not self._acoustid.is_available():
|
||||
return "No metadata match: AcoustID lookup is unavailable (no API key)."
|
||||
return "No metadata match found in tags or AcoustID."
|
||||
|
||||
async def find_matches(self, track_id: uuid.UUID) -> list[RecordingMatch]:
|
||||
"""AcoustID candidates for the metadata editor's match picker (§A7).
|
||||
|
||||
Read-only — unlike :meth:`enrich`, never touches the track. Runs
|
||||
inline (single track, user-triggered) rather than via the worker.
|
||||
Degrades to ``[]`` whenever fingerprinting/AcoustID is unavailable or
|
||||
the file can't be read, same as the enrichment pipeline.
|
||||
"""
|
||||
track = await self._tracks.get_by_id(track_id)
|
||||
if track is None:
|
||||
return []
|
||||
if not self._acoustid.is_available() or not self._fingerprinter.is_available():
|
||||
return []
|
||||
if track.storage_uri is None:
|
||||
return []
|
||||
try:
|
||||
async with self._storage.as_local_path(track.storage_uri) as path:
|
||||
fingerprint = await self._fingerprinter.calculate(path)
|
||||
if fingerprint is None:
|
||||
return []
|
||||
return await self._acoustid.lookup_all(fingerprint)
|
||||
except Exception:
|
||||
log.warning("find_matches_failed", track_id=str(track_id))
|
||||
return []
|
||||
|
||||
async def _read_local(self, storage_uri: str) -> AudioTags | None:
|
||||
try:
|
||||
async with self._storage.as_local_path(storage_uri) as path:
|
||||
return await self._tag_reader.read(path)
|
||||
except Exception:
|
||||
log.warning("enrich_tag_step_failed", storage_uri=storage_uri)
|
||||
return None
|
||||
|
||||
async def _identify(self, storage_uri: str) -> RecordingMatch | None:
|
||||
if not self._acoustid.is_available() or not self._fingerprinter.is_available():
|
||||
return None
|
||||
try:
|
||||
async with self._storage.as_local_path(storage_uri) as path:
|
||||
fingerprint = await self._fingerprinter.calculate(path)
|
||||
if fingerprint is None:
|
||||
return None
|
||||
return await self._acoustid.lookup(fingerprint)
|
||||
except Exception:
|
||||
log.warning("enrich_identify_step_failed", storage_uri=storage_uri)
|
||||
return None
|
||||
|
||||
async def _resolve_artist(self, name: str | None, *, fallback: uuid.UUID) -> uuid.UUID:
|
||||
if not name or name == _UNKNOWN_ARTIST:
|
||||
return fallback
|
||||
artist = await self._artists.get_or_create(name)
|
||||
return artist.id
|
||||
|
||||
async def _resolve_album(
|
||||
self,
|
||||
title: str | None,
|
||||
*,
|
||||
artist_id: uuid.UUID,
|
||||
year: int | None,
|
||||
mbid: str | None,
|
||||
) -> Album | None:
|
||||
if not title:
|
||||
return None
|
||||
return await self._albums.get_or_create(
|
||||
title=title,
|
||||
artist_id=artist_id,
|
||||
year=year,
|
||||
musicbrainz_id=mbid,
|
||||
)
|
||||
|
||||
async def _resolve_cover(
|
||||
self,
|
||||
album: Album,
|
||||
*,
|
||||
storage_uri: str,
|
||||
release_group_mbid: str | None,
|
||||
) -> None:
|
||||
"""Fill in an album cover when it has none. Source order mirrors the
|
||||
tag-first pipeline: embedded artwork (offline) → Cover Art Archive
|
||||
(network, by release-group). Best-effort — any failure is swallowed so a
|
||||
missing cover never affects enrichment status."""
|
||||
if album.cover_path:
|
||||
return # already has one — never overwrite (idempotent)
|
||||
|
||||
cover = await self._extract_cover(storage_uri)
|
||||
if cover is None:
|
||||
cover = await self._fetch_cover(release_group_mbid)
|
||||
if cover is None:
|
||||
return
|
||||
|
||||
try:
|
||||
key = await self._save_cover(album.id, cover)
|
||||
await self._albums.set_cover_path(album.id, key)
|
||||
log.info("cover_resolved", album_id=str(album.id), content_type=cover.content_type)
|
||||
except Exception:
|
||||
log.warning("cover_save_failed", album_id=str(album.id))
|
||||
|
||||
async def _extract_cover(self, storage_uri: str) -> CoverArt | None:
|
||||
if self._cover_extractor is None:
|
||||
return None
|
||||
try:
|
||||
async with self._storage.as_local_path(storage_uri) as path:
|
||||
return await self._cover_extractor.extract(path)
|
||||
except Exception:
|
||||
log.warning("cover_extract_step_failed", storage_uri=storage_uri)
|
||||
return None
|
||||
|
||||
async def _fetch_cover(self, release_group_mbid: str | None) -> CoverArt | None:
|
||||
if self._cover_provider is None or not release_group_mbid:
|
||||
return None
|
||||
if not self._cover_provider.is_available():
|
||||
return None
|
||||
try:
|
||||
return await self._cover_provider.fetch_release_group(release_group_mbid)
|
||||
except Exception:
|
||||
log.warning("cover_fetch_step_failed", release_group=release_group_mbid)
|
||||
return None
|
||||
|
||||
async def _save_cover(self, album_id: uuid.UUID, cover: CoverArt) -> str:
|
||||
key = f"covers/{album_id}.{cover.extension}"
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{cover.extension}") as tmp:
|
||||
tmp.write(cover.data)
|
||||
tmp.flush()
|
||||
await self._storage.save_file(key, Path(tmp.name))
|
||||
return key
|
||||
|
||||
|
||||
def _opt_str(*values: str | None) -> str | None:
|
||||
for value in values:
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _first_int(*values: int | None) -> int | None:
|
||||
for value in values:
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
@@ -0,0 +1,122 @@
|
||||
"""RemoteLibraryService — save-to-library + materialize for remote browse hits
|
||||
(plan: Model C, on-demand YTM library).
|
||||
|
||||
Two operations:
|
||||
|
||||
* ``save_remote`` persists a placeholder ``Track`` (``availability="remote"``,
|
||||
``storage_uri=None``) for a remote browse hit. Idempotent on
|
||||
``(source, source_id)`` — CLAUDE.md dedup.
|
||||
* ``request_materialize`` lazily fills a placeholder's audio in place: it
|
||||
creates (or reuses) a ``DownloadJob`` pointing at the existing track and
|
||||
enqueues the materialize worker, which calls ``TrackRepository.materialize``
|
||||
on completion. ``track.id`` never changes (CLAUDE.md), so likes/playlists/
|
||||
queue entries referencing the placeholder keep working once it's filled in.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.entities.download import DownloadJob
|
||||
from app.domain.entities.track import Track
|
||||
from app.domain.errors import NotFoundError, ValidationError
|
||||
from app.domain.ports import ArtistRepository, DownloadJobRepository, TrackRepository
|
||||
|
||||
_UNKNOWN_ARTIST = "Unknown Artist"
|
||||
|
||||
# (job_id) -> None — enqueue the materialize worker, same deferred pattern as
|
||||
# download/enrich enqueuers.
|
||||
MaterializeEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaterializeOutcome:
|
||||
"""Result of requesting materialization.
|
||||
|
||||
``job`` is ``None`` when the track is already ``local`` — nothing to do,
|
||||
the caller can stream immediately. Otherwise it's the (new or already
|
||||
in-flight) job filling the placeholder."""
|
||||
|
||||
track: Track
|
||||
job: DownloadJob | None
|
||||
|
||||
|
||||
class RemoteLibraryService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
jobs: DownloadJobRepository,
|
||||
enqueue_materialize: MaterializeEnqueuer | None = None,
|
||||
) -> None:
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._jobs = jobs
|
||||
self._enqueue_materialize = enqueue_materialize
|
||||
|
||||
async def save_remote(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str,
|
||||
title: str,
|
||||
artist: str | None,
|
||||
added_by: uuid.UUID | None,
|
||||
) -> Track:
|
||||
"""Persist a placeholder for a remote browse hit. Idempotent: a hit
|
||||
already saved (by ``(source, source_id)``) is returned as-is."""
|
||||
source_id = source_id.strip()
|
||||
if not source_id:
|
||||
raise ValidationError("A source_id is required to save.")
|
||||
|
||||
existing = await self._tracks.get_by_source(source, source_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
artist_entity = await self._artists.get_or_create(artist or _UNKNOWN_ARTIST)
|
||||
return await self._tracks.add(
|
||||
id=uuid.uuid4(),
|
||||
title=title,
|
||||
artist_id=artist_entity.id,
|
||||
storage_uri=None,
|
||||
file_format=None,
|
||||
file_size=None,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
metadata_status="pending",
|
||||
added_by=added_by,
|
||||
availability="remote",
|
||||
)
|
||||
|
||||
async def request_materialize(
|
||||
self, track_id: uuid.UUID, *, requested_by: uuid.UUID | None
|
||||
) -> MaterializeOutcome:
|
||||
"""Kick off (or report on) materializing a placeholder track.
|
||||
|
||||
Already-local tracks are a no-op (``job=None``). A track with no
|
||||
remote ``source_id`` (e.g. a deleted upload row reused for something
|
||||
else) can't be materialized."""
|
||||
track = await self._tracks.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
if track.availability == "local":
|
||||
return MaterializeOutcome(track=track, job=None)
|
||||
if track.source_id is None:
|
||||
raise ValidationError("Track has no remote source to materialize from.")
|
||||
|
||||
active = await self._jobs.get_active_for_source(track.source, track.source_id)
|
||||
if active is not None:
|
||||
return MaterializeOutcome(track=track, job=active)
|
||||
|
||||
job = await self._jobs.add(
|
||||
source=track.source,
|
||||
source_id=track.source_id,
|
||||
query=None,
|
||||
requested_by=requested_by,
|
||||
)
|
||||
await self._jobs.set_status(job.id, status="queued", track_id=track.id)
|
||||
if self._enqueue_materialize is not None:
|
||||
await self._enqueue_materialize(job.id)
|
||||
refreshed = await self._jobs.get_by_id(job.id)
|
||||
return MaterializeOutcome(track=track, job=refreshed if refreshed is not None else job)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""StreamingService — resolves a track and opens a byte-range stream."""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.errors import NotFoundError, RangeNotSatisfiableError
|
||||
from app.domain.ports import FileStorage, TrackRepository
|
||||
|
||||
_FORMAT_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",
|
||||
}
|
||||
|
||||
_RANGE_RE = re.compile(r"bytes=(\d+)-(\d*)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamResult:
|
||||
stream: AsyncIterator[bytes]
|
||||
total_size: int
|
||||
content_length: int
|
||||
content_type: str
|
||||
start: int
|
||||
end: int
|
||||
is_partial: bool
|
||||
|
||||
|
||||
def _parse_range(header: str | None, total_size: int) -> tuple[int, int | None, bool]:
|
||||
"""Return (start, end, is_partial). Raises RangeNotSatisfiableError on invalid range."""
|
||||
if header is None:
|
||||
return 0, None, False
|
||||
|
||||
m = _RANGE_RE.fullmatch(header.strip())
|
||||
if not m:
|
||||
return 0, None, False # malformed → treat as absent per RFC 7233
|
||||
|
||||
start = int(m.group(1))
|
||||
end: int | None = int(m.group(2)) if m.group(2) else None
|
||||
|
||||
if start >= total_size:
|
||||
raise RangeNotSatisfiableError(total_size)
|
||||
|
||||
if end is not None:
|
||||
if end >= total_size:
|
||||
end = total_size - 1
|
||||
if end < start:
|
||||
raise RangeNotSatisfiableError(total_size)
|
||||
|
||||
return start, end, True
|
||||
|
||||
|
||||
class StreamingService:
|
||||
def __init__(self, tracks: TrackRepository, storage: FileStorage) -> None:
|
||||
self._tracks = tracks
|
||||
self._storage = storage
|
||||
|
||||
async def open_stream(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
range_header: str | None,
|
||||
) -> StreamResult:
|
||||
track = await self._tracks.get_by_id(track_id)
|
||||
if track is None:
|
||||
raise NotFoundError("Track not found.")
|
||||
storage_uri = track.storage_uri
|
||||
if storage_uri is None:
|
||||
raise NotFoundError("Track is not yet downloaded.")
|
||||
|
||||
stat = await self._storage.stat(storage_uri)
|
||||
total_size = stat.size
|
||||
content_type = stat.content_type or _FORMAT_CONTENT_TYPE.get(
|
||||
(track.file_format or "").lower(), "application/octet-stream"
|
||||
)
|
||||
|
||||
start, end, is_partial = _parse_range(range_header, total_size)
|
||||
|
||||
stream, _ = await self._storage.open_range(storage_uri, start, end)
|
||||
|
||||
actual_end = end if end is not None else total_size - 1
|
||||
content_length = actual_end - start + 1
|
||||
|
||||
return StreamResult(
|
||||
stream=stream,
|
||||
total_size=total_size,
|
||||
content_length=content_length,
|
||||
content_type=content_type,
|
||||
start=start,
|
||||
end=actual_end,
|
||||
is_partial=is_partial,
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""SubsonicAuthService — app-password lifecycle + Subsonic auth verification.
|
||||
|
||||
The Subsonic protocol authenticates with either ``t=md5(password+salt)`` (+``s``)
|
||||
or the legacy ``p=`` (plaintext or ``enc:<hex>``). Both need a *recoverable*
|
||||
secret server-side, so Subsonic clients authenticate against a dedicated,
|
||||
high-entropy app-password — never the argon2 login password. That app-password
|
||||
is encrypted at rest (:class:`~app.domain.ports.SubsonicCipher`) and decrypted
|
||||
only here, to verify a request or to reveal it for copying into a client.
|
||||
|
||||
This is an adapter over the existing user store; it adds no business state of its
|
||||
own beyond the app-password column (CLAUDE.md: Subsonic is an adapter, not a
|
||||
reimplementation).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
|
||||
from app.core.security import generate_subsonic_password
|
||||
from app.domain.entities import User
|
||||
from app.domain.errors import AuthenticationError, NotFoundError, ValidationError
|
||||
from app.domain.ports import SubsonicCipher, UserRepository
|
||||
|
||||
|
||||
def _md5_hex(value: str) -> str:
|
||||
return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
|
||||
|
||||
def _decode_legacy_password(p: str) -> str:
|
||||
"""Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext."""
|
||||
if p.startswith("enc:"):
|
||||
try:
|
||||
return bytes.fromhex(p[4:]).decode("utf-8")
|
||||
except ValueError as exc:
|
||||
raise AuthenticationError("Wrong username or password.") from exc
|
||||
return p
|
||||
|
||||
|
||||
class SubsonicAuthService:
|
||||
def __init__(self, *, users: UserRepository, cipher: SubsonicCipher) -> None:
|
||||
self._users = users
|
||||
self._cipher = cipher
|
||||
|
||||
async def authenticate(
|
||||
self,
|
||||
*,
|
||||
username: str | None,
|
||||
token: str | None,
|
||||
salt: str | None,
|
||||
password: str | None,
|
||||
) -> User:
|
||||
"""Resolve Subsonic query auth params to a domain :class:`User`.
|
||||
|
||||
Raises :class:`ValidationError` (Subsonic code 10) for missing params and
|
||||
:class:`AuthenticationError` (code 40) for any credential mismatch — an
|
||||
unknown user is reported identically to a wrong password (no enumeration).
|
||||
"""
|
||||
if not username:
|
||||
raise ValidationError("Required parameter 'u' is missing.")
|
||||
if not ((token and salt) or password):
|
||||
raise ValidationError("Required authentication parameter is missing.")
|
||||
|
||||
creds = await self._users.get_subsonic_credentials_by_username(username)
|
||||
if creds is None or not creds.user.is_active or creds.password_enc is None:
|
||||
raise AuthenticationError("Wrong username or password.")
|
||||
|
||||
app_password = self._cipher.decrypt(creds.password_enc)
|
||||
|
||||
if token and salt:
|
||||
expected = _md5_hex(app_password + salt)
|
||||
if not hmac.compare_digest(expected, token.lower()):
|
||||
raise AuthenticationError("Wrong username or password.")
|
||||
else:
|
||||
assert password is not None # guaranteed by the missing-param check above
|
||||
supplied = _decode_legacy_password(password)
|
||||
if not hmac.compare_digest(supplied, app_password):
|
||||
raise AuthenticationError("Wrong username or password.")
|
||||
|
||||
return creds.user
|
||||
|
||||
async def rotate(self, user_id: uuid.UUID) -> str:
|
||||
"""Generate a fresh app-password, store it encrypted, return the plaintext."""
|
||||
await self._require_user(user_id)
|
||||
password = generate_subsonic_password()
|
||||
await self._users.set_subsonic_password_enc(user_id, self._cipher.encrypt(password))
|
||||
return password
|
||||
|
||||
async def reveal(self, user_id: uuid.UUID) -> str:
|
||||
"""Return the current app-password, generating one on first access."""
|
||||
await self._require_user(user_id)
|
||||
enc = await self._users.get_subsonic_password_enc(user_id)
|
||||
if enc is None:
|
||||
return await self.rotate(user_id)
|
||||
return self._cipher.decrypt(enc)
|
||||
|
||||
async def _require_user(self, user_id: uuid.UUID) -> User:
|
||||
user = await self._users.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("User not found.")
|
||||
return user
|
||||
@@ -0,0 +1,122 @@
|
||||
"""UploadService — handles user file uploads."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import anyio
|
||||
|
||||
from app.domain.entities.user import User
|
||||
from app.domain.ports import ArtistRepository, FileStorage, TrackRepository
|
||||
|
||||
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
|
||||
|
||||
|
||||
class UploadFileProtocol(Protocol):
|
||||
filename: str | None
|
||||
|
||||
async def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadResult:
|
||||
track_id: uuid.UUID
|
||||
title: str
|
||||
already_exists: bool
|
||||
|
||||
|
||||
async def _stream_to_temp(upload: UploadFileProtocol, dest: Path) -> tuple[str, int]:
|
||||
h = hashlib.sha256()
|
||||
size = 0
|
||||
async with await anyio.open_file(dest, "wb") as out:
|
||||
while True:
|
||||
chunk = await upload.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
await out.write(chunk)
|
||||
size += len(chunk)
|
||||
return h.hexdigest(), size
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(
|
||||
self,
|
||||
tracks: TrackRepository,
|
||||
artists: ArtistRepository,
|
||||
storage: FileStorage,
|
||||
tmp_dir: Path | None = None,
|
||||
enqueue_enrich: EnrichEnqueuer | None = None,
|
||||
) -> None:
|
||||
self._tracks = tracks
|
||||
self._artists = artists
|
||||
self._storage = storage
|
||||
self._tmp_dir = tmp_dir
|
||||
self._enqueue_enrich = enqueue_enrich
|
||||
|
||||
async def handle_upload(
|
||||
self,
|
||||
*,
|
||||
upload: UploadFileProtocol,
|
||||
user: User,
|
||||
) -> UploadResult:
|
||||
filename = upload.filename or "unknown"
|
||||
ext = Path(filename).suffix.lower().lstrip(".") or "bin"
|
||||
title = Path(filename).stem or "Unknown"
|
||||
|
||||
fd, tmp_str = tempfile.mkstemp(
|
||||
suffix=f".{ext}",
|
||||
dir=str(self._tmp_dir) if self._tmp_dir else None,
|
||||
)
|
||||
tmp_path = Path(tmp_str)
|
||||
try:
|
||||
os.close(fd)
|
||||
sha256_hex, file_size = await _stream_to_temp(upload, tmp_path)
|
||||
|
||||
existing = await self._tracks.get_by_source("upload", sha256_hex)
|
||||
if existing is not None:
|
||||
return UploadResult(
|
||||
track_id=existing.id,
|
||||
title=existing.title,
|
||||
already_exists=True,
|
||||
)
|
||||
|
||||
track_id = uuid.uuid4()
|
||||
key = f"tracks/{str(track_id)[:2]}/{track_id}.{ext}"
|
||||
|
||||
await self._storage.save_file(key, tmp_path)
|
||||
try:
|
||||
artist = await self._artists.get_or_create("Unknown Artist")
|
||||
track = await self._tracks.add(
|
||||
id=track_id,
|
||||
title=title,
|
||||
artist_id=artist.id,
|
||||
storage_uri=key,
|
||||
file_format=ext,
|
||||
file_size=file_size,
|
||||
source="upload",
|
||||
source_id=sha256_hex,
|
||||
metadata_status="pending",
|
||||
added_by=user.id,
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._storage.delete(key)
|
||||
raise
|
||||
|
||||
if self._enqueue_enrich is not None:
|
||||
await self._enqueue_enrich(track.id)
|
||||
|
||||
return UploadResult(
|
||||
track_id=track.id,
|
||||
title=track.title,
|
||||
already_exists=False,
|
||||
)
|
||||
finally:
|
||||
await anyio.Path(tmp_path).unlink(missing_ok=True)
|
||||
+75
-1
@@ -5,12 +5,25 @@ development). Access the cached singleton via :func:`get_settings`.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# App identity for outbound API calls (e.g. the MusicBrainz/AcoustID
|
||||
# User-Agent). Name is fixed; version comes from the installed package.
|
||||
APP_NAME = "MCMA"
|
||||
_PROJECT_URL = "https://git.ollyhearn.ru/olly/mcma-backend"
|
||||
|
||||
|
||||
def app_version() -> str:
|
||||
try:
|
||||
return version("mcma-backend")
|
||||
except PackageNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
@@ -44,18 +57,69 @@ class Settings(BaseSettings):
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
||||
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
||||
# Public self-service sign-up. When disabled, accounts are created
|
||||
# admin-only (POST /admin/users). Registered users are never superusers.
|
||||
allow_registration: bool = True
|
||||
|
||||
# -- subsonic ---------------------------------------------------------
|
||||
# Symmetric key (any string) used to encrypt each user's recoverable
|
||||
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
||||
# this value renders stored app-passwords undecryptable (rotate them too).
|
||||
subsonic_secret_key: SecretStr = SecretStr("change-me-subsonic-key")
|
||||
|
||||
# -- media / storage --------------------------------------------------
|
||||
media_path: Path = Path("/data/media")
|
||||
transcode_cache_path: Path = Path("/data/transcode-cache")
|
||||
max_parallel_downloads: int = 2
|
||||
# How many times the download worker retries a failed fetch (yt-dlp fails
|
||||
# often) before marking the job ``failed`` — exponential backoff between tries.
|
||||
download_max_retries: int = 3
|
||||
storage_backend: Literal["local", "s3"] = "local"
|
||||
upload_tmp_dir: Path | None = None
|
||||
|
||||
# -- sources ----------------------------------------------------------
|
||||
# Mounted folder the ``local`` source indexes (copies into managed storage).
|
||||
# Unset → the local source is simply not registered.
|
||||
local_media_import_path: Path | None = None
|
||||
|
||||
# -- S3 storage (deferred; set storage_backend="s3" to use) ----------
|
||||
s3_endpoint_url: str | None = None
|
||||
s3_bucket: str | None = None
|
||||
s3_region: str | None = None
|
||||
s3_access_key: SecretStr | None = None
|
||||
s3_secret_key: SecretStr | None = None
|
||||
|
||||
# -- external services (all optional; graceful degradation) ----------
|
||||
ml_service_url: str | None = None
|
||||
acoustid_api_key: SecretStr | None = None
|
||||
musicbrainz_user_agent: str = "mcma-backend/0.1.0 ( https://github.com/your/repo )"
|
||||
acoustid_api_url: str = "https://api.acoustid.org/v2/lookup"
|
||||
# Above this AcoustID match score, trust the acoustic identification over
|
||||
# embedded file tags (which are frequently junk on downloaded files —
|
||||
# e.g. "Music Track" / "Sound_12345"). Below it, keep the tag-first merge.
|
||||
acoustid_trust_score: float = 0.85
|
||||
# MusicBrainz/AcoustID require a meaningful User-Agent identifying the
|
||||
# application and a way to contact its maintainer (see
|
||||
# https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting). Self-hosted
|
||||
# deployments should set their own contact email; see
|
||||
# ``musicbrainz_user_agent`` below for how it's used.
|
||||
musicbrainz_owner_email: str | None = None
|
||||
# ``youtube`` fetch source (search + download via ytmusicapi/yt-dlp). Enabled
|
||||
# by default; the source still reports unavailable if the libs aren't present.
|
||||
youtube_enabled: bool = True
|
||||
# Optional cookies file (Netscape format) for yt-dlp — lets it fetch
|
||||
# age-restricted / region-locked items via an authenticated session.
|
||||
youtube_cookies_path: Path | None = None
|
||||
|
||||
# -- enrichment -------------------------------------------------------
|
||||
# ``fpcalc`` (Chromaprint) binary; resolved on PATH by default. The Docker
|
||||
# image installs it via libchromaprint-tools.
|
||||
fpcalc_path: str = "fpcalc"
|
||||
|
||||
# Cover Art Archive — network fallback for album covers (after embedded art).
|
||||
# Disable to keep enrichment fully offline; embedded artwork still works.
|
||||
coverart_enabled: bool = True
|
||||
coverart_base_url: str = "https://coverartarchive.org"
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def _require_async_driver(cls, v: str) -> str:
|
||||
@@ -67,6 +131,16 @@ class Settings(BaseSettings):
|
||||
def is_prod(self) -> bool:
|
||||
return self.environment == "prod"
|
||||
|
||||
@property
|
||||
def musicbrainz_user_agent(self) -> str:
|
||||
"""User-Agent sent to MusicBrainz/AcoustID: ``MCMA/<version> ( <contact> )``.
|
||||
|
||||
Falls back to the project URL if the deployment hasn't set
|
||||
``musicbrainz_owner_email``.
|
||||
"""
|
||||
contact = self.musicbrainz_owner_email or _PROJECT_URL
|
||||
return f"{APP_NAME}/{app_version()} ( {contact} )"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""File hashing utilities."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_of_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
@@ -6,16 +6,54 @@ to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
|
||||
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.errors import AuthenticationError
|
||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||
|
||||
# Length (in bytes of entropy) of a generated Subsonic app-password. 18 bytes of
|
||||
# url-safe base64 → 24 characters, well above the Subsonic auth threat model.
|
||||
_SUBSONIC_PASSWORD_ENTROPY_BYTES = 18
|
||||
|
||||
|
||||
def generate_subsonic_password() -> str:
|
||||
"""A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)."""
|
||||
return secrets.token_urlsafe(_SUBSONIC_PASSWORD_ENTROPY_BYTES)
|
||||
|
||||
|
||||
class SubsonicPasswordCipher:
|
||||
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password.
|
||||
|
||||
Subsonic auth (``t=md5(password+salt)`` and legacy ``p=``) needs the plaintext
|
||||
password server-side, so — unlike the argon2-hashed login password — the
|
||||
app-password is stored *encrypted*, not hashed. A Fernet key (AES-128-CBC +
|
||||
HMAC) is derived from the configured secret; the plaintext key never touches
|
||||
the DB. Implements :class:`app.domain.ports.SubsonicCipher`.
|
||||
"""
|
||||
|
||||
def __init__(self, secret_key: str) -> None:
|
||||
digest = hashlib.sha256(secret_key.encode("utf-8")).digest()
|
||||
self._fernet = Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
def encrypt(self, plaintext: str) -> str:
|
||||
return self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||
|
||||
def decrypt(self, token: str) -> str:
|
||||
try:
|
||||
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
# Wrong/rotated secret key, or corrupted ciphertext.
|
||||
raise AuthenticationError("Stored Subsonic password could not be decrypted.") from exc
|
||||
|
||||
|
||||
class Argon2PasswordHasher:
|
||||
"""argon2id hasher with sensible defaults from pwdlib."""
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
"""Domain entities and value objects — pure, framework-free."""
|
||||
|
||||
from app.domain.entities.user import Credentials, User
|
||||
from app.domain.entities.album import Album
|
||||
from app.domain.entities.cover import CoverArt
|
||||
from app.domain.entities.download import DownloadJob
|
||||
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 (
|
||||
DiskUsage,
|
||||
FormatBreakdown,
|
||||
LibraryStats,
|
||||
ObjectStat,
|
||||
)
|
||||
from app.domain.entities.track import Artist, Track
|
||||
from app.domain.entities.user import Credentials, SubsonicCredentials, User
|
||||
|
||||
__all__ = ["Credentials", "User"]
|
||||
__all__ = [
|
||||
"Album",
|
||||
"Artist",
|
||||
"AudioTags",
|
||||
"CoverArt",
|
||||
"Credentials",
|
||||
"DiskUsage",
|
||||
"DownloadJob",
|
||||
"Fingerprint",
|
||||
"FormatBreakdown",
|
||||
"LibraryStats",
|
||||
"Like",
|
||||
"ObjectStat",
|
||||
"PlayHistoryEntry",
|
||||
"Playlist",
|
||||
"RecordingMatch",
|
||||
"SubsonicCredentials",
|
||||
"Track",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Album domain entity."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Album:
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
artist_id: uuid.UUID
|
||||
year: int | None
|
||||
cover_path: str | None
|
||||
musicbrainz_id: str | None
|
||||
source: str | None
|
||||
source_id: str | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Cover-art value object — raw image bytes plus their MIME type.
|
||||
|
||||
Crosses the domain boundary between the cover sources (embedded extractor,
|
||||
Cover Art Archive) and the storage/serving layers. The bytes are the encoded
|
||||
image as-is; we never decode/resize in Phase 1.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverArt:
|
||||
data: bytes
|
||||
content_type: str # "image/jpeg" | "image/png" | …
|
||||
|
||||
@property
|
||||
def extension(self) -> str:
|
||||
"""File extension for the content type (no leading dot)."""
|
||||
return _EXT_BY_TYPE.get(self.content_type.lower(), "jpg")
|
||||
|
||||
|
||||
_EXT_BY_TYPE: dict[str, str] = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Download job domain entity (plan §6.1).
|
||||
|
||||
A queued fetch from an external source, tracked through its lifecycle so the UI
|
||||
download manager (screen §A5) can show progress, errors, and retries. The
|
||||
``status`` strings mirror :class:`~app.infrastructure.db.models.enums.DownloadStatus`.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadJob:
|
||||
id: uuid.UUID
|
||||
source: str
|
||||
source_id: str | None
|
||||
query: str | None
|
||||
requested_by: uuid.UUID | None
|
||||
status: str
|
||||
progress: float
|
||||
error_message: str | None
|
||||
retry_count: int
|
||||
track_id: uuid.UUID | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Play history domain entity — append-only scrobble log entry."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlayHistoryEntry:
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
track_id: uuid.UUID
|
||||
played_at: dt.datetime
|
||||
play_duration_seconds: int | None
|
||||
completed: bool
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Like domain entity — append-only event log entry."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Like:
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
track_id: uuid.UUID
|
||||
value: str # "like" | "dislike" | "neutral"
|
||||
created_at: dt.datetime
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Value objects for the metadata-enrichment pipeline (plan §6.2).
|
||||
|
||||
Pure data carriers between the enrichment service and its adapters (tag reader,
|
||||
fingerprinter, AcoustID). No framework imports — these cross the domain boundary.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AudioTags:
|
||||
"""Embedded tags read from the file itself (ID3 / Vorbis / MP4 …).
|
||||
|
||||
Every field is optional — files are tagged inconsistently. The reader fills
|
||||
what it can and leaves the rest ``None`` for downstream identification.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
album_artist: str | None = None
|
||||
genre: str | None = None
|
||||
year: int | None = None
|
||||
track_number: int | None = None
|
||||
duration_seconds: int | None = None
|
||||
bitrate: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Fingerprint:
|
||||
"""Chromaprint fingerprint plus the decoded duration (both needed by AcoustID)."""
|
||||
|
||||
fingerprint: str
|
||||
duration_seconds: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordingMatch:
|
||||
"""A single AcoustID result, flattened to the fields enrichment cares about.
|
||||
|
||||
``acoustid`` is the stable AcoustID identifier (a UUID) — used as the
|
||||
dedup key persisted on ``track.acoustid_fingerprint`` (fits the 64-char
|
||||
column; the raw fingerprint does not). ``recording_mbid`` is the MusicBrainz
|
||||
recording id when present.
|
||||
"""
|
||||
|
||||
acoustid: str
|
||||
score: float
|
||||
recording_mbid: str | None = None
|
||||
release_group_mbid: str | None = None
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
year: int | None = None
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Playlist domain entity."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Playlist:
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
owner_id: uuid.UUID
|
||||
version: int
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Value objects for file storage."""
|
||||
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Track and Artist domain entities."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Artist:
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
source: str | None
|
||||
source_id: str | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Track:
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
artist_id: uuid.UUID
|
||||
album_id: uuid.UUID | None
|
||||
storage_uri: str | None
|
||||
file_format: str | None
|
||||
file_size: int | None
|
||||
source: str
|
||||
source_id: str
|
||||
duration_seconds: int | None
|
||||
genre: str | None
|
||||
year: int | None
|
||||
track_number: int | None
|
||||
metadata_status: str
|
||||
metadata_error: str | None
|
||||
enriched_at: dt.datetime | None
|
||||
availability: str
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
@@ -31,3 +31,14 @@ class Credentials:
|
||||
|
||||
user: User
|
||||
password_hash: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubsonicCredentials:
|
||||
"""A user paired with their *encrypted* Subsonic app-password.
|
||||
|
||||
``password_enc`` is ``None`` until the user generates one. Stays inside the
|
||||
application layer; the plaintext is only recovered for auth verification."""
|
||||
|
||||
user: User
|
||||
password_enc: str | None
|
||||
|
||||
@@ -61,3 +61,19 @@ class DependencyUnavailableError(DomainError):
|
||||
"""
|
||||
|
||||
code = "dependency_unavailable"
|
||||
|
||||
|
||||
class StorageError(DomainError):
|
||||
"""File storage operation failed."""
|
||||
|
||||
code = "storage_error"
|
||||
|
||||
|
||||
class RangeNotSatisfiableError(DomainError):
|
||||
"""Requested byte range cannot be satisfied."""
|
||||
|
||||
code = "range_not_satisfiable"
|
||||
|
||||
def __init__(self, total_size: int) -> None:
|
||||
super().__init__("Requested range is not satisfiable.")
|
||||
self.total_size = total_size
|
||||
|
||||
+390
-1
@@ -7,11 +7,37 @@ are bound to these ports at the composition root (``app.api.deps``).
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.entities import Credentials, User
|
||||
from app.domain.entities import (
|
||||
Album,
|
||||
AudioTags,
|
||||
CoverArt,
|
||||
Credentials,
|
||||
DiskUsage,
|
||||
DownloadJob,
|
||||
Fingerprint,
|
||||
LibraryStats,
|
||||
Like,
|
||||
ObjectStat,
|
||||
PlayHistoryEntry,
|
||||
Playlist,
|
||||
RecordingMatch,
|
||||
SubsonicCredentials,
|
||||
User,
|
||||
)
|
||||
from app.domain.entities.track import Artist, Track
|
||||
from app.domain.sources import DownloadResult, RawMetadata, SearchResult, SourceFile, SourceInfo
|
||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||
|
||||
# A fetch source reports download progress as a fraction in [0.0, 1.0]. It's a
|
||||
# plain callback (not a port) because it's an inversion of control supplied per
|
||||
# call by the worker, which persists it to the download job.
|
||||
ProgressCallback = Callable[[float], Awaitable[None]]
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None: ...
|
||||
@@ -22,6 +48,19 @@ class UserRepository(Protocol):
|
||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
||||
async def count(self) -> int: ...
|
||||
# -- subsonic app-password (recoverable, encrypted at rest) ----------
|
||||
async def get_subsonic_credentials_by_username(
|
||||
self, username: str
|
||||
) -> SubsonicCredentials | None: ...
|
||||
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None: ...
|
||||
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
|
||||
|
||||
|
||||
class SubsonicCipher(Protocol):
|
||||
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
|
||||
|
||||
def encrypt(self, plaintext: str) -> str: ...
|
||||
def decrypt(self, token: str) -> str: ...
|
||||
|
||||
|
||||
class RefreshTokenRepository(Protocol):
|
||||
@@ -56,3 +95,353 @@ class TokenService(Protocol):
|
||||
"""Verify signature + expiry and return claims. Raises
|
||||
:class:`~app.domain.errors.AuthenticationError` on any failure."""
|
||||
...
|
||||
|
||||
|
||||
class FileStorage(Protocol):
|
||||
async def save_file(self, key: str, src_path: Path) -> int: ...
|
||||
async def open_range(
|
||||
self, key: str, start: int, end: int | None
|
||||
) -> tuple[AsyncIterator[bytes], int]: ...
|
||||
async def stat(self, key: str) -> ObjectStat: ...
|
||||
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):
|
||||
async def get_or_create(self, name: str) -> Artist: ...
|
||||
async def get_or_create_remote(self, *, name: str, source: str, source_id: str) -> Artist:
|
||||
"""Resolve/create an artist bound to a remote ``(source, source_id)``
|
||||
(lazy materialization save-to-library)."""
|
||||
...
|
||||
|
||||
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ...
|
||||
async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: ...
|
||||
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
|
||||
async def count(self, *, q: str | None) -> int: ...
|
||||
async def album_count(self, artist_id: uuid.UUID) -> int: ...
|
||||
async def track_count(self, artist_id: uuid.UUID) -> int: ...
|
||||
|
||||
|
||||
class TrackRepository(Protocol):
|
||||
async def get_by_id(self, track_id: uuid.UUID) -> Track | None: ...
|
||||
async def get_by_source(self, source: str, source_id: str) -> Track | None: ...
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
id: uuid.UUID,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
storage_uri: str | None,
|
||||
file_format: str | None,
|
||||
file_size: int | None,
|
||||
source: str,
|
||||
source_id: str,
|
||||
metadata_status: str,
|
||||
added_by: uuid.UUID | None,
|
||||
availability: str = ...,
|
||||
) -> Track: ...
|
||||
async def materialize(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
*,
|
||||
storage_uri: str,
|
||||
file_format: str,
|
||||
file_size: int,
|
||||
bitrate: int | None,
|
||||
) -> Track:
|
||||
"""Fill in a remote placeholder's audio fields after a download
|
||||
(lazy materialization), flipping ``availability`` to ``local``."""
|
||||
...
|
||||
|
||||
async def delete(self, track_id: uuid.UUID) -> None: ...
|
||||
# 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,
|
||||
*,
|
||||
artist_id: uuid.UUID | None,
|
||||
album_id: uuid.UUID | None,
|
||||
q: str | None,
|
||||
source: str | None = None,
|
||||
sort_by: str,
|
||||
order: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[Track]: ...
|
||||
async def count(
|
||||
self,
|
||||
*,
|
||||
artist_id: uuid.UUID | None,
|
||||
album_id: uuid.UUID | None,
|
||||
q: str | None,
|
||||
source: str | None = None,
|
||||
) -> int: ...
|
||||
async def update(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
*,
|
||||
title: str | None,
|
||||
genre: str | None,
|
||||
year: int | None,
|
||||
) -> Track: ...
|
||||
async def apply_enrichment(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
*,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
album_id: uuid.UUID | None,
|
||||
genre: str | None,
|
||||
year: int | None,
|
||||
track_number: int | None,
|
||||
duration_seconds: int | None,
|
||||
bitrate: int | None,
|
||||
acoustid_fingerprint: str | None,
|
||||
musicbrainz_id: str | None,
|
||||
metadata_status: str,
|
||||
metadata_error: str | None = None,
|
||||
) -> Track:
|
||||
"""Persist auto-enrichment results. Nullable fields are filled only when
|
||||
a non-``None`` value is supplied (re-enrich never erases prior data);
|
||||
``title``/``artist_id``/``metadata_status`` are always written, and the
|
||||
run's outcome (``metadata_error`` + completion time) is always stamped.
|
||||
Callers must not invoke this for ``metadata_status == 'manual'`` tracks."""
|
||||
...
|
||||
|
||||
async def mark_enrichment_failed(self, track_id: uuid.UUID, *, error: str) -> None:
|
||||
"""Record that an enrichment run crashed unexpectedly: set ``failed`` +
|
||||
the error reason. A no-op for ``manual`` or missing tracks."""
|
||||
...
|
||||
|
||||
|
||||
class AlbumRepository(Protocol):
|
||||
async def get_or_create(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
year: int | None,
|
||||
musicbrainz_id: str | None,
|
||||
) -> Album: ...
|
||||
async def get_or_create_remote(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
year: int | None,
|
||||
musicbrainz_id: str | None,
|
||||
source: str,
|
||||
source_id: str,
|
||||
) -> Album:
|
||||
"""Resolve/create an album bound to a remote ``(source, source_id)``
|
||||
(lazy materialization save-to-library)."""
|
||||
...
|
||||
|
||||
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None: ...
|
||||
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
|
||||
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
|
||||
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
|
||||
async def track_count(self, album_id: uuid.UUID) -> int: ...
|
||||
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
|
||||
# list must come after any method using list[...] in its signature (name shadowing)
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
artist_id: uuid.UUID | None,
|
||||
q: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
sort_by: str = "title",
|
||||
order: str = "asc",
|
||||
) -> list[Album]: ...
|
||||
|
||||
|
||||
class PlaylistRepository(Protocol):
|
||||
async def get_by_id(self, playlist_id: uuid.UUID) -> Playlist | None: ...
|
||||
async def count(self, *, owner_id: uuid.UUID) -> int: ...
|
||||
async def add(self, *, name: str, description: str | None, owner_id: uuid.UUID) -> Playlist: ...
|
||||
async def update(
|
||||
self, playlist_id: uuid.UUID, *, name: str | None, description: str | None
|
||||
) -> Playlist: ...
|
||||
async def delete(self, playlist_id: uuid.UUID) -> None: ...
|
||||
async def track_count(self, playlist_id: uuid.UUID) -> int: ...
|
||||
async def track_count_many(self, playlist_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
|
||||
async def get_tracks(
|
||||
self, playlist_id: uuid.UUID, *, limit: int, offset: int
|
||||
) -> list[Track]: ...
|
||||
async def get_track_total(self, playlist_id: uuid.UUID) -> int: ...
|
||||
async def add_track(
|
||||
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
|
||||
) -> None: ...
|
||||
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None: ...
|
||||
async def max_position(self, playlist_id: uuid.UUID) -> float: ...
|
||||
# list must come after any method using list[...] in its signature (name shadowing)
|
||||
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]: ...
|
||||
|
||||
|
||||
class LikeRepository(Protocol):
|
||||
async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like: ...
|
||||
async def get_latest_state(
|
||||
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
|
||||
) -> list[Like]: ...
|
||||
async def list_liked_tracks(
|
||||
self, *, user_id: uuid.UUID, limit: int, offset: int
|
||||
) -> list[Track]: ...
|
||||
async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int: ...
|
||||
|
||||
|
||||
class HistoryRepository(Protocol):
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
track_id: uuid.UUID,
|
||||
played_at: dt.datetime,
|
||||
play_duration_seconds: int | None,
|
||||
completed: bool,
|
||||
) -> PlayHistoryEntry: ...
|
||||
async def list(
|
||||
self, *, user_id: uuid.UUID, limit: int, offset: int
|
||||
) -> list[PlayHistoryEntry]: ...
|
||||
async def count(self, *, user_id: uuid.UUID) -> int: ...
|
||||
|
||||
|
||||
class DownloadJobRepository(Protocol):
|
||||
"""Persistence for download jobs (plan §6.1). Drives the §A5 download manager
|
||||
and the worker's retry/backoff loop."""
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str | None,
|
||||
query: str | None,
|
||||
requested_by: uuid.UUID | None,
|
||||
) -> DownloadJob: ...
|
||||
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None: ...
|
||||
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
|
||||
"""An unfinished (queued/downloading/enriching) job for the same item, if
|
||||
any — used to dedup before enqueuing so a double-click can't queue twice."""
|
||||
...
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
requested_by: uuid.UUID | None,
|
||||
status: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[DownloadJob]: ...
|
||||
async def count(self, *, requested_by: uuid.UUID | None, status: str | None) -> int: ...
|
||||
async def set_status(
|
||||
self,
|
||||
job_id: uuid.UUID,
|
||||
*,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
track_id: uuid.UUID | None = None,
|
||||
) -> None: ...
|
||||
async def set_progress(self, job_id: uuid.UUID, progress: float) -> None: ...
|
||||
async def increment_retry(self, job_id: uuid.UUID) -> int:
|
||||
"""Bump ``retry_count`` and return the new value."""
|
||||
...
|
||||
|
||||
async def delete(self, job_id: uuid.UUID) -> None: ...
|
||||
async def failure_rate(self, source: str, *, since: dt.datetime) -> float:
|
||||
"""Fraction of jobs for ``source`` created since ``since`` that ended
|
||||
``failed`` (0.0 when there are none) — drives the §A5 "source unhealthy"
|
||||
banner."""
|
||||
...
|
||||
|
||||
|
||||
class SourceBackend(Protocol):
|
||||
"""A registered source of tracks (mounted folder, YouTube, …).
|
||||
|
||||
``name`` is the stable identifier used in URLs and stored on ``track.source``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def info(self) -> SourceInfo: ...
|
||||
def is_available(self) -> bool: ...
|
||||
|
||||
|
||||
class IndexableSource(SourceBackend, Protocol):
|
||||
"""A source that enumerates files already on disk (e.g. the local folder)."""
|
||||
|
||||
def scan(self) -> Iterator[SourceFile]: ...
|
||||
|
||||
|
||||
class SearchableSource(SourceBackend, Protocol):
|
||||
"""A source that can be searched by free text (e.g. YouTube Music).
|
||||
|
||||
Returns ``[]`` (never raises) on no results / the service being down — the
|
||||
discover screen degrades to "nothing found" rather than erroring."""
|
||||
|
||||
async def search(self, query: str, *, limit: int) -> list[SearchResult]: ...
|
||||
|
||||
|
||||
class FetchableSource(SourceBackend, Protocol):
|
||||
"""A source that can download a previously-discovered item to local disk.
|
||||
|
||||
``fetch`` resolves a ``source_id`` (from a :class:`SearchResult`) into a file
|
||||
and reports progress through ``on_progress``. It runs only in a worker (heavy
|
||||
I/O) and raises on failure so the download task can retry with backoff."""
|
||||
|
||||
async def fetch(
|
||||
self, source_id: str, *, on_progress: ProgressCallback | None = None
|
||||
) -> DownloadResult: ...
|
||||
|
||||
async def get_metadata(self, source_id: str) -> RawMetadata | None: ...
|
||||
|
||||
|
||||
# -- metadata enrichment (plan §6.2) -----------------------------------------
|
||||
class AudioTagReader(Protocol):
|
||||
"""Reads embedded tags from a local audio file. Returns ``None`` only when
|
||||
the file can't be parsed at all — never raises (graceful degradation)."""
|
||||
|
||||
async def read(self, path: Path) -> AudioTags | None: ...
|
||||
|
||||
|
||||
class AudioFingerprinter(Protocol):
|
||||
"""Chromaprint (fpcalc) wrapper. ``is_available`` reflects whether the
|
||||
binary is present; ``calculate`` returns ``None`` on any failure."""
|
||||
|
||||
def is_available(self) -> bool: ...
|
||||
async def calculate(self, path: Path) -> Fingerprint | None: ...
|
||||
|
||||
|
||||
class AcoustIdClient(Protocol):
|
||||
"""AcoustID lookup. ``is_available`` is False without an API key (the whole
|
||||
fingerprint path is then skipped). ``lookup`` returns the best match or
|
||||
``None`` (no result / service down), never raising. ``lookup_all`` returns
|
||||
the same candidates ranked by confidence (``[]`` on no result / unavailable
|
||||
/ error), for the metadata editor's match picker."""
|
||||
|
||||
def is_available(self) -> bool: ...
|
||||
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
|
||||
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]: ...
|
||||
|
||||
|
||||
class CoverArtExtractor(Protocol):
|
||||
"""Pulls embedded cover art out of a local audio file (offline, no network).
|
||||
Returns ``None`` when the file has no picture or can't be parsed — never raises."""
|
||||
|
||||
async def extract(self, path: Path) -> CoverArt | None: ...
|
||||
|
||||
|
||||
class CoverArtProvider(Protocol):
|
||||
"""Fetches cover art from an external service (Cover Art Archive) by
|
||||
MusicBrainz release-group id. ``is_available`` may gate it off; ``fetch``
|
||||
returns ``None`` (not found / service down), never raising."""
|
||||
|
||||
def is_available(self) -> bool: ...
|
||||
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Source-backend value objects — framework-free.
|
||||
|
||||
A *source* is a place tracks come from (a mounted folder, YouTube, an upload).
|
||||
Backends are driven adapters (``app.infrastructure.sources``); these are the
|
||||
shapes they speak in, and the ports they satisfy live in ``app.domain.ports``.
|
||||
|
||||
The first backend, ``local``, is *indexable*: it enumerates files already on
|
||||
disk. Concrete metadata (artist/album/tags) is intentionally **not** resolved
|
||||
here — a source yields a file plus a minimal title; enrichment (plan §6.2) fills
|
||||
the rest later, so this stays a thin discovery layer (CLAUDE.md: no duplicated
|
||||
business logic)."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# A source's ``kind`` describes which ports it satisfies, so the UI/admin can
|
||||
# tell an indexed folder from a searchable fetch-source. A backend may be both.
|
||||
KIND_INDEXABLE = "indexable" # enumerates files already on disk (local folder)
|
||||
KIND_FETCH = "fetch" # searches + downloads from an external service (YTM, …)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourceInfo:
|
||||
"""Describes a registered source for enumeration / health (UI, admin)."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
kind: str # KIND_INDEXABLE | KIND_FETCH
|
||||
available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourceFile:
|
||||
"""A single importable file discovered by an indexable source.
|
||||
|
||||
``source_id`` is stable per source (the local backend uses the path relative
|
||||
to its root) so re-scans are idempotent — already-imported files are skipped.
|
||||
"""
|
||||
|
||||
source_id: str
|
||||
path: Path
|
||||
suggested_title: str
|
||||
file_format: str
|
||||
file_size: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchResult:
|
||||
"""One hit from a searchable source (plan §5), shown on the discover screen.
|
||||
|
||||
``source_id`` is the stable handle the same backend later resolves in
|
||||
``fetch`` — it must round-trip a download request without re-searching.
|
||||
``raw`` carries the backend's untouched payload for debugging / future use.
|
||||
"""
|
||||
|
||||
source: str
|
||||
source_id: str
|
||||
title: str
|
||||
artist: str | None
|
||||
album: str | None
|
||||
duration_seconds: int | None
|
||||
thumbnail_url: str | None
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawMetadata:
|
||||
"""Metadata a fetch-source can offer about an item *before* enrichment.
|
||||
|
||||
Best-effort and source-shaped — the canonical metadata still comes from the
|
||||
enrichment pipeline (plan §6.2). Used to seed a more useful provisional
|
||||
title than a bare id while a download is queued."""
|
||||
|
||||
title: str | None
|
||||
artist: str | None
|
||||
album: str | None
|
||||
year: int | None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadResult:
|
||||
"""A file a fetch-source produced on local disk (plan §5).
|
||||
|
||||
``path`` is a temp file the caller owns: it is stored into managed storage
|
||||
and then removed (same lifecycle as an upload). ``source_id`` is echoed back
|
||||
because some backends only learn the canonical id during the download."""
|
||||
|
||||
source_id: str
|
||||
path: Path
|
||||
file_format: str
|
||||
file_size: int
|
||||
bitrate: int | None
|
||||
suggested_title: str
|
||||
@@ -5,6 +5,26 @@ autogenerate and ``create_all`` (tests) see the full schema. ``alembic/env.py``
|
||||
imports it for exactly this side effect.
|
||||
"""
|
||||
|
||||
from app.infrastructure.db.models.album import AlbumModel
|
||||
from app.infrastructure.db.models.artist import ArtistModel
|
||||
from app.infrastructure.db.models.download_job import DownloadJobModel
|
||||
from app.infrastructure.db.models.like import LikeModel
|
||||
from app.infrastructure.db.models.lyrics import LyricsModel
|
||||
from app.infrastructure.db.models.play_history import PlayHistoryModel
|
||||
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
|
||||
from app.infrastructure.db.models.track import TrackModel
|
||||
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
|
||||
|
||||
__all__ = ["RefreshTokenModel", "UserModel"]
|
||||
__all__ = [
|
||||
"AlbumModel",
|
||||
"ArtistModel",
|
||||
"DownloadJobModel",
|
||||
"LikeModel",
|
||||
"LyricsModel",
|
||||
"PlayHistoryModel",
|
||||
"PlaylistModel",
|
||||
"PlaylistTrackModel",
|
||||
"RefreshTokenModel",
|
||||
"TrackModel",
|
||||
"UserModel",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""ORM model for albums."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class AlbumModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "albums"
|
||||
__table_args__ = (
|
||||
# Binds a remote (browsable) album to its local row for re-browse/save
|
||||
# dedup. Multiple NULLs are allowed by Postgres, so locally-created
|
||||
# albums (source/source_id both NULL) never collide on this.
|
||||
UniqueConstraint("source", "source_id", name="uq_albums_source_source_id"),
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(1024), index=True, nullable=False)
|
||||
artist_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("artists.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
|
||||
|
||||
# -- remote identity (lazy materialization) --------------------------
|
||||
source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""ORM model for artists."""
|
||||
|
||||
from sqlalchemy import String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class ArtistModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "artists"
|
||||
__table_args__ = (
|
||||
# Binds a remote (browsable) artist to its local row for re-browse/save
|
||||
# dedup. Multiple NULLs are allowed by Postgres, so locally-created
|
||||
# artists (source/source_id both NULL) never collide on this.
|
||||
UniqueConstraint("source", "source_id", name="uq_artists_source_source_id"),
|
||||
)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(512), index=True, nullable=False)
|
||||
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
|
||||
|
||||
# -- remote identity (lazy materialization) --------------------------
|
||||
source: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""ORM model for download jobs (plan §6.1).
|
||||
|
||||
Tracks a queued download through its lifecycle. ``retry_count`` supports the
|
||||
exponential-backoff retries that yt-dlp needs; ``progress`` drives the UI
|
||||
download manager.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.enums import DownloadStatus
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class DownloadJobModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "download_jobs"
|
||||
|
||||
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
query: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
requested_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
index=True,
|
||||
nullable=False,
|
||||
default=DownloadStatus.QUEUED.value,
|
||||
)
|
||||
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Set once the download finishes and the track is imported — lets the UI
|
||||
# link a completed job to its library track.
|
||||
track_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Domain enums used by ORM columns.
|
||||
|
||||
Plain ``str``-valued enums, stored as strings (not native PG enums) — adding a
|
||||
variant is a code change, never a migration (plan §4). Columns map these via
|
||||
``mapped_column(String(...))`` and persist ``Enum.value``.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class TrackSource(enum.StrEnum):
|
||||
"""Which backend imported a track. Drives ``is_replaceable`` (plan §6.6)."""
|
||||
|
||||
YOUTUBE = "youtube"
|
||||
LOCAL = "local"
|
||||
UPLOAD = "upload"
|
||||
SOUNDCLOUD = "soundcloud"
|
||||
BANDCAMP = "bandcamp"
|
||||
|
||||
|
||||
class StoragePolicy(enum.StrEnum):
|
||||
"""What the system did / must do with the stored format (plan §6.6).
|
||||
|
||||
``master_keep`` is inviolable — never auto-optimized.
|
||||
"""
|
||||
|
||||
AS_IS = "as_is"
|
||||
OPTIMIZED = "optimized"
|
||||
MASTER_KEEP = "master_keep"
|
||||
|
||||
|
||||
class MetadataStatus(enum.StrEnum):
|
||||
"""Enrichment state. ``manual`` is never overwritten by auto-enrichment."""
|
||||
|
||||
PENDING = "pending"
|
||||
ENRICHED = "enriched"
|
||||
FAILED = "failed"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class LikeValue(enum.StrEnum):
|
||||
"""A like event's value. Likes are an append-only log, not a boolean —
|
||||
current state is the latest event per ``(user, track)`` (plan §4.1)."""
|
||||
|
||||
LIKE = "like"
|
||||
DISLIKE = "dislike"
|
||||
NEUTRAL = "neutral"
|
||||
|
||||
|
||||
class DownloadStatus(enum.StrEnum):
|
||||
"""Lifecycle of a download job (plan §6.1)."""
|
||||
|
||||
QUEUED = "queued"
|
||||
DOWNLOADING = "downloading"
|
||||
ENRICHING = "enriching"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class LyricsStatus(enum.StrEnum):
|
||||
"""Lyrics fetch outcome. ``not_found`` is cached too (with TTL) so we don't
|
||||
hammer the provider for tracks that have no lyrics (plan §6.7)."""
|
||||
|
||||
FOUND = "found"
|
||||
NOT_FOUND = "not_found"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class TrackAvailability(enum.StrEnum):
|
||||
"""Whether a track's audio is on local storage or still a remote placeholder
|
||||
(plan: lazy materialization). ``remote`` tracks have ``storage_uri = NULL``
|
||||
until ``TrackRepository.materialize`` fills it in."""
|
||||
|
||||
LOCAL = "local"
|
||||
REMOTE = "remote"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""ORM model for likes — an append-only event log.
|
||||
|
||||
A like/dislike is **never** updated in place. Current state for a ``(user,
|
||||
track)`` pair is the latest event by ``created_at``. This shape is required for
|
||||
future sync and as a clean ML signal (plan §4.1). Hence: no ``updated_at``,
|
||||
no unique constraint on ``(user, track)``.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class LikeModel(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "likes"
|
||||
__table_args__ = (
|
||||
# Latest-event lookups query by (user, track) ordered by time.
|
||||
Index("ix_likes_user_id_track_id", "user_id", "track_id"),
|
||||
)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
value: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""ORM model for cached lyrics (plan §6.7).
|
||||
|
||||
Cached per track (one row, ``track_id`` unique) so the external provider
|
||||
(LRCLIB) isn't hit on every play. ``not_found`` is cached too — re-fetch policy
|
||||
(TTL) lives in the service. ``synced`` holds timestamped LRC; ``plain`` the
|
||||
fallback text.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.enums import LyricsStatus
|
||||
from app.infrastructure.db.models.mixins import UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class LyricsModel(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "lyrics"
|
||||
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
synced: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
plain: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default=LyricsStatus.PENDING.value,
|
||||
)
|
||||
fetched_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""ORM model for play history — an append-only event log (scrobbles).
|
||||
|
||||
``play_duration_seconds`` (how long was actually listened) feeds skip-rate for
|
||||
future ML; ``completed`` marks a full play (plan §4).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class PlayHistoryModel(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "play_history"
|
||||
__table_args__ = (Index("ix_play_history_user_id_played_at", "user_id", "played_at"),)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
played_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
play_duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""ORM models for playlists and their ordered tracks."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class PlaylistModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "playlists"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
# Optimistic-locking / future sync counter — bumped on every mutation.
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
|
||||
class PlaylistTrackModel(UUIDPrimaryKeyMixin, Base):
|
||||
"""A track's membership in a playlist.
|
||||
|
||||
``position`` is a float so a track can be inserted between two others
|
||||
without reindexing the whole list (plan §4).
|
||||
"""
|
||||
|
||||
__tablename__ = "playlist_tracks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("playlist_id", "track_id", name="uq_playlist_tracks_playlist_id_track_id"),
|
||||
)
|
||||
|
||||
playlist_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("playlists.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
position: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
added_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""ORM model for tracks — the central entity.
|
||||
|
||||
``id`` is the stable, client-facing ``content_id`` (generated app-side by
|
||||
``UUIDPrimaryKeyMixin``) — never regenerate it. Dedup is enforced on both
|
||||
``(source, source_id)`` (unique) and ``acoustid_fingerprint`` (indexed) so
|
||||
imports/downloads stay idempotent (plan §4, §6.1).
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.db.base import Base
|
||||
from app.infrastructure.db.models.enums import MetadataStatus, StoragePolicy, TrackAvailability
|
||||
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class TrackModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "tracks"
|
||||
__table_args__ = (
|
||||
# Dedup by source identity — a source never yields the same id twice.
|
||||
UniqueConstraint("source", "source_id", name="uq_tracks_source_source_id"),
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(String(1024), index=True, nullable=False)
|
||||
artist_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("artists.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
album_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("albums.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
nullable=True,
|
||||
)
|
||||
track_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
genre: Mapped[str | None] = mapped_column(String(255), index=True, nullable=True)
|
||||
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# -- file (original, stored as-is) -----------------------------------
|
||||
# NULL on a remote placeholder (not yet materialized) — see ``availability``.
|
||||
storage_uri: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
file_format: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
bitrate: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# ``remote`` = placeholder with no local audio yet; materialize() flips this
|
||||
# to ``local`` once the file is downloaded and ``storage_uri`` is filled in.
|
||||
availability: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default=TrackAvailability.LOCAL.value,
|
||||
)
|
||||
|
||||
# -- dedup / external ids --------------------------------------------
|
||||
acoustid_fingerprint: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
|
||||
|
||||
# -- provenance / policy ---------------------------------------------
|
||||
source: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
source_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
is_replaceable: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
storage_policy: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default=StoragePolicy.AS_IS.value,
|
||||
)
|
||||
metadata_status: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default=MetadataStatus.PENDING.value,
|
||||
)
|
||||
# Human-readable reason the last enrichment run set ``failed`` (no match, or
|
||||
# an unexpected worker error). ``None`` once a run succeeds. Surfaced in the
|
||||
# UI so a stuck/failed track is diagnosable, not silent.
|
||||
metadata_error: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# When the last enrichment run finished (success or failure). ``None`` while
|
||||
# still ``pending`` — lets the UI distinguish "queued/running" from "done".
|
||||
enriched_at: Mapped[dt.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
added_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
nullable=True,
|
||||
)
|
||||
@@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
# Recoverable Subsonic app-password, Fernet-encrypted at rest. NULL until the
|
||||
# user generates one. Never the argon2 login password — see core.security.
|
||||
subsonic_password_enc: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
"""SQLAlchemy repository adapters implementing the domain ports."""
|
||||
|
||||
from app.infrastructure.db.repositories.album_repository import SqlAlchemyAlbumRepository
|
||||
from app.infrastructure.db.repositories.artist_repository import SqlAlchemyArtistRepository
|
||||
from app.infrastructure.db.repositories.download_job_repository import (
|
||||
SqlAlchemyDownloadJobRepository,
|
||||
)
|
||||
from app.infrastructure.db.repositories.history_repository import SqlAlchemyHistoryRepository
|
||||
from app.infrastructure.db.repositories.like_repository import SqlAlchemyLikeRepository
|
||||
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
|
||||
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__ = [
|
||||
"SqlAlchemyAlbumRepository",
|
||||
"SqlAlchemyArtistRepository",
|
||||
"SqlAlchemyDownloadJobRepository",
|
||||
"SqlAlchemyHistoryRepository",
|
||||
"SqlAlchemyLikeRepository",
|
||||
"SqlAlchemyPlaylistRepository",
|
||||
"SqlAlchemyRefreshTokenRepository",
|
||||
"SqlAlchemyTrackRepository",
|
||||
"SqlAlchemyUserRepository",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Album repository — adapter over ``AsyncSession``."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.album import Album
|
||||
from app.infrastructure.db.models.album import AlbumModel
|
||||
from app.infrastructure.db.models.track import TrackModel
|
||||
|
||||
|
||||
def _to_entity(row: AlbumModel) -> Album:
|
||||
return Album(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
artist_id=row.artist_id,
|
||||
year=row.year,
|
||||
cover_path=row.cover_path,
|
||||
musicbrainz_id=row.musicbrainz_id,
|
||||
source=row.source,
|
||||
source_id=row.source_id,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyAlbumRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_or_create(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
year: int | None,
|
||||
musicbrainz_id: str | None,
|
||||
) -> Album:
|
||||
"""Resolve an album by ``(title, artist_id)``, creating it if absent.
|
||||
Backfills ``year``/``musicbrainz_id`` onto an existing row when it lacks
|
||||
them and enrichment now has values (gap-fill, never overwrite)."""
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(AlbumModel).where(
|
||||
AlbumModel.title == title,
|
||||
AlbumModel.artist_id == artist_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = AlbumModel(
|
||||
title=title,
|
||||
artist_id=artist_id,
|
||||
year=year,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
)
|
||||
self._session.add(row)
|
||||
else:
|
||||
if row.year is None and year is not None:
|
||||
row.year = year
|
||||
if row.musicbrainz_id is None and musicbrainz_id is not None:
|
||||
row.musicbrainz_id = musicbrainz_id
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def get_or_create_remote(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
year: int | None,
|
||||
musicbrainz_id: str | None,
|
||||
source: str,
|
||||
source_id: str,
|
||||
) -> Album:
|
||||
"""Resolve an album by ``(source, source_id)`` first (re-browse/save
|
||||
dedup), falling back to ``(title, artist_id)`` and gap-filling the
|
||||
remote ids onto an existing row, else creating a new remote-bound row."""
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(AlbumModel).where(
|
||||
AlbumModel.source == source,
|
||||
AlbumModel.source_id == source_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(AlbumModel).where(
|
||||
AlbumModel.title == title,
|
||||
AlbumModel.artist_id == artist_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = AlbumModel(
|
||||
title=title,
|
||||
artist_id=artist_id,
|
||||
year=year,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
)
|
||||
self._session.add(row)
|
||||
else:
|
||||
if row.year is None and year is not None:
|
||||
row.year = year
|
||||
if row.musicbrainz_id is None and musicbrainz_id is not None:
|
||||
row.musicbrainz_id = musicbrainz_id
|
||||
if row.source is None and row.source_id is None:
|
||||
row.source = source
|
||||
row.source_id = source_id
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None:
|
||||
row = await self._session.get(AlbumModel, album_id)
|
||||
if row is not None:
|
||||
row.cover_path = cover_path
|
||||
await self._session.flush()
|
||||
|
||||
async def get_by_id(self, album_id: uuid.UUID) -> Album | None:
|
||||
row = await self._session.get(AlbumModel, album_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]:
|
||||
if not ids:
|
||||
return []
|
||||
rows = (
|
||||
(await self._session.execute(select(AlbumModel).where(AlbumModel.id.in_(ids))))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int:
|
||||
stmt = select(func.count()).select_from(AlbumModel)
|
||||
if artist_id is not None:
|
||||
stmt = stmt.where(AlbumModel.artist_id == artist_id)
|
||||
if q:
|
||||
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
|
||||
return (await self._session.execute(stmt)).scalar_one()
|
||||
|
||||
async def track_count(self, album_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count()).select_from(TrackModel).where(TrackModel.album_id == album_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not album_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(TrackModel.album_id, func.count(TrackModel.id).label("cnt"))
|
||||
.where(TrackModel.album_id.in_(album_ids))
|
||||
.group_by(TrackModel.album_id)
|
||||
)
|
||||
).all()
|
||||
return {row.album_id: row.cnt for row in rows}
|
||||
|
||||
# list must come after methods using list[...] in signatures (builtin name shadowing)
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
artist_id: uuid.UUID | None,
|
||||
q: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
sort_by: str = "title",
|
||||
order: str = "asc",
|
||||
) -> list[Album]:
|
||||
stmt = select(AlbumModel)
|
||||
if artist_id is not None:
|
||||
stmt = stmt.where(AlbumModel.artist_id == artist_id)
|
||||
if q:
|
||||
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
|
||||
|
||||
if order == "random":
|
||||
stmt = stmt.order_by(func.random())
|
||||
else:
|
||||
col = AlbumModel.created_at if sort_by == "created" else AlbumModel.title
|
||||
stmt = stmt.order_by(col.desc() if order == "desc" else col.asc())
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Artist repository — adapter over ``AsyncSession``."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.track import Artist
|
||||
from app.infrastructure.db.models.album import AlbumModel
|
||||
from app.infrastructure.db.models.artist import ArtistModel
|
||||
from app.infrastructure.db.models.track import TrackModel
|
||||
|
||||
|
||||
def _to_entity(row: ArtistModel) -> Artist:
|
||||
return Artist(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
source=row.source,
|
||||
source_id=row.source_id,
|
||||
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)
|
||||
|
||||
async def get_or_create_remote(self, *, name: str, source: str, source_id: str) -> Artist:
|
||||
"""Resolve an artist by ``(source, source_id)`` first (re-browse/save
|
||||
dedup), falling back to ``name`` and gap-filling the remote ids onto an
|
||||
existing row, else creating a new remote-bound row."""
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(ArtistModel).where(
|
||||
ArtistModel.source == source,
|
||||
ArtistModel.source_id == source_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = (
|
||||
await self._session.execute(select(ArtistModel).where(ArtistModel.name == name))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = ArtistModel(name=name, source=source, source_id=source_id)
|
||||
self._session.add(row)
|
||||
elif row.source is None and row.source_id is None:
|
||||
row.source = source
|
||||
row.source_id = source_id
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None:
|
||||
row = await self._session.get(ArtistModel, artist_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]:
|
||||
if not ids:
|
||||
return []
|
||||
rows = (
|
||||
(await self._session.execute(select(ArtistModel).where(ArtistModel.id.in_(ids))))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]:
|
||||
stmt = select(ArtistModel)
|
||||
if q:
|
||||
stmt = stmt.where(ArtistModel.name.ilike(f"%{q}%"))
|
||||
stmt = stmt.order_by(ArtistModel.name).limit(limit).offset(offset)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def count(self, *, q: str | None) -> int:
|
||||
stmt = select(func.count()).select_from(ArtistModel)
|
||||
if q:
|
||||
stmt = stmt.where(ArtistModel.name.ilike(f"%{q}%"))
|
||||
return (await self._session.execute(stmt)).scalar_one()
|
||||
|
||||
async def album_count(self, artist_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count())
|
||||
.select_from(AlbumModel)
|
||||
.where(AlbumModel.artist_id == artist_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def track_count(self, artist_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count())
|
||||
.select_from(TrackModel)
|
||||
.where(TrackModel.artist_id == artist_id)
|
||||
)
|
||||
).scalar_one()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Download job repository — adapter over ``AsyncSession`` (plan §6.1)."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.download import DownloadJob
|
||||
from app.infrastructure.db.models.download_job import DownloadJobModel
|
||||
from app.infrastructure.db.models.enums import DownloadStatus
|
||||
|
||||
# Jobs that are not yet finished — used to dedup an in-flight download.
|
||||
_ACTIVE_STATUSES = (
|
||||
DownloadStatus.QUEUED.value,
|
||||
DownloadStatus.DOWNLOADING.value,
|
||||
DownloadStatus.ENRICHING.value,
|
||||
)
|
||||
|
||||
|
||||
def _to_entity(row: DownloadJobModel) -> DownloadJob:
|
||||
return DownloadJob(
|
||||
id=row.id,
|
||||
source=row.source,
|
||||
source_id=row.source_id,
|
||||
query=row.query,
|
||||
requested_by=row.requested_by,
|
||||
status=row.status,
|
||||
progress=row.progress,
|
||||
error_message=row.error_message,
|
||||
retry_count=row.retry_count,
|
||||
track_id=row.track_id,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyDownloadJobRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str | None,
|
||||
query: str | None,
|
||||
requested_by: uuid.UUID | None,
|
||||
) -> DownloadJob:
|
||||
row = DownloadJobModel(
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
query=query,
|
||||
requested_by=requested_by,
|
||||
status=DownloadStatus.QUEUED.value,
|
||||
progress=0.0,
|
||||
retry_count=0,
|
||||
)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None:
|
||||
row = await self._session.get(DownloadJobModel, job_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(DownloadJobModel)
|
||||
.where(
|
||||
DownloadJobModel.source == source,
|
||||
DownloadJobModel.source_id == source_id,
|
||||
DownloadJobModel.status.in_(_ACTIVE_STATUSES),
|
||||
)
|
||||
.order_by(DownloadJobModel.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
requested_by: uuid.UUID | None,
|
||||
status: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[DownloadJob]:
|
||||
stmt = select(DownloadJobModel)
|
||||
if requested_by is not None:
|
||||
stmt = stmt.where(DownloadJobModel.requested_by == requested_by)
|
||||
if status is not None:
|
||||
stmt = stmt.where(DownloadJobModel.status == status)
|
||||
stmt = stmt.order_by(DownloadJobModel.created_at.desc()).limit(limit).offset(offset)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def count(self, *, requested_by: uuid.UUID | None, status: str | None) -> int:
|
||||
stmt = select(func.count()).select_from(DownloadJobModel)
|
||||
if requested_by is not None:
|
||||
stmt = stmt.where(DownloadJobModel.requested_by == requested_by)
|
||||
if status is not None:
|
||||
stmt = stmt.where(DownloadJobModel.status == status)
|
||||
return (await self._session.execute(stmt)).scalar_one()
|
||||
|
||||
async def set_status(
|
||||
self,
|
||||
job_id: uuid.UUID,
|
||||
*,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
track_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
row = await self._session.get(DownloadJobModel, job_id)
|
||||
if row is None:
|
||||
return
|
||||
row.status = status
|
||||
# ``error_message`` is always written: a successful transition clears a
|
||||
# stale reason from an earlier failed attempt.
|
||||
row.error_message = error_message
|
||||
if track_id is not None:
|
||||
row.track_id = track_id
|
||||
if status == DownloadStatus.DONE.value:
|
||||
row.progress = 1.0
|
||||
await self._session.flush()
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, progress: float) -> None:
|
||||
row = await self._session.get(DownloadJobModel, job_id)
|
||||
if row is None:
|
||||
return
|
||||
row.progress = max(0.0, min(1.0, progress))
|
||||
await self._session.flush()
|
||||
|
||||
async def increment_retry(self, job_id: uuid.UUID) -> int:
|
||||
row = await self._session.get(DownloadJobModel, job_id)
|
||||
if row is None:
|
||||
return 0
|
||||
row.retry_count += 1
|
||||
await self._session.flush()
|
||||
return row.retry_count
|
||||
|
||||
async def delete(self, job_id: uuid.UUID) -> None:
|
||||
row = await self._session.get(DownloadJobModel, job_id)
|
||||
if row is not None:
|
||||
await self._session.delete(row)
|
||||
await self._session.flush()
|
||||
|
||||
async def failure_rate(self, source: str, *, since: dt.datetime) -> float:
|
||||
total, failed = (
|
||||
await self._session.execute(
|
||||
select(
|
||||
func.count(),
|
||||
func.count().filter(DownloadJobModel.status == DownloadStatus.FAILED.value),
|
||||
)
|
||||
.select_from(DownloadJobModel)
|
||||
.where(
|
||||
DownloadJobModel.source == source,
|
||||
DownloadJobModel.created_at >= since,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
return (failed / total) if total else 0.0
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Play history repository — adapter over ``AsyncSession``."""
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.history import PlayHistoryEntry
|
||||
from app.infrastructure.db.models.play_history import PlayHistoryModel
|
||||
|
||||
|
||||
def _to_entity(row: PlayHistoryModel) -> PlayHistoryEntry:
|
||||
return PlayHistoryEntry(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
track_id=row.track_id,
|
||||
played_at=row.played_at,
|
||||
play_duration_seconds=row.play_duration_seconds,
|
||||
completed=row.completed,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyHistoryRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(
|
||||
self,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
track_id: uuid.UUID,
|
||||
played_at: dt.datetime,
|
||||
play_duration_seconds: int | None,
|
||||
completed: bool,
|
||||
) -> PlayHistoryEntry:
|
||||
row = PlayHistoryModel(
|
||||
user_id=user_id,
|
||||
track_id=track_id,
|
||||
played_at=played_at,
|
||||
play_duration_seconds=play_duration_seconds,
|
||||
completed=completed,
|
||||
)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def list(self, *, user_id: uuid.UUID, limit: int, offset: int) -> list[PlayHistoryEntry]:
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(PlayHistoryModel)
|
||||
.where(PlayHistoryModel.user_id == user_id)
|
||||
.order_by(PlayHistoryModel.played_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def count(self, *, user_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count())
|
||||
.select_from(PlayHistoryModel)
|
||||
.where(PlayHistoryModel.user_id == user_id)
|
||||
)
|
||||
).scalar_one()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Like repository — adapter over ``AsyncSession``.
|
||||
|
||||
Likes are an append-only event log. Current state = latest event per (user, track).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.like import Like
|
||||
from app.domain.entities.track import Track
|
||||
from app.infrastructure.db.models.like import LikeModel
|
||||
from app.infrastructure.db.models.track import TrackModel
|
||||
|
||||
|
||||
def _to_entity(row: LikeModel) -> Like:
|
||||
return Like(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
track_id=row.track_id,
|
||||
value=row.value,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _track_to_entity(row: TrackModel) -> Track:
|
||||
return Track(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
artist_id=row.artist_id,
|
||||
album_id=row.album_id,
|
||||
storage_uri=row.storage_uri,
|
||||
file_format=row.file_format,
|
||||
file_size=row.file_size,
|
||||
source=row.source,
|
||||
source_id=row.source_id,
|
||||
duration_seconds=row.duration_seconds,
|
||||
genre=row.genre,
|
||||
year=row.year,
|
||||
track_number=row.track_number,
|
||||
metadata_status=row.metadata_status,
|
||||
metadata_error=row.metadata_error,
|
||||
enriched_at=row.enriched_at,
|
||||
availability=row.availability,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyLikeRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like:
|
||||
row = LikeModel(user_id=user_id, track_id=track_id, value=value)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def get_latest_state(
|
||||
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
|
||||
) -> list[Like]:
|
||||
if not track_ids:
|
||||
return []
|
||||
# Subquery: max(created_at) per track for this user
|
||||
max_sq = (
|
||||
select(
|
||||
LikeModel.track_id,
|
||||
func.max(LikeModel.created_at).label("latest"),
|
||||
)
|
||||
.where(LikeModel.user_id == user_id, LikeModel.track_id.in_(track_ids))
|
||||
.group_by(LikeModel.track_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(LikeModel)
|
||||
.join(
|
||||
max_sq,
|
||||
(LikeModel.track_id == max_sq.c.track_id)
|
||||
& (LikeModel.created_at == max_sq.c.latest),
|
||||
)
|
||||
.where(LikeModel.user_id == user_id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def list_liked_tracks(
|
||||
self, *, user_id: uuid.UUID, limit: int, offset: int
|
||||
) -> list[Track]:
|
||||
# Tracks where the latest like event has value='like', ordered by like time desc
|
||||
max_sq = (
|
||||
select(
|
||||
LikeModel.track_id,
|
||||
func.max(LikeModel.created_at).label("latest"),
|
||||
)
|
||||
.where(LikeModel.user_id == user_id)
|
||||
.group_by(LikeModel.track_id)
|
||||
.subquery()
|
||||
)
|
||||
liked_sq = (
|
||||
select(LikeModel.track_id, LikeModel.created_at)
|
||||
.join(
|
||||
max_sq,
|
||||
(LikeModel.track_id == max_sq.c.track_id)
|
||||
& (LikeModel.created_at == max_sq.c.latest),
|
||||
)
|
||||
.where(LikeModel.user_id == user_id, LikeModel.value == "like")
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(TrackModel)
|
||||
.join(liked_sq, TrackModel.id == liked_sq.c.track_id)
|
||||
.order_by(liked_sq.c.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_track_to_entity(r) for r in rows]
|
||||
|
||||
async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int:
|
||||
max_sq = (
|
||||
select(
|
||||
LikeModel.track_id,
|
||||
func.max(LikeModel.created_at).label("latest"),
|
||||
)
|
||||
.where(LikeModel.user_id == user_id)
|
||||
.group_by(LikeModel.track_id)
|
||||
.subquery()
|
||||
)
|
||||
liked_sq = (
|
||||
select(LikeModel.track_id)
|
||||
.join(
|
||||
max_sq,
|
||||
(LikeModel.track_id == max_sq.c.track_id)
|
||||
& (LikeModel.created_at == max_sq.c.latest),
|
||||
)
|
||||
.where(LikeModel.user_id == user_id, LikeModel.value == "like")
|
||||
.subquery()
|
||||
)
|
||||
return (
|
||||
await self._session.execute(select(func.count()).select_from(liked_sq))
|
||||
).scalar_one()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Playlist repository — adapter over ``AsyncSession``."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities.playlist import Playlist
|
||||
from app.domain.entities.track import Track
|
||||
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
|
||||
from app.infrastructure.db.models.track import TrackModel
|
||||
|
||||
|
||||
def _to_entity(row: PlaylistModel) -> Playlist:
|
||||
return Playlist(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
owner_id=row.owner_id,
|
||||
version=row.version,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _track_to_entity(row: TrackModel) -> Track:
|
||||
return Track(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
artist_id=row.artist_id,
|
||||
album_id=row.album_id,
|
||||
storage_uri=row.storage_uri,
|
||||
file_format=row.file_format,
|
||||
file_size=row.file_size,
|
||||
source=row.source,
|
||||
source_id=row.source_id,
|
||||
duration_seconds=row.duration_seconds,
|
||||
genre=row.genre,
|
||||
year=row.year,
|
||||
track_number=row.track_number,
|
||||
metadata_status=row.metadata_status,
|
||||
metadata_error=row.metadata_error,
|
||||
enriched_at=row.enriched_at,
|
||||
availability=row.availability,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyPlaylistRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_id(self, playlist_id: uuid.UUID) -> Playlist | None:
|
||||
row = await self._session.get(PlaylistModel, playlist_id)
|
||||
return _to_entity(row) if row is not None else None
|
||||
|
||||
async def count(self, *, owner_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count())
|
||||
.select_from(PlaylistModel)
|
||||
.where(PlaylistModel.owner_id == owner_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def add(self, *, name: str, description: str | None, owner_id: uuid.UUID) -> Playlist:
|
||||
row = PlaylistModel(name=name, description=description, owner_id=owner_id)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def update(
|
||||
self, playlist_id: uuid.UUID, *, name: str | None, description: str | None
|
||||
) -> Playlist:
|
||||
row = await self._session.get(PlaylistModel, playlist_id)
|
||||
if row is None:
|
||||
from app.domain.errors import NotFoundError
|
||||
|
||||
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||
if name is not None:
|
||||
row.name = name
|
||||
if description is not None:
|
||||
row.description = description
|
||||
row.version = row.version + 1
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def delete(self, playlist_id: uuid.UUID) -> None:
|
||||
row = await self._session.get(PlaylistModel, playlist_id)
|
||||
if row is not None:
|
||||
await self._session.delete(row)
|
||||
await self._session.flush()
|
||||
|
||||
async def track_count(self, playlist_id: uuid.UUID) -> int:
|
||||
return (
|
||||
await self._session.execute(
|
||||
select(func.count())
|
||||
.select_from(PlaylistTrackModel)
|
||||
.where(PlaylistTrackModel.playlist_id == playlist_id)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
async def track_count_many(self, playlist_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not playlist_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(
|
||||
PlaylistTrackModel.playlist_id,
|
||||
func.count(PlaylistTrackModel.id).label("cnt"),
|
||||
)
|
||||
.where(PlaylistTrackModel.playlist_id.in_(playlist_ids))
|
||||
.group_by(PlaylistTrackModel.playlist_id)
|
||||
)
|
||||
).all()
|
||||
return {row.playlist_id: row.cnt for row in rows}
|
||||
|
||||
async def get_tracks(self, playlist_id: uuid.UUID, *, limit: int, offset: int) -> list[Track]:
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(TrackModel)
|
||||
.join(PlaylistTrackModel, TrackModel.id == PlaylistTrackModel.track_id)
|
||||
.where(PlaylistTrackModel.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrackModel.position)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_track_to_entity(r) for r in rows]
|
||||
|
||||
async def get_track_total(self, playlist_id: uuid.UUID) -> int:
|
||||
return await self.track_count(playlist_id)
|
||||
|
||||
async def add_track(
|
||||
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
|
||||
) -> None:
|
||||
row = PlaylistTrackModel(playlist_id=playlist_id, track_id=track_id, position=position)
|
||||
self._session.add(row)
|
||||
playlist = await self._session.get(PlaylistModel, playlist_id)
|
||||
if playlist is not None:
|
||||
playlist.version = playlist.version + 1
|
||||
await self._session.flush()
|
||||
|
||||
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
select(PlaylistTrackModel).where(
|
||||
PlaylistTrackModel.playlist_id == playlist_id,
|
||||
PlaylistTrackModel.track_id == track_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
await self._session.delete(row)
|
||||
playlist = await self._session.get(PlaylistModel, playlist_id)
|
||||
if playlist is not None:
|
||||
playlist.version = playlist.version + 1
|
||||
await self._session.flush()
|
||||
|
||||
async def max_position(self, playlist_id: uuid.UUID) -> float:
|
||||
result = (
|
||||
await self._session.execute(
|
||||
select(func.max(PlaylistTrackModel.position)).where(
|
||||
PlaylistTrackModel.playlist_id == playlist_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return float(result) if result is not None else 0.0
|
||||
|
||||
# list must come after methods using list[...] in signatures (builtin name shadowing)
|
||||
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]:
|
||||
rows = (
|
||||
(
|
||||
await self._session.execute(
|
||||
select(PlaylistModel)
|
||||
.where(PlaylistModel.owner_id == owner_id)
|
||||
.order_by(PlaylistModel.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_entity(r) for r in rows]
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Track repository — adapter over ``AsyncSession``."""
|
||||
|
||||
import datetime as dt
|
||||
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
|
||||
from app.infrastructure.db.models.enums import TrackAvailability
|
||||
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,
|
||||
album_id=row.album_id,
|
||||
storage_uri=row.storage_uri,
|
||||
file_format=row.file_format,
|
||||
file_size=row.file_size,
|
||||
source=row.source,
|
||||
source_id=row.source_id,
|
||||
duration_seconds=row.duration_seconds,
|
||||
genre=row.genre,
|
||||
year=row.year,
|
||||
track_number=row.track_number,
|
||||
metadata_status=row.metadata_status,
|
||||
metadata_error=row.metadata_error,
|
||||
enriched_at=row.enriched_at,
|
||||
availability=row.availability,
|
||||
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,
|
||||
storage_uri: str | None,
|
||||
file_format: str | None,
|
||||
file_size: int | None,
|
||||
source: str,
|
||||
source_id: str,
|
||||
metadata_status: str,
|
||||
added_by: uuid.UUID | None,
|
||||
availability: str = TrackAvailability.LOCAL.value,
|
||||
) -> Track:
|
||||
row = TrackModel(
|
||||
id=id,
|
||||
title=title,
|
||||
artist_id=artist_id,
|
||||
storage_uri=storage_uri,
|
||||
file_format=file_format,
|
||||
file_size=file_size,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
metadata_status=metadata_status,
|
||||
added_by=added_by,
|
||||
availability=availability,
|
||||
)
|
||||
self._session.add(row)
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def materialize(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
*,
|
||||
storage_uri: str,
|
||||
file_format: str,
|
||||
file_size: int,
|
||||
bitrate: int | None,
|
||||
) -> Track:
|
||||
"""Fill in a remote placeholder's audio fields after a download (lazy
|
||||
materialization). ``track.id`` is unchanged, so likes/playlists/queue
|
||||
entries that already reference it keep working."""
|
||||
row = await self._session.get(TrackModel, track_id)
|
||||
if row is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
row.storage_uri = storage_uri
|
||||
row.file_format = file_format
|
||||
row.file_size = file_size
|
||||
if bitrate is not None:
|
||||
row.bitrate = bitrate
|
||||
row.availability = TrackAvailability.LOCAL.value
|
||||
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()
|
||||
|
||||
async def genres(self) -> list[tuple[str, int]]:
|
||||
"""Distinct non-null genres with their song counts, most common first.
|
||||
|
||||
Defined before ``list`` — the method named ``list`` shadows the builtin
|
||||
in later annotations within the class body."""
|
||||
rows = (
|
||||
await self._session.execute(
|
||||
select(TrackModel.genre, func.count(TrackModel.id).label("cnt"))
|
||||
.where(TrackModel.genre.is_not(None))
|
||||
.group_by(TrackModel.genre)
|
||||
.order_by(func.count(TrackModel.id).desc())
|
||||
)
|
||||
).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),
|
||||
)
|
||||
.where(TrackModel.file_format.is_not(None))
|
||||
.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,
|
||||
*,
|
||||
artist_id: uuid.UUID | None,
|
||||
album_id: uuid.UUID | None,
|
||||
q: str | None,
|
||||
source: str | None = None,
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Track]:
|
||||
stmt = select(TrackModel)
|
||||
if artist_id is not None:
|
||||
stmt = stmt.where(TrackModel.artist_id == artist_id)
|
||||
if album_id is not None:
|
||||
stmt = stmt.where(TrackModel.album_id == album_id)
|
||||
if source is not None:
|
||||
stmt = stmt.where(TrackModel.source == source)
|
||||
if q:
|
||||
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
|
||||
|
||||
if sort_by == "artist":
|
||||
stmt = stmt.join(ArtistModel, TrackModel.artist_id == ArtistModel.id)
|
||||
col_artist = ArtistModel.name
|
||||
stmt = stmt.order_by(col_artist.asc() if order == "asc" else col_artist.desc())
|
||||
elif sort_by == "title":
|
||||
col_title = TrackModel.title
|
||||
stmt = stmt.order_by(col_title.asc() if order == "asc" else col_title.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(
|
||||
TrackModel.created_at.asc() if order == "asc" else TrackModel.created_at.desc()
|
||||
)
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
rows = (await self._session.execute(stmt)).scalars().all()
|
||||
return [_to_entity(r) for r in rows]
|
||||
|
||||
async def count(
|
||||
self,
|
||||
*,
|
||||
artist_id: uuid.UUID | None,
|
||||
album_id: uuid.UUID | None,
|
||||
q: str | None,
|
||||
source: str | None = None,
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(TrackModel)
|
||||
if artist_id is not None:
|
||||
stmt = stmt.where(TrackModel.artist_id == artist_id)
|
||||
if album_id is not None:
|
||||
stmt = stmt.where(TrackModel.album_id == album_id)
|
||||
if source is not None:
|
||||
stmt = stmt.where(TrackModel.source == source)
|
||||
if q:
|
||||
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
|
||||
return (await self._session.execute(stmt)).scalar_one()
|
||||
|
||||
async def update(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
*,
|
||||
title: str | None,
|
||||
genre: str | None,
|
||||
year: int | None,
|
||||
artist_id: uuid.UUID | None = None,
|
||||
album_id: uuid.UUID | None = None,
|
||||
track_number: int | None = None,
|
||||
) -> Track:
|
||||
row = await self._session.get(TrackModel, track_id)
|
||||
if row is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
if title is not None:
|
||||
row.title = title
|
||||
if genre is not None:
|
||||
row.genre = genre
|
||||
if year is not None:
|
||||
row.year = year
|
||||
if artist_id is not None:
|
||||
row.artist_id = artist_id
|
||||
if album_id is not None:
|
||||
row.album_id = album_id
|
||||
if track_number is not None:
|
||||
row.track_number = track_number
|
||||
row.metadata_status = "manual"
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def apply_enrichment(
|
||||
self,
|
||||
track_id: uuid.UUID,
|
||||
*,
|
||||
title: str,
|
||||
artist_id: uuid.UUID,
|
||||
album_id: uuid.UUID | None,
|
||||
genre: str | None,
|
||||
year: int | None,
|
||||
track_number: int | None,
|
||||
duration_seconds: int | None,
|
||||
bitrate: int | None,
|
||||
acoustid_fingerprint: str | None,
|
||||
musicbrainz_id: str | None,
|
||||
metadata_status: str,
|
||||
metadata_error: str | None = None,
|
||||
) -> Track:
|
||||
row = await self._session.get(TrackModel, track_id)
|
||||
if row is None:
|
||||
raise NotFoundError(f"Track {track_id} not found.")
|
||||
# Identity + status are authoritative for an enrichment run.
|
||||
row.title = title
|
||||
row.artist_id = artist_id
|
||||
row.metadata_status = metadata_status
|
||||
# A finished run always stamps outcome: clear/set the reason and mark the
|
||||
# completion time so the UI can tell "still pending" from "done/failed".
|
||||
row.metadata_error = metadata_error
|
||||
row.enriched_at = dt.datetime.now(dt.UTC)
|
||||
# Nullable extras: fill gaps only — never erase data a prior run found.
|
||||
if album_id is not None:
|
||||
row.album_id = album_id
|
||||
if genre is not None:
|
||||
row.genre = genre
|
||||
if year is not None:
|
||||
row.year = year
|
||||
if track_number is not None:
|
||||
row.track_number = track_number
|
||||
if duration_seconds is not None:
|
||||
row.duration_seconds = duration_seconds
|
||||
if bitrate is not None:
|
||||
row.bitrate = bitrate
|
||||
if acoustid_fingerprint is not None:
|
||||
row.acoustid_fingerprint = acoustid_fingerprint
|
||||
if musicbrainz_id is not None:
|
||||
row.musicbrainz_id = musicbrainz_id
|
||||
await self._session.flush()
|
||||
await self._session.refresh(row)
|
||||
return _to_entity(row)
|
||||
|
||||
async def mark_enrichment_failed(self, track_id: uuid.UUID, *, error: str) -> None:
|
||||
"""Record that an enrichment run crashed (unexpected exception). Runs in
|
||||
its own session so the failure is persisted even though the run's own
|
||||
transaction rolled back. Never overwrites ``manual`` (a no-op then), and
|
||||
a missing track is a clean no-op."""
|
||||
row = await self._session.get(TrackModel, track_id)
|
||||
if row is None or row.metadata_status == "manual":
|
||||
return
|
||||
row.metadata_status = "failed"
|
||||
row.metadata_error = error
|
||||
row.enriched_at = dt.datetime.now(dt.UTC)
|
||||
await self._session.flush()
|
||||
@@ -10,7 +10,7 @@ import uuid
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities import Credentials, User
|
||||
from app.domain.entities import Credentials, SubsonicCredentials, User
|
||||
from app.domain.errors import NotFoundError
|
||||
from app.infrastructure.db.models import UserModel
|
||||
|
||||
@@ -91,3 +91,22 @@ class SqlAlchemyUserRepository:
|
||||
return (
|
||||
await self._session.execute(select(func.count()).select_from(UserModel))
|
||||
).scalar_one()
|
||||
|
||||
async def get_subsonic_credentials_by_username(
|
||||
self, username: str
|
||||
) -> SubsonicCredentials | None:
|
||||
row = (
|
||||
await self._session.execute(select(UserModel).where(UserModel.username == username))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return SubsonicCredentials(user=_to_entity(row), password_enc=row.subsonic_password_enc)
|
||||
|
||||
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
|
||||
row = await self._get_row(user_id)
|
||||
return row.subsonic_password_enc
|
||||
|
||||
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
|
||||
row = await self._get_row(user_id)
|
||||
row.subsonic_password_enc = password_enc
|
||||
await self._session.flush()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Metadata-enrichment adapters: tag reader, fingerprinter, AcoustID client."""
|
||||
@@ -0,0 +1,161 @@
|
||||
"""AcoustIdHttpClient — identifies a recording from its Chromaprint fingerprint.
|
||||
|
||||
One ``/v2/lookup`` call with ``meta=recordings+releasegroups`` returns the
|
||||
AcoustID id, the MusicBrainz recording id, and canonical title/artist/album —
|
||||
metadata that itself originates from MusicBrainz, so a separate MB call is not
|
||||
needed for Phase 1 (plan §6.2 steps 2-3 collapsed into one request).
|
||||
|
||||
Graceful degradation: no API key → ``is_available()`` is False and the whole
|
||||
fingerprint path is skipped; any network/parse error → ``lookup`` returns
|
||||
``None``. A small inter-call delay keeps us within AcoustID's rate limit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.metadata import Fingerprint, RecordingMatch
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_DEFAULT_URL = "https://api.acoustid.org/v2/lookup"
|
||||
_TIMEOUT_SECONDS = 10.0
|
||||
_MIN_INTERVAL_SECONDS = 0.34 # AcoustID allows ~3 req/s; stay polite
|
||||
|
||||
|
||||
class AcoustIdHttpClient:
|
||||
"""Implements :class:`app.domain.ports.AcoustIdClient`."""
|
||||
|
||||
_throttle_lock = asyncio.Lock()
|
||||
_last_call_monotonic = 0.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None,
|
||||
user_agent: str,
|
||||
api_url: str = _DEFAULT_URL,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._user_agent = user_agent
|
||||
self._api_url = api_url
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self._api_key)
|
||||
|
||||
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
|
||||
payload = await self._lookup_raw(fingerprint)
|
||||
if payload is None:
|
||||
return None
|
||||
return _parse_best_match(payload)
|
||||
|
||||
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]:
|
||||
payload = await self._lookup_raw(fingerprint)
|
||||
if payload is None:
|
||||
return []
|
||||
return _parse_matches(payload)
|
||||
|
||||
async def _lookup_raw(self, fingerprint: Fingerprint) -> object | None:
|
||||
if not self._api_key:
|
||||
return None
|
||||
try:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
headers={"User-Agent": self._user_agent},
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
self._api_url,
|
||||
params={
|
||||
"client": self._api_key,
|
||||
"duration": str(fingerprint.duration_seconds),
|
||||
"fingerprint": fingerprint.fingerprint,
|
||||
"meta": "recordings releasegroups",
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() # type: ignore[no-any-return]
|
||||
except httpx.HTTPError, ValueError:
|
||||
log.warning("acoustid_lookup_failed")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def _throttle(cls) -> None:
|
||||
async with cls._throttle_lock:
|
||||
elapsed = time.monotonic() - cls._last_call_monotonic
|
||||
wait = _MIN_INTERVAL_SECONDS - elapsed
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
cls._last_call_monotonic = time.monotonic()
|
||||
|
||||
|
||||
_MAX_MATCHES = 5
|
||||
|
||||
|
||||
def _parse_best_match(payload: object) -> RecordingMatch | None:
|
||||
matches = _parse_matches(payload)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _parse_matches(payload: object) -> list[RecordingMatch]:
|
||||
if not isinstance(payload, dict) or payload.get("status") != "ok":
|
||||
return []
|
||||
results = payload.get("results")
|
||||
if not isinstance(results, list) or not results:
|
||||
return []
|
||||
|
||||
# Results are returned best-score-first, but sort defensively and cap the
|
||||
# number of candidates surfaced to the editor.
|
||||
candidates = [r for r in results if isinstance(r, dict)]
|
||||
candidates.sort(key=lambda r: r.get("score", 0.0), reverse=True)
|
||||
|
||||
matches: list[RecordingMatch] = []
|
||||
for result in candidates[:_MAX_MATCHES]:
|
||||
match = _parse_one(result)
|
||||
if match is not None:
|
||||
matches.append(match)
|
||||
return matches
|
||||
|
||||
|
||||
def _parse_one(result: dict[str, object]) -> RecordingMatch | None:
|
||||
acoustid = result.get("id")
|
||||
if not isinstance(acoustid, str):
|
||||
return None
|
||||
score = float(result.get("score", 0.0)) # type: ignore[arg-type]
|
||||
|
||||
recording_mbid: str | None = None
|
||||
release_group_mbid: str | None = None
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
|
||||
recordings = result.get("recordings")
|
||||
if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict):
|
||||
rec = recordings[0]
|
||||
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
|
||||
title = rec.get("title") if isinstance(rec.get("title"), str) else None
|
||||
artists = rec.get("artists")
|
||||
if isinstance(artists, list) and artists and isinstance(artists[0], dict):
|
||||
name = artists[0].get("name")
|
||||
artist = name if isinstance(name, str) else None
|
||||
groups = rec.get("releasegroups")
|
||||
if isinstance(groups, list) and groups and isinstance(groups[0], dict):
|
||||
group = groups[0]
|
||||
gtitle = group.get("title")
|
||||
album = gtitle if isinstance(gtitle, str) else None
|
||||
gid = group.get("id")
|
||||
release_group_mbid = gid if isinstance(gid, str) else None
|
||||
|
||||
return RecordingMatch(
|
||||
acoustid=acoustid,
|
||||
score=score,
|
||||
recording_mbid=recording_mbid,
|
||||
release_group_mbid=release_group_mbid,
|
||||
title=title,
|
||||
artist=artist,
|
||||
album=album,
|
||||
year=None,
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""MutagenCoverExtractor — pulls embedded cover art from a local audio file.
|
||||
|
||||
The offline-first cover source (mirrors the tag pre-pass): a well-tagged file
|
||||
often already carries front-cover artwork (ID3 ``APIC``, FLAC/OGG picture
|
||||
blocks, MP4 ``covr``). We read it without any network call. Parsing is blocking,
|
||||
so it runs in a worker thread. Any failure degrades to ``None`` — never raises.
|
||||
|
||||
mutagen ships no type stubs, so its objects are handled as ``Any`` and accessed
|
||||
defensively (``getattr``) — the format zoo doesn't fit one static shape anyway.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from mutagen import File as MutagenFile # type: ignore[attr-defined]
|
||||
from mutagen.flac import Picture
|
||||
from mutagen.mp4 import MP4Cover
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.cover import CoverArt
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# MP4 cover format flag → MIME (mutagen exposes an int, not a content type).
|
||||
_MP4_FORMATS: dict[int, str] = {
|
||||
MP4Cover.FORMAT_JPEG: "image/jpeg",
|
||||
MP4Cover.FORMAT_PNG: "image/png",
|
||||
}
|
||||
_FRONT_COVER = 3 # APIC/Picture "type" value for the front cover
|
||||
|
||||
|
||||
class MutagenCoverExtractor:
|
||||
"""Implements :class:`app.domain.ports.CoverArtExtractor`."""
|
||||
|
||||
async def extract(self, path: Path) -> CoverArt | None:
|
||||
try:
|
||||
return await anyio.to_thread.run_sync(self._extract_sync, path)
|
||||
except Exception:
|
||||
log.warning("cover_extract_failed", path=str(path))
|
||||
return None
|
||||
|
||||
def _extract_sync(self, path: Path) -> CoverArt | None:
|
||||
audio: Any = MutagenFile(str(path))
|
||||
if audio is None:
|
||||
return None
|
||||
|
||||
# FLAC / OGG-FLAC: typed picture blocks on the file object.
|
||||
pictures = getattr(audio, "pictures", None)
|
||||
if pictures:
|
||||
cover = _from_picture(_front_or_first(pictures))
|
||||
if cover is not None:
|
||||
return cover
|
||||
|
||||
tags = audio.tags
|
||||
if tags is None:
|
||||
return None
|
||||
|
||||
# MP3 / anything with ID3 frames: APIC frames keyed as "APIC:...".
|
||||
apics = [frame for frame in tags.values() if frame.__class__.__name__ == "APIC"]
|
||||
if apics:
|
||||
cover = _from_picture(_front_or_first(apics))
|
||||
if cover is not None:
|
||||
return cover
|
||||
|
||||
get = getattr(tags, "get", None)
|
||||
if get is None:
|
||||
return None
|
||||
|
||||
# MP4 / M4A: "covr" atom holds a list of MP4Cover (a bytes subclass).
|
||||
covr = get("covr")
|
||||
if covr:
|
||||
mp4_cover = covr[0]
|
||||
content_type = _MP4_FORMATS.get(getattr(mp4_cover, "imageformat", -1), "image/jpeg")
|
||||
return CoverArt(data=bytes(mp4_cover), content_type=content_type)
|
||||
|
||||
# OGG Vorbis: base64 picture block in METADATA_BLOCK_PICTURE.
|
||||
block = get("metadata_block_picture")
|
||||
if block:
|
||||
cover = _from_picture(_decode_vorbis_picture(block[0]))
|
||||
if cover is not None:
|
||||
return cover
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _from_picture(picture: Any) -> CoverArt | None:
|
||||
"""Build a :class:`CoverArt` from a mutagen picture/APIC frame, or ``None``."""
|
||||
if picture is None:
|
||||
return None
|
||||
data = getattr(picture, "data", None)
|
||||
if not data:
|
||||
return None
|
||||
mime = getattr(picture, "mime", None) or "image/jpeg"
|
||||
return CoverArt(data=bytes(data), content_type=str(mime))
|
||||
|
||||
|
||||
def _front_or_first(pictures: list[Any]) -> Any:
|
||||
"""Prefer the front-cover picture (type 3), else the first available."""
|
||||
for pic in pictures:
|
||||
if getattr(pic, "type", None) == _FRONT_COVER:
|
||||
return pic
|
||||
return pictures[0] if pictures else None
|
||||
|
||||
|
||||
def _decode_vorbis_picture(encoded: str) -> Any:
|
||||
try:
|
||||
return Picture(base64.b64decode(encoded)) # type: ignore[no-untyped-call]
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,83 @@
|
||||
"""CoverArtArchiveClient — fetches front cover art from the Cover Art Archive.
|
||||
|
||||
The network fallback when a file carries no embedded artwork: given a
|
||||
MusicBrainz **release-group** id (supplied by the AcoustID lookup), request the
|
||||
front image from ``coverartarchive.org``. The CAA redirects to the Internet
|
||||
Archive, so redirects are followed. ``thumbnail`` 500px keeps payloads small.
|
||||
|
||||
Graceful degradation (CLAUDE.md): no release-group id → never called; any
|
||||
network/HTTP error (incl. 404 "no cover") → returns ``None``, never raises. A
|
||||
small inter-call delay respects the shared MusicBrainz/CAA infrastructure.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.entities.cover import CoverArt
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_DEFAULT_BASE_URL = "https://coverartarchive.org"
|
||||
_TIMEOUT_SECONDS = 15.0
|
||||
_MIN_INTERVAL_SECONDS = 1.0 # CAA piggybacks on MusicBrainz infra; stay polite
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # ignore absurdly large images
|
||||
|
||||
|
||||
class CoverArtArchiveClient:
|
||||
"""Implements :class:`app.domain.ports.CoverArtProvider`."""
|
||||
|
||||
_throttle_lock = asyncio.Lock()
|
||||
_last_call_monotonic = 0.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_agent: str,
|
||||
enabled: bool = True,
|
||||
base_url: str = _DEFAULT_BASE_URL,
|
||||
) -> None:
|
||||
self._user_agent = user_agent
|
||||
self._enabled = enabled
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None:
|
||||
if not self._enabled or not release_group_mbid:
|
||||
return None
|
||||
url = f"{self._base_url}/release-group/{release_group_mbid}/front-500"
|
||||
try:
|
||||
await self._throttle()
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": self._user_agent},
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 404:
|
||||
return None # no cover for this release group — normal, not an error
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
log.warning("coverart_fetch_failed", release_group=release_group_mbid)
|
||||
return None
|
||||
|
||||
data = resp.content
|
||||
if not data or len(data) > _MAX_BYTES:
|
||||
return None
|
||||
content_type = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
||||
if not content_type.startswith("image/"):
|
||||
return None
|
||||
return CoverArt(data=data, content_type=content_type)
|
||||
|
||||
@classmethod
|
||||
async def _throttle(cls) -> None:
|
||||
async with cls._throttle_lock:
|
||||
elapsed = time.monotonic() - cls._last_call_monotonic
|
||||
wait = _MIN_INTERVAL_SECONDS - elapsed
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
cls._last_call_monotonic = time.monotonic()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user