diff --git a/app/api/rest/__init__.py b/app/api/rest/__init__.py new file mode 100644 index 0000000..2868145 --- /dev/null +++ b/app/api/rest/__init__.py @@ -0,0 +1,20 @@ +"""Subsonic-compatible API layer mounted at /rest.""" + +from fastapi import APIRouter + +from app.api.rest.annotation import router as annotation_router +from app.api.rest.browsing import router as browsing_router +from app.api.rest.media import router as media_router +from app.api.rest.playlists import router as playlists_router +from app.api.rest.search import router as search_router +from app.api.rest.system import router as system_router + +subsonic_router = APIRouter(tags=["subsonic"]) +subsonic_router.include_router(system_router) +subsonic_router.include_router(browsing_router) +subsonic_router.include_router(search_router) +subsonic_router.include_router(playlists_router) +subsonic_router.include_router(media_router) +subsonic_router.include_router(annotation_router) + +__all__ = ["subsonic_router"] diff --git a/app/api/rest/annotation.py b/app/api/rest/annotation.py new file mode 100644 index 0000000..21fc85b --- /dev/null +++ b/app/api/rest/annotation.py @@ -0,0 +1,23 @@ +"""Subsonic annotation endpoints: star, rating, scrobble.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/star") +async def star() -> Any: ... + + +@router.get("/unstar") +async def unstar() -> Any: ... + + +@router.get("/setRating") +async def set_rating() -> Any: ... + + +@router.get("/scrobble") +async def scrobble() -> Any: ... diff --git a/app/api/rest/browsing.py b/app/api/rest/browsing.py new file mode 100644 index 0000000..a6be821 --- /dev/null +++ b/app/api/rest/browsing.py @@ -0,0 +1,47 @@ +"""Subsonic browsing endpoints.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/getMusicFolders") +async def get_music_folders() -> Any: ... + + +@router.get("/getIndexes") +async def get_indexes() -> Any: ... + + +@router.get("/getMusicDirectory") +async def get_music_directory() -> Any: ... + + +@router.get("/getArtists") +async def get_artists() -> Any: ... + + +@router.get("/getArtist") +async def get_artist() -> Any: ... + + +@router.get("/getAlbum") +async def get_album() -> Any: ... + + +@router.get("/getAlbumList") +async def get_album_list() -> Any: ... + + +@router.get("/getAlbumList2") +async def get_album_list2() -> Any: ... + + +@router.get("/getSong") +async def get_song() -> Any: ... + + +@router.get("/getGenres") +async def get_genres() -> Any: ... diff --git a/app/api/rest/media.py b/app/api/rest/media.py new file mode 100644 index 0000000..4d5d658 --- /dev/null +++ b/app/api/rest/media.py @@ -0,0 +1,19 @@ +"""Subsonic media endpoints: stream, download, cover art.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/stream") +async def stream() -> Any: ... + + +@router.get("/download") +async def download() -> Any: ... + + +@router.get("/getCoverArt") +async def get_cover_art() -> Any: ... diff --git a/app/api/rest/playlists.py b/app/api/rest/playlists.py new file mode 100644 index 0000000..92072e9 --- /dev/null +++ b/app/api/rest/playlists.py @@ -0,0 +1,27 @@ +"""Subsonic playlist endpoints.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/getPlaylists") +async def get_playlists() -> Any: ... + + +@router.get("/getPlaylist") +async def get_playlist() -> Any: ... + + +@router.get("/createPlaylist") +async def create_playlist() -> Any: ... + + +@router.get("/updatePlaylist") +async def update_playlist() -> Any: ... + + +@router.get("/deletePlaylist") +async def delete_playlist() -> Any: ... diff --git a/app/api/rest/search.py b/app/api/rest/search.py new file mode 100644 index 0000000..5a61ae5 --- /dev/null +++ b/app/api/rest/search.py @@ -0,0 +1,11 @@ +"""Subsonic search endpoints.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/search3") +async def search3() -> Any: ... diff --git a/app/api/rest/system.py b/app/api/rest/system.py new file mode 100644 index 0000000..10a8e38 --- /dev/null +++ b/app/api/rest/system.py @@ -0,0 +1,15 @@ +"""Subsonic system endpoints: ping and license.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/ping") +async def ping() -> Any: ... + + +@router.get("/getLicense") +async def get_license() -> Any: ... diff --git a/app/api/v1/albums.py b/app/api/v1/albums.py new file mode 100644 index 0000000..dba0a69 --- /dev/null +++ b/app/api/v1/albums.py @@ -0,0 +1,24 @@ +"""Album endpoints.""" + +import uuid +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/albums", tags=["albums"]) + + +@router.get("") +async def list_albums() -> Any: ... + + +@router.get("/{album_id}") +async def get_album(album_id: uuid.UUID) -> Any: ... + + +@router.get("/{album_id}/tracks") +async def get_album_tracks(album_id: uuid.UUID) -> Any: ... + + +@router.get("/{album_id}/cover") +async def get_album_cover(album_id: uuid.UUID) -> Any: ... diff --git a/app/api/v1/artists.py b/app/api/v1/artists.py new file mode 100644 index 0000000..ca24a68 --- /dev/null +++ b/app/api/v1/artists.py @@ -0,0 +1,28 @@ +"""Artist endpoints.""" + +import uuid +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/artists", tags=["artists"]) + + +@router.get("") +async def list_artists() -> Any: ... + + +@router.get("/{artist_id}") +async def get_artist(artist_id: uuid.UUID) -> Any: ... + + +@router.get("/{artist_id}/albums") +async def get_artist_albums(artist_id: uuid.UUID) -> Any: ... + + +@router.get("/{artist_id}/tracks") +async def get_artist_tracks(artist_id: uuid.UUID) -> Any: ... + + +@router.get("/{artist_id}/similar") +async def get_similar_artists(artist_id: uuid.UUID) -> Any: ... diff --git a/app/api/v1/downloads.py b/app/api/v1/downloads.py new file mode 100644 index 0000000..03981c2 --- /dev/null +++ b/app/api/v1/downloads.py @@ -0,0 +1,36 @@ +"""Download job endpoints. Heavy work is dispatched to arq workers.""" + +import uuid +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/downloads", tags=["downloads"]) + + +@router.get("") +async def list_downloads() -> Any: ... + + +@router.post("") +async def create_download() -> Any: ... + + +@router.get("/{job_id}") +async def get_download(job_id: uuid.UUID) -> Any: ... + + +@router.delete("/{job_id}") +async def cancel_download(job_id: uuid.UUID) -> Any: ... + + +@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: ... diff --git a/app/api/v1/history.py b/app/api/v1/history.py new file mode 100644 index 0000000..acfba34 --- /dev/null +++ b/app/api/v1/history.py @@ -0,0 +1,15 @@ +"""Playback history endpoints.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/history", tags=["history"]) + + +@router.get("") +async def get_history() -> Any: ... + + +@router.post("") +async def record_history() -> Any: ... diff --git a/app/api/v1/likes.py b/app/api/v1/likes.py new file mode 100644 index 0000000..33126c2 --- /dev/null +++ b/app/api/v1/likes.py @@ -0,0 +1,19 @@ +"""Like endpoints. Likes are an append-only event-log — never updated in place.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/likes", tags=["likes"]) + + +@router.get("") +async def get_likes() -> Any: ... + + +@router.post("") +async def add_like() -> Any: ... + + +@router.get("/state") +async def get_likes_state() -> Any: ... diff --git a/app/api/v1/playlists.py b/app/api/v1/playlists.py new file mode 100644 index 0000000..5bbd07a --- /dev/null +++ b/app/api/v1/playlists.py @@ -0,0 +1,48 @@ +"""Playlist endpoints.""" + +import uuid +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/playlists", tags=["playlists"]) + + +@router.get("") +async def list_playlists() -> Any: ... + + +@router.post("") +async def create_playlist() -> Any: ... + + +@router.get("/{playlist_id}") +async def get_playlist(playlist_id: uuid.UUID) -> Any: ... + + +@router.patch("/{playlist_id}") +async def update_playlist(playlist_id: uuid.UUID) -> Any: ... + + +@router.delete("/{playlist_id}") +async def delete_playlist(playlist_id: uuid.UUID) -> Any: ... + + +@router.get("/{playlist_id}/tracks") +async def get_playlist_tracks(playlist_id: uuid.UUID) -> Any: ... + + +@router.post("/{playlist_id}/tracks") +async def add_playlist_tracks(playlist_id: uuid.UUID) -> Any: ... + + +@router.delete("/{playlist_id}/tracks/{track_id}") +async def remove_playlist_track(playlist_id: uuid.UUID, track_id: uuid.UUID) -> Any: ... + + +@router.put("/{playlist_id}/tracks/reorder") +async def reorder_playlist_tracks(playlist_id: uuid.UUID) -> Any: ... + + +@router.get("/{playlist_id}/cover") +async def get_playlist_cover(playlist_id: uuid.UUID) -> Any: ... diff --git a/app/api/v1/radio.py b/app/api/v1/radio.py new file mode 100644 index 0000000..cd5a591 --- /dev/null +++ b/app/api/v1/radio.py @@ -0,0 +1,15 @@ +"""Radio / continuous-mix endpoints. Degrades gracefully when ML service is down.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/radio", tags=["radio"]) + + +@router.post("") +async def start_radio() -> Any: ... + + +@router.post("/next") +async def next_radio_track() -> Any: ... diff --git a/app/api/v1/search.py b/app/api/v1/search.py new file mode 100644 index 0000000..53e5121 --- /dev/null +++ b/app/api/v1/search.py @@ -0,0 +1,15 @@ +"""Search endpoints: global and library-scoped.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/search", tags=["search"]) + + +@router.get("") +async def search() -> Any: ... + + +@router.get("/library") +async def search_library() -> Any: ... diff --git a/app/api/v1/sources.py b/app/api/v1/sources.py new file mode 100644 index 0000000..bd8a0c9 --- /dev/null +++ b/app/api/v1/sources.py @@ -0,0 +1,19 @@ +"""External source endpoints (yt-dlp etc.).""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/sources", tags=["sources"]) + + +@router.get("") +async def list_sources() -> Any: ... + + +@router.get("/{source}/search") +async def search_source(source: str) -> Any: ... + + +@router.get("/{source}/health") +async def source_health(source: str) -> Any: ... diff --git a/app/api/v1/storage.py b/app/api/v1/storage.py new file mode 100644 index 0000000..643242a --- /dev/null +++ b/app/api/v1/storage.py @@ -0,0 +1,27 @@ +"""Storage analysis and cleanup endpoints.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/storage", tags=["storage"]) + + +@router.get("") +async def get_storage_stats() -> Any: ... + + +@router.get("/duplicates") +async def get_duplicates() -> Any: ... + + +@router.get("/broken") +async def get_broken_files() -> Any: ... + + +@router.get("/missing-metadata") +async def get_missing_metadata() -> Any: ... + + +@router.post("/cleanup") +async def run_cleanup() -> Any: ... diff --git a/app/api/v1/streaming.py b/app/api/v1/streaming.py new file mode 100644 index 0000000..7642730 --- /dev/null +++ b/app/api/v1/streaming.py @@ -0,0 +1,20 @@ +"""Audio streaming endpoints: direct stream and HLS.""" + +import uuid +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/stream", tags=["streaming"]) + + +@router.get("/{track_id}") +async def stream_track(track_id: uuid.UUID) -> Any: ... + + +@router.get("/{track_id}/hls/playlist.m3u8") +async def hls_playlist(track_id: uuid.UUID) -> Any: ... + + +@router.get("/{track_id}/hls/{segment}") +async def hls_segment(track_id: uuid.UUID, segment: str) -> Any: ... diff --git a/app/api/v1/sync.py b/app/api/v1/sync.py new file mode 100644 index 0000000..5c0704f --- /dev/null +++ b/app/api/v1/sync.py @@ -0,0 +1,15 @@ +"""Client sync endpoints (offline-first event log).""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/sync", tags=["sync"]) + + +@router.get("/changes") +async def get_changes() -> Any: ... + + +@router.post("/push") +async def push_changes() -> Any: ... diff --git a/app/api/v1/tracks.py b/app/api/v1/tracks.py new file mode 100644 index 0000000..00fecdc --- /dev/null +++ b/app/api/v1/tracks.py @@ -0,0 +1,48 @@ +"""Track endpoints (library CRUD, similarity, optimization, cover, metadata, streaming).""" + +import uuid +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/tracks", tags=["tracks"]) + + +@router.get("") +async def list_tracks() -> Any: ... + + +@router.get("/{track_id}") +async def get_track(track_id: uuid.UUID) -> Any: ... + + +@router.patch("/{track_id}") +async def update_track(track_id: uuid.UUID) -> Any: ... + + +@router.delete("/{track_id}") +async def delete_track(track_id: uuid.UUID) -> Any: ... + + +@router.get("/{track_id}/similar") +async def get_similar_tracks(track_id: uuid.UUID) -> Any: ... + + +@router.post("/{track_id}/optimize") +async def optimize_track(track_id: uuid.UUID) -> Any: ... + + +@router.get("/{track_id}/cover") +async def get_track_cover(track_id: uuid.UUID) -> Any: ... + + +@router.post("/{track_id}/metadata/enrich") +async def enrich_metadata(track_id: uuid.UUID) -> Any: ... + + +@router.get("/{track_id}/metadata/matches") +async def get_metadata_matches(track_id: uuid.UUID) -> Any: ... + + +@router.put("/{track_id}/metadata") +async def set_metadata(track_id: uuid.UUID) -> Any: ... diff --git a/app/api/v1/upload.py b/app/api/v1/upload.py new file mode 100644 index 0000000..e276702 --- /dev/null +++ b/app/api/v1/upload.py @@ -0,0 +1,11 @@ +"""Local file upload endpoint.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/upload", tags=["upload"]) + + +@router.post("") +async def upload_file() -> Any: ... diff --git a/app/api/v1/user_settings.py b/app/api/v1/user_settings.py new file mode 100644 index 0000000..06d5b00 --- /dev/null +++ b/app/api/v1/user_settings.py @@ -0,0 +1,23 @@ +"""User settings endpoints, including scrobbling configuration.""" + +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("") +async def get_settings() -> Any: ... + + +@router.patch("") +async def update_settings() -> Any: ... + + +@router.get("/scrobbling") +async def get_scrobbling_settings() -> Any: ... + + +@router.put("/scrobbling") +async def set_scrobbling_settings() -> Any: ...