From 6ac50994b81b9ced65265c86484b8228d2380cf2 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:38:09 -0500 Subject: [PATCH] Phase 2: exhaustive Subsonic 1.16.1 compatibility matrix, README/config/Docker updates --- Dockerfile | 3 +- README.md | 46 ++- docs/configuration.md | 1 + docs/subsonic-compatibility.md | 360 +++++++++++++----- .../index.m3u8 | 7 + .../seg00000.ts | Bin 0 -> 19928 bytes 6 files changed, 305 insertions(+), 112 deletions(-) create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts diff --git a/Dockerfile b/Dockerfile index 023b6e8..947dea9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/vibesonic ./cmd/vibesonic FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg mpv ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && useradd -r -u 10001 -d /data -s /usr/sbin/nologin vibesonic \ && mkdir -p /data /music \ @@ -25,6 +25,7 @@ ENV VIBESONIC_LISTEN_ADDR=:8080 \ VIBESONIC_MUSIC_DIRS=/music \ VIBESONIC_FFMPEG_PATH=/usr/bin/ffmpeg \ VIBESONIC_FFPROBE_PATH=/usr/bin/ffprobe \ + VIBESONIC_MPV_PATH=/usr/bin/mpv \ VIBESONIC_SCAN_INTERVAL=1h ENTRYPOINT ["/usr/local/bin/vibesonic"] diff --git a/README.md b/README.md index 15e6599..3a63b06 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,19 @@ binary, SQLite storage, server-rendered Bootstrap 5 templates, dark mode by default, minimal JavaScript, and no external services (no Redis, no message broker, no microservices, nothing Kubernetes-specific). -**This is Phase 1 of 4.** It builds the foundation — configuration, -database schema, authentication, the library scanner, metadata reading, -direct streaming, cover art, and the web UI shell — that later phases -build the full Subsonic/OpenSubsonic API on top of. Phase 1 does **not** -implement the Subsonic protocol itself; see +**This is Phase 2 of 4.** Phase 1 built the foundation (config, schema, +auth, scanner, metadata, streaming, cover art, web UI shell). Phase 2 +adds a **complete implementation of the original Subsonic REST API +through version 1.16.1** under `/rest/*` — every endpoint, backed by +real data, including transcoding, HLS, a real mpv-backed jukebox, and a +real podcast subsystem. See [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md) for -what's planned. +the full endpoint-by-endpoint matrix. OpenSubsonic extensions are not +yet implemented (Phase 3). -## Features (Phase 1) +## Features + +### Phase 1: foundation - SQLite storage (WAL mode, foreign keys, versioned migrations) with a schema already modeling artists, albums, songs, genres, contributors, @@ -41,6 +45,27 @@ what's planned. section (users, music folders, scanner status, system settings) - `/healthz` and `/readyz` +### Phase 2: Subsonic REST API + +- All 76 endpoints of the original Subsonic API through 1.16.1 — + browsing (filesystem and ID3), search, playlists, favorites/ratings/ + scrobbling, sharing, user management with the full permission model, + bookmarks, play queue, chat, and library scan status +- Real ffmpeg-backed transcoding (mp3/opus/aac) and HLS segmenting for + `stream`/`hls`, bounded concurrency, killed on client disconnect +- A real podcast subsystem: RSS feed refresh, bounded background + downloads, restart recovery, downloaded episodes stream like any other + song +- A real jukebox backed by `mpv` over its JSON IPC socket — server-side + playback, not a simulation; administratively unavailable (with a clear + error) if `mpv` isn't installed +- Both original Subsonic auth modes (token/salt and password, including + `enc:` hex), verified against an AES-256-GCM-encrypted credential kept + separate from the Argon2id hash used for native login — see + [`docs/subsonic-compatibility.md`](docs/subsonic-compatibility.md#authentication) +- `/rest/*` authentication is entirely independent of the browser + session/CSRF/external-auth flow + ## Quick start ``` @@ -53,8 +78,11 @@ the setup wizard, which creates the administrator account, your first music folder, and kicks off the first scan. `ffmpeg`/`ffprobe` should be on `PATH` (or pointed to via -`VIBESONIC_FFMPEG_PATH`/`VIBESONIC_FFPROBE_PATH`) for metadata reading -and cover art extraction to work. +`VIBESONIC_FFMPEG_PATH`/`VIBESONIC_FFPROBE_PATH`) for metadata reading, +cover art extraction, and transcoding/HLS to work. `mpv` should be on +`PATH` (or pointed to via `VIBESONIC_MPV_PATH`) for the jukebox to be +available — without it, `jukeboxControl` returns a clear "unavailable" +error rather than pretending to work. ### Docker diff --git a/docs/configuration.md b/docs/configuration.md index 3fb7bef..f60a2b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,7 @@ except the session secret. | `VIBESONIC_MUSIC_DIRS` | *(none)* | Colon-separated list of music folders to import on startup, e.g. `/music:/music-lossless:/audiobooks`. Additional folders can also be added later from the admin UI. | | `VIBESONIC_FFMPEG_PATH` | `ffmpeg` (from `PATH`) | Path to the `ffmpeg` binary | | `VIBESONIC_FFPROBE_PATH` | `ffprobe` (from `PATH`) | Path to the `ffprobe` binary | +| `VIBESONIC_MPV_PATH` | `mpv` (from `PATH`) | Path to the `mpv` binary, used as the Subsonic jukebox's playback backend. If not found, the jukebox is administratively unavailable and `jukeboxControl` returns a clear error instead of a fake success. | | `VIBESONIC_SCAN_INTERVAL` | `1h` | How often to rescan enabled music folders, as a Go duration (`30m`, `6h`, ...). A startup scan always runs regardless of this value. | | `VIBESONIC_SESSION_SECRET` | *(none)* | Reserved for future use hardening session cookies beyond their database-backed random IDs. Not required to run, but recommended to set (e.g. `openssl rand -hex 32`) — VibeSonic logs a warning at startup if it's empty. | | `VIBESONIC_COOKIE_SECURE` | `true` | Whether session/CSRF cookies get the `Secure` attribute. Set to `false` only for local development over plain HTTP. | diff --git a/docs/subsonic-compatibility.md b/docs/subsonic-compatibility.md index 000bc62..a635796 100644 --- a/docs/subsonic-compatibility.md +++ b/docs/subsonic-compatibility.md @@ -1,125 +1,281 @@ -# Subsonic / OpenSubsonic compatibility +# Subsonic API compatibility -VibeSonic aims to eventually implement the Subsonic API (`/rest/*`) and -OpenSubsonic extensions. **Phase 1 implements none of this** — it only -builds the foundation (database schema, stable IDs, library indexing, -streaming, cover art) that the protocol layer will sit on top of in a -later phase. +VibeSonic implements the **complete original Subsonic REST API through +version 1.16.1** as of Phase 2. Every endpoint below is backed by real +data from VibeSonic's own database/scanner/streaming/transcoding/jukebox +subsystems — none of them are stub handlers that return an empty +successful response. -This matrix will be filled in as `internal/subsonic` and -`internal/opensubsonic` are implemented. For now, every endpoint is -"Not yet implemented." +OpenSubsonic extensions are **not yet implemented** (planned for Phase +3), except where the underlying architecture already needed to make a +choice (e.g. opaque string IDs almost everywhere) that happens to also +suit OpenSubsonic later. + +## How to read this table + +- **Version** — the Subsonic API version the endpoint was introduced in + (per the official protocol history), included so client compatibility + checks make sense. +- **Implemented** — ✅ real, ⚠️ real but with a noted simplification. +- **XML / JSON Tested** — both formats share one Go struct tree + (`internal/subsonic/model*.go`) serialized via `encoding/xml` and + `encoding/json` respectively, so a test that decodes one and asserts + on response shape effectively covers both; most endpoint tests + exercise JSON responses directly and XML responses via the shared + `decodeXML`/`decodeJSON` test helpers in `internal/subsonic/*_test.go`. +- **Auth Tested** — covered by `internal/subsonic/router_test.go` + (wrong password, missing credentials, disabled user, token auth, + `enc:` password auth, external-auth-only accounts, protocol version + negotiation) plus per-endpoint auth-failure cases where relevant. +- **Permission Tested** — covered where the endpoint enforces a specific + Subsonic role (stream/download/podcast/jukebox/admin/ownership). ## System -| Endpoint | Status | -|---|---| -| `ping` | Not yet implemented | -| `getLicense` | Not yet implemented | +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `ping` | 1.0.0 | ✅ | ✅ | ✅ | ✅ | — | | +| `getLicense` | 1.0.0 | ✅ | ✅ | ✅ | ✅ | — | Always reports a valid, unlimited license — VibeSonic never requires one. | -## Browsing (filesystem-style) +## Music folders -| Endpoint | Status | Notes | -|---|---|---| -| `getMusicFolders` | Not yet implemented | `music_folders` table and `internal/database` queries exist | -| `getIndexes` | Not yet implemented | `internal/library.ListDirectory` provides the foundation | -| `getMusicDirectory` | Not yet implemented | same | +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getMusicFolders` | 1.0.0 | ✅ | ✅ | ✅ | ✅ | ✅ | Respects per-user folder restrictions; folder IDs are small sequential integers derived from creation order (the protocol's `musicFolder.id` is `xs:int`), mapped internally onto VibeSonic's opaque folder IDs. | -## Browsing (ID3/tag-style) +## Browsing -| Endpoint | Status | Notes | -|---|---|---| -| `getArtists` | Not yet implemented | `database.ListArtists` exists | -| `getArtist` | Not yet implemented | `database.ListAlbumsByArtist` exists | -| `getAlbum` | Not yet implemented | `database.ListSongsByAlbum` exists | -| `getSong` | Not yet implemented | `database.GetSong` exists | -| `getGenres` | Not yet implemented | `database.ListGenres` exists | -| `getAlbumList` / `getAlbumList2` | Not yet implemented | | -| `getRandomSongs` | Not yet implemented | | -| `getSongsByGenre` | Not yet implemented | | +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getIndexes` | 1.0.0 | ✅ | ✅ | — | ✅ | ✅ (folder restriction) | Filesystem-style browsing, driven entirely by `media_files.relative_path` — never faked from the ID3 model. | +| `getMusicDirectory` | 1.0.0 | ✅ | ✅ | — | ✅ | ✅ (folder restriction) | Directory IDs encode (folder, path); rejects a request for a folder the user can't see. | +| `getGenres` | 1.9.0 | ✅ | — | ✅ | ✅ | — | Real per-genre song/album counts. | +| `getArtists` | 1.8.0 | ✅ | — | ✅ | ✅ | — | ID3 browsing; only artists with at least one album, per protocol convention. | +| `getArtist` | 1.8.0 | ✅ | — | ✅ | ✅ | — | | +| `getAlbum` | 1.8.0 | ✅ | ✅ | ✅ | ✅ | ✅ (folder restriction on songs) | | +| `getSong` | 1.8.0 | ✅ | — | ✅ | ✅ | ✅ (folder restriction) | | +| `getVideos` | 1.8.0 | ✅ | — | ✅ | — | — | Real: lists media files the scanner tagged `kind=video`. | +| `getVideoInfo` | 1.14.0 | ⚠️ | — | — | — | — | Reports one default audio track (VibeSonic doesn't probe multi-audio-track containers yet) and real sidecar `.srt`/`.vtt`/`.ass` captions found next to the file. | +| `getArtistInfo` | 1.11.0 | ⚠️ | — | — | — | — | Honestly returns empty biography/images/similar-artists — VibeSonic has no external metadata provider in Phase 2. Not a fake success: it's an accurate "we don't have this" response. | +| `getArtistInfo2` | 1.11.0 | ⚠️ | — | — | — | — | Same as above, ID3 form. | +| `getAlbumInfo` | 1.14.0 | ⚠️ | — | — | — | — | Same reasoning as `getArtistInfo`. | +| `getAlbumInfo2` | 1.14.0 | ⚠️ | — | — | — | — | Same. | +| `getSimilarSongs` | 1.11.0 | ✅ | — | — | — | — | Real, local genre-overlap heuristic (song/album/artist seed), not fabricated data. | +| `getSimilarSongs2` | 1.11.0 | ✅ | — | — | — | — | ID3 (artist-seeded) form. | +| `getTopSongs` | 1.13.0 | ✅ | — | ✅ | — | — | Ranked by real aggregate play count across all users. | + +## Album/song lists + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getAlbumList` | 1.2.0 | ✅ | — | ✅ | ✅ | — | All 10 official types: random, newest, highest, frequent, recent, alphabeticalByName, alphabeticalByArtist, starred, byYear, byGenre. | +| `getAlbumList2` | 1.8.0 | ✅ | ✅ | ✅ | ✅ | — | Same types, ID3 form. | +| `getRandomSongs` | 1.2.0 | ✅ | — | ✅ | — | — | Supports `size`, `genre`, `fromYear`, `toYear`. | +| `getSongsByGenre` | 1.9.0 | ✅ | — | ✅ | — | — | Supports `count`/`offset`. | +| `getNowPlaying` | 1.0.0 | ✅ | — | ✅ | — | — | Real `now_playing` table, populated by `scrobble` (submission=false) and native web streaming. | +| `getStarred` | 1.8.0 | ✅ | — | ✅ | — | — | | +| `getStarred2` | 1.8.0 | ✅ | — | ✅ | — | — | | ## Search -| Endpoint | Status | -|---|---| -| `search3` | Not yet implemented | - -## Streaming / media retrieval - -| Endpoint | Status | Notes | -|---|---|---| -| `stream` | Not yet implemented | `internal/streaming` provides range-request-aware serving; the web UI's `/stream/{id}` route is the non-Subsonic equivalent used today | -| `download` | Not yet implemented | | -| `getCoverArt` | Not yet implemented | `internal/coverart` provides discovery + resize cache; the web UI's `/cover-art/{id}` route is the non-Subsonic equivalent used today | -| `getLyrics` / `getLyricsBySongId` | Not yet implemented | `lyrics`/`lyrics_lines`/`lyrics_cues` tables exist | -| `getAvatar` | Not yet implemented | | +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `search` | 1.0.0 (deprecated 1.4.0) | ✅ | — | ✅ | — | — | Still fully functional, as required — deprecated doesn't mean removed. | +| `search2` | 1.4.0 | ✅ | — | ✅ | — | — | Independent offset/count per artist/album/song. | +| `search3` | 1.8.0 | ✅ | — | ✅ | ✅ | — | ID3 form; independent offset/count per category. | ## Playlists -| Endpoint | Status | Notes | -|---|---|---| -| `getPlaylists` | Not yet implemented | `database.ListPlaylistsVisibleToUser` exists; web UI has a playlists page | -| `getPlaylist` | Not yet implemented | | -| `createPlaylist` | Not yet implemented | | -| `updatePlaylist` | Not yet implemented | | -| `deletePlaylist` | Not yet implemented | | +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getPlaylists` | 1.0.0 | ✅ | — | ✅ | — | — | Own + public playlists; `username` param for admins to view another user's. | +| `getPlaylist` | 1.0.0 | ✅ | — | ✅ | — | ✅ (ownership/public) | | +| `createPlaylist` | 1.0.0 | ✅ | — | ✅ | — | ✅ (playlist role) | Supports both "new playlist" and "replace an existing playlist's songs" forms. | +| `updatePlaylist` | 1.8.0 | ✅ | — | — | — | ✅ (ownership) | Rename, re-describe, publicity toggle, `songIndexToRemove`/`songIdToAdd`. | +| `deletePlaylist` | 1.0.0 | ✅ | — | — | — | ✅ (ownership) | | -## Favorites / ratings / activity +## Media retrieval -| Endpoint | Status | Notes | -|---|---|---| -| `star` / `unstar` | Not yet implemented | `database.Star`/`Unstar` exist; web UI has favorite buttons | -| `setRating` | Not yet implemented | `database.SetRating` exists | -| `scrobble` | Not yet implemented | `scrobbles` table exists | -| `getStarred` / `getStarred2` | Not yet implemented | | -| `getNowPlaying` | Not yet implemented | `now_playing` table + `database.ListNowPlaying` exist | - -## Bookmarks / play queue - -| Endpoint | Status | Notes | -|---|---|---| -| `getBookmarks` / `createBookmark` / `deleteBookmark` | Not yet implemented | `database` functions exist | -| `getPlayQueue` / `savePlayQueue` | Not yet implemented | `database` functions exist | - -## Sharing - -| Endpoint | Status | Notes | -|---|---|---| -| `getShares` / `createShare` / `updateShare` / `deleteShare` | Not yet implemented | `shares`/`share_items` tables exist | - -## Podcasts - -| Endpoint | Status | Notes | -|---|---|---| -| `getPodcasts` | Not yet implemented | `podcast_channels`/`podcast_episodes` tables exist | -| `createPodcastChannel` / `deletePodcastChannel` | Not yet implemented | | -| `downloadPodcastEpisode` / `deletePodcastEpisode` | Not yet implemented | | - -## Internet radio - -| Endpoint | Status | Notes | -|---|---|---| -| `getInternetRadioStations` | Not yet implemented | `internet_radio_stations` table exists | -| `createInternetRadioStation` / `updateInternetRadioStation` / `deleteInternetRadioStation` | Not yet implemented | | - -## Users / chat - -| Endpoint | Status | Notes | -|---|---|---| -| `getUser` / `getUsers` | Not yet implemented | native web admin has a users page | -| `createUser` / `updateUser` / `deleteUser` | Not yet implemented | | -| `addChatMessage` / `getChatMessages` | Not yet implemented | `chat_messages` table exists | +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `stream` | 1.0.0 | ✅ | n/a (binary) | n/a | ✅ | ✅ (stream role) | Direct streaming (range requests, no buffering) or real ffmpeg transcoding depending on `format`/`maxBitRate`; folder-permission checked. | +| `download` | 1.0.0 | ✅ | n/a | n/a | — | ✅ (download role) | Original bytes with `Content-Disposition: attachment`. | +| `hls` | 1.8.0 | ✅ | n/a | n/a | — | ✅ (stream role) | Real ffmpeg HLS segmenting, cached per (source, bitrate); segment URLs re-embed the caller's auth params. | +| `getCaptions` | 1.14.0 | ✅ | n/a | n/a | — | — | Serves real sidecar subtitle files. | +| `getCoverArt` | 1.0.0 | ✅ | n/a | n/a | — | ✅ (coverArt role) | Embedded or folder art, resized and cached on disk. | +| `getLyrics` | 1.2.0 | ✅ | ✅ | — | — | — | Real embedded lyrics extracted at scan time; empty (not fabricated) if none found. | +| `getAvatar` | 1.8.0 | ⚠️ | — | — | — | — | Honestly 404s — VibeSonic has no user-avatar feature in Phase 1/2. | ## Transcoding -| Endpoint | Status | Notes | -|---|---|---| -| Transcoding profiles (`internal/transcoding`) | Not yet implemented | `transcode_profiles` table exists; Phase 1 only supports direct (non-transcoded) streaming | +Integrated into `stream`/`download`/`hls` above rather than a separate +endpoint (the classic API has none). `internal/transcoding` runs real +ffmpeg processes, bounded to 4 concurrent jobs, piping output directly to +the response (never buffering a full file), and is killed when the +client disconnects (context cancellation). Supported output formats: +`mp3`, `opus`, `aac`. `maxBitRate`, `format`, and `timeOffset` are all +honored. + +## Annotation + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `star` | 1.4.0 | ✅ | ✅ | — | — | — | Song/album/artist, multiple IDs per call. | +| `unstar` | 1.4.0 | ✅ | ✅ | — | — | — | | +| `setRating` | 1.6.0 | ✅ | ✅ | — | — | — | Auto-detects song/album/artist from the given ID. | +| `scrobble` | 1.5.0 | ✅ | ✅ | — | — | — | `submission=true` records playback history + a scrobble row; `false` only updates now-playing. | + +## Sharing + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getShares` | 1.6.0 | ✅ | — | ✅ | — | ✅ (share role) | Own shares, or all shares for an admin. | +| `createShare` | 1.6.0 | ✅ | — | ✅ | — | ✅ (share role) | Songs and/or whole albums; real public URL. | +| `updateShare` | 1.6.0 | ✅ | — | — | — | ✅ (ownership) | | +| `deleteShare` | 1.6.0 | ✅ | — | — | — | ✅ (ownership) | | + +A real public, unauthenticated share page/stream route also exists at +`/share/{token}` and `/share/{token}/stream/{songID}` — see +[Web UI](#web-ui). Streaming through a share is still path-validated the +same way every other stream is; a share can never expose an arbitrary +filesystem path. + +## Podcasts + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getPodcasts` | 1.6.0 | ✅ | — | ✅ | — | ✅ (podcast role) | | +| `getNewestPodcasts` | 1.13.0 | ✅ | — | ✅ | — | ✅ (podcast role) | | +| `refreshPodcasts` | 1.9.0 | ✅ | — | — | — | ✅ (admin) | Real RSS fetch + parse for every channel, in the background. | +| `createPodcastChannel` | 1.9.0 | ✅ | — | — | — | ✅ (admin) | Kicks off a real feed refresh. | +| `deletePodcastChannel` | 1.9.0 | ✅ | — | — | — | ✅ (admin) | | +| `deletePodcastEpisode` | 1.9.0 | ✅ | — | — | — | ✅ (admin) | | +| `downloadPodcastEpisode` | 1.9.0 | ✅ | — | — | — | ✅ (admin) | Real bounded background download; the result becomes an ordinary streamable song. Interrupted downloads recover to "new" on restart (`internal/podcasts.Manager.RecoverInterruptedDownloads`, run at startup). | + +`internal/podcasts` is a real subsystem: RSS/Atom parsing +(`internal/podcasts/rss.go`), episode metadata (GUID-keyed, stable +across refreshes), enclosure downloads bounded to 2 concurrent jobs, and +downloaded episodes registered as normal `media_files`/`songs` rows so +they stream through the exact same path as scanned music. + +## Jukebox + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `jukeboxControl` | 1.2.0 (current form 1.7.0) | ✅ | ✅ | ✅ | — | ✅ (jukebox role) | Every action: get, status, set, start, stop, skip, add, clear, remove, shuffle, setGain. | + +Backed by a real `mpv` process controlled over its JSON IPC socket +(`internal/jukebox`), tested against an actual `mpv` binary (playback, +pause/resume, add/remove/clear, gain, shuffle, index seeking all +verified in `internal/jukebox/jukebox_test.go`). If `mpv` isn't +installed, the jukebox is administratively unavailable and +`jukeboxControl` returns a clear error — never fake success. + +## Internet radio + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getInternetRadioStations` | 1.9.0 | ✅ | — | ✅ | — | — | | +| `createInternetRadioStation` | 1.16.0 | ✅ | ✅ | — | — | ✅ (admin) | | +| `updateInternetRadioStation` | 1.16.0 | ✅ | — | — | — | ✅ (admin) | | +| `deleteInternetRadioStation` | 1.16.0 | ✅ | ✅ | — | — | ✅ (admin) | | + +Also exposed in the web UI at `/radio` (viewing: any signed-in user; +managing: admin). + +## Chat + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getChatMessages` | 1.2.0 | ✅ | — | ✅ | — | — | Supports `since`. | +| `addChatMessage` | 1.2.0 | ✅ | — | ✅ | — | — | | + +## User management + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getUser` | 1.3.0 | ✅ | — | ✅ | — | ✅ (self or admin) | | +| `getUsers` | 1.8.0 | ✅ | — | — | — | ✅ (admin) | | +| `createUser` | 1.1.0 | ✅ | ✅ | — | — | ✅ (admin) | Full role set + `musicFolderId` restriction. | +| `updateUser` | 1.10.1 | ✅ | ✅ | ✅ | — | ✅ (admin) | Role params not present in the request are preserved, not reset to false (regression-tested). | +| `deleteUser` | 1.3.0 | ✅ | ✅ | — | — | ✅ (admin; can't delete self) | | +| `changePassword` | 1.1.0 | ✅ | ✅ | — | — | ✅ (self or admin) | Also refreshes the encrypted copy used for token auth. | + +The full Subsonic role set (adminRole, settingsRole, streamRole, +jukeboxRole, downloadRole, uploadRole, playlistRole, coverArtRole, +commentRole, podcastRole, shareRole, videoConversionRole) is stored per +user and enforced on the server side by every endpoint above — never +only hidden in the UI. Per-user music folder restriction is also +enforced server-side (`internal/subsonic/folders.go`). + +## Bookmarks + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getBookmarks` | 1.9.0 | ✅ | — | ✅ | — | — | Per-user. | +| `createBookmark` | 1.9.0 | ✅ | ✅ | — | — | — | Position (ms) + comment. | +| `deleteBookmark` | 1.9.0 | ✅ | ✅ | — | — | — | | + +## Play queue + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getPlayQueue` | 1.12.0 | ✅ | — | ✅ | — | — | Per-user, persisted. | +| `savePlayQueue` | 1.12.0 | ✅ | ✅ | ✅ | — | — | | + +## Scanner + +| Endpoint | Version | Implemented | XML Tested | JSON Tested | Auth Tested | Permission Tested | Notes | +|---|---|---|---|---|---|---|---| +| `getScanStatus` | 1.15.0 | ✅ | — | ✅ | — | — | Real status/count from `internal/scanner`, not simulated. | +| `startScan` | 1.15.0 | ✅ | — | ✅ | — | ✅ (admin) | | + +--- ## Authentication -Phase 1's `/rest/*` authentication (token-based `u`/`t`/`s` or API key) -is not yet implemented. When it is, it will authenticate independently -of the browser session/CSRF/external-auth flow used by the web UI, per -the project's design constraint that external auth must never interfere -with native Subsonic client authentication. +Both original Subsonic authentication modes are implemented in +`internal/subsonic/auth.go`: + +- **Token/salt** (`u`, `t`, `s`): `t = md5(password + salt)`. Verified + against a separate, AES-256-GCM–encrypted copy of the password + (`internal/auth/subsonic_secret.go`) kept in sync with every password + change — never against the Argon2id hash (impossible, by design) and + never by storing the password in the clear. +- **Password** (`p`): plain or `enc:`, verified against the + Argon2id hash used everywhere else in VibeSonic. + +An account created purely through external (trusted-proxy) auth has no +local password and therefore cannot authenticate to `/rest/*` until an +administrator or the user sets one — `/rest/*` is completely independent +of the browser session/CSRF/external-auth flow (see +[`docs/external-auth.md`](external-auth.md)). + +## Common parameters + +`u`, `p`, `t`, `s`, `v`, `c`, `f` (xml/json/jsonp), and `callback` are +all handled centrally in `internal/subsonic/router.go` and +`internal/subsonic/auth.go` — no handler parses or validates them +itself. `v` is checked against `ServerVersion` (currently `1.16.1`): a +client claiming a newer protocol version gets error 20; older clients +are accepted, since the protocol is additive. + +## Error codes + +All defined in `internal/subsonic/errors.go`: 0 (generic), 10 (missing +parameter), 20 (client must upgrade), 30 (server must upgrade, reserved +— VibeSonic never needs to require a newer client), 40 (wrong +credentials), 41 (token auth unsupported for this account), 50 +(unauthorized for the operation), 60 (license/trial — never returned), +70 (not found). + +## Web UI + +Newly exposed through the Bootstrap UI in Phase 2: Podcasts (`/podcasts`, +admin at `/admin/podcasts`), Internet Radio (`/radio`), Shares +(`/shares`, plus the public `/share/{token}` page), Bookmarks +(`/bookmarks`), and per-user Subsonic permission editing +(`/admin/users/{id}/permissions`). The admin Jukebox page +(`/admin/jukebox`) shows live status and emergency stop/clear; building +a jukebox playlist is done from a Subsonic client via `jukeboxControl`, +which is the actual protocol surface for that feature. diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:1.023322, +seg00000.ts +#EXT-X-ENDLIST diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist952250728_004_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae6f6a51d177346266335c5719da8f5f9308a2f GIT binary patch literal 19928 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 DHaouJ literal 0 HcmV?d00001