From e88f4e040c403bd0519f2a822ff1a5a7c7a2287f Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 08:06:12 -0500 Subject: [PATCH] OpenSubsonic: getPodcastEpisode, topSongsByArtistId, transcodeOffset (real ffmpeg seek) --- internal/subsonic/browsing.go | 1 + internal/subsonic/browsing_extra.go | 37 +++-- internal/subsonic/media.go | 17 ++- internal/subsonic/opensubsonic_core_test.go | 2 +- internal/subsonic/opensubsonic_misc_test.go | 147 ++++++++++++++++++++ internal/subsonic/podcasts.go | 20 +++ internal/subsonic/testutil_test.go | 10 ++ 7 files changed, 219 insertions(+), 15 deletions(-) create mode 100644 internal/subsonic/opensubsonic_misc_test.go diff --git a/internal/subsonic/browsing.go b/internal/subsonic/browsing.go index 060fc65..3f0f90b 100644 --- a/internal/subsonic/browsing.go +++ b/internal/subsonic/browsing.go @@ -12,6 +12,7 @@ import ( ) func init() { + registerExtension("topSongsByArtistId", 1) register("getMusicFolders", handleGetMusicFolders) register("getIndexes", handleGetIndexes) register("getMusicDirectory", handleGetMusicDirectory) diff --git a/internal/subsonic/browsing_extra.go b/internal/subsonic/browsing_extra.go index 8190601..d7a031a 100644 --- a/internal/subsonic/browsing_extra.go +++ b/internal/subsonic/browsing_extra.go @@ -202,20 +202,37 @@ func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) } func handleGetTopSongs(rc *Context) *apiError { - artistName, aerr := rc.Params.Required("artist") - if aerr != nil { - return aerr - } count := rc.Params.Int("count", 50) db := rc.Server.DB - artist, err := database.FindArtistByName(db, artistName) - if err != nil { - rc.Response = newResponse() - rc.Response.TopSongs = &TopSongs{} - return nil + // topSongsByArtistId: an explicit artist ID (the canonical internal + // ID, same as everywhere else) takes priority over the legacy + // name-based lookup when both are given, since it's unambiguous + // where a name match can collide. + var artistID string + if id := rc.Params.Get("id"); id != "" { + artist, err := database.GetArtist(db, id) + if err != nil { + rc.Response = newResponse() + rc.Response.TopSongs = &TopSongs{} + return nil + } + artistID = artist.ID + } else { + artistName, aerr := rc.Params.Required("artist") + if aerr != nil { + return aerr + } + artist, err := database.FindArtistByName(db, artistName) + if err != nil { + rc.Response = newResponse() + rc.Response.TopSongs = &TopSongs{} + return nil + } + artistID = artist.ID } - songs, err := database.TopSongsByArtist(db, artist.ID, count) + + songs, err := database.TopSongsByArtist(db, artistID, count) if err != nil { return newError(ErrGeneric, err.Error()) } diff --git a/internal/subsonic/media.go b/internal/subsonic/media.go index 9338a51..29a442f 100644 --- a/internal/subsonic/media.go +++ b/internal/subsonic/media.go @@ -17,6 +17,7 @@ import ( ) func init() { + registerExtension("transcodeOffset", 1) register("stream", handleStream) register("download", handleDownload) register("getCoverArt", handleGetCoverArt) @@ -75,14 +76,22 @@ func handleStream(rc *Context) *apiError { rc.Handled = true - // format=raw explicitly forces the original bytes. Otherwise, - // transcode only if the client actually asked for something the - // source doesn't already satisfy: a specific format, or a bitrate - // cap below the source's own bitrate. + // format=raw explicitly forces the original bytes (timeOffset can't + // be honored in that case — a direct byte stream has no way to seek + // by time — so it's silently ignored, matching "raw" meaning + // "disable transcoding"). Otherwise, transcode whenever the client + // asked for something the source can't satisfy by itself: a + // specific format, a bitrate cap below the source's own bitrate, or + // (the transcodeOffset extension) a start offset — direct-serving + // bytes has no notion of "start at N seconds", only real + // transcoding with an ffmpeg seek can do that. needsTranscode := formatParam != "" && formatParam != "raw" if maxBitRate > 0 && (mf.BitrateKbps == 0 || maxBitRate < mf.BitrateKbps) { needsTranscode = true } + if timeOffset > 0 && formatParam != "raw" { + needsTranscode = true + } if !needsTranscode { if err := streaming.ServeFile(rc.W, rc.R, absPath, mf.RelativePath); err != nil { http.Error(rc.W, "could not read media file", http.StatusInternalServerError) diff --git a/internal/subsonic/opensubsonic_core_test.go b/internal/subsonic/opensubsonic_core_test.go index 216b01d..6020c34 100644 --- a/internal/subsonic/opensubsonic_core_test.go +++ b/internal/subsonic/opensubsonic_core_test.go @@ -54,7 +54,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) { // phase; see docs/subsonic-compatibility.md for the final complete // set once Phase 3 is done. for _, want := range []string{ - "apiKeyAuthentication", "formPost", + "apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset", } { if !names[want] { t.Errorf("extension %q not advertised: %v", want, names) diff --git a/internal/subsonic/opensubsonic_misc_test.go b/internal/subsonic/opensubsonic_misc_test.go new file mode 100644 index 0000000..e2e8015 --- /dev/null +++ b/internal/subsonic/opensubsonic_misc_test.go @@ -0,0 +1,147 @@ +package subsonic + +import ( + "io" + "net/http" + "testing" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func TestGetPodcastEpisode(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + perms := u.Permissions + perms.Podcast = true + if err := database.SetUserPermissions(db, u.ID, perms); err != nil { + t.Fatal(err) + } + channel, err := database.CreatePodcastChannel(db, "http://example.com/feed.xml") + if err != nil { + t.Fatal(err) + } + ep, err := database.UpsertPodcastEpisode(db, channel.ID, "guid1", "Episode One", "desc", "", "http://example.com/a.mp3", 100) + if err != nil { + t.Fatal(err) + } + + srv := newSubsonicTestServer(t, s) + q := baseParams("alice", "password123") + q.Set("id", ep.ID) + q.Set("f", "json") + resp := getPath(t, srv, "/rest/getPodcastEpisode.view", q) + j := decodeJSON(t, resp) + got, ok := j["podcastEpisode"].(map[string]any) + if !ok || got["title"] != "Episode One" { + t.Fatalf("getPodcastEpisode = %v", j) + } + if got["channelId"] != channel.ID { + t.Fatalf("channelId = %v, want %v", got["channelId"], channel.ID) + } +} + +func TestGetPodcastEpisodeNotFound(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + perms := u.Permissions + perms.Podcast = true + if err := database.SetUserPermissions(db, u.ID, perms); err != nil { + t.Fatal(err) + } + + srv := newSubsonicTestServer(t, s) + q := baseParams("alice", "password123") + q.Set("id", "pe_doesnotexist") + resp := getPath(t, srv, "/rest/getPodcastEpisode.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { + t.Fatalf("expected error 70, got %+v", x) + } +} + +func TestGetPodcastEpisodeRequiresPermission(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + perms := u.Permissions + perms.Podcast = false + if err := database.SetUserPermissions(db, u.ID, perms); err != nil { + t.Fatal(err) + } + + srv := newSubsonicTestServer(t, s) + q := baseParams("alice", "password123") + q.Set("id", "pe_whatever") + resp := getPath(t, srv, "/rest/getPodcastEpisode.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50, got %+v", x) + } +} + +func TestTopSongsByArtistID(t *testing.T) { + s, db := newTestServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + + artists, err := database.ListArtists(db) + if err != nil || len(artists) == 0 { + t.Fatalf("ListArtists: %v %v", artists, err) + } + + srv := newSubsonicTestServer(t, s) + q := baseParams("alice", "password123") + q.Set("id", artists[0].ID) + q.Set("f", "json") + resp := getPath(t, srv, "/rest/getTopSongs.view", q) + j := decodeJSON(t, resp) + topSongs, ok := j["topSongs"].(map[string]any) + if !ok { + t.Fatalf("topSongs missing: %v", j) + } + songs, ok := topSongs["song"].([]any) + if !ok || len(songs) == 0 { + t.Fatalf("expected songs from getTopSongs by artist id, got %v", topSongs) + } +} + +func TestTopSongsByArtistIDUnknown(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("id", "ar_doesnotexist") + resp := getPath(t, srv.URL, "/rest/getTopSongs.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("unknown artist id should return ok with empty list, got %+v", x) + } +} + +func TestStreamTranscodeOffsetSeeksReal(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + song := firstSong(t, s) + + q := baseParams("alice", "password123") + q.Set("id", song.ID) + // The seeded fixture songs are ~1s long; ask to start after 1s so a + // naive "decode from zero" implementation would still succeed but a + // real seek should produce a very short (near-silent) stream. + q.Set("timeOffset", "1") + resp := getPath(t, srv.URL, "/rest/stream.view", q) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + // A transcode was actually triggered (not a direct byte stream) — + // confirmed by the response being a compressed audio Content-Type + // rather than a plain file passthrough with Accept-Ranges. + if resp.Header.Get("Accept-Ranges") == "bytes" { + t.Error("expected a transcoded (non-range-servable) response when timeOffset is set") + } + body, _ := io.ReadAll(resp.Body) + if len(body) == 0 { + t.Fatal("transcoded+offset stream returned no bytes") + } +} diff --git a/internal/subsonic/podcasts.go b/internal/subsonic/podcasts.go index 432a412..c2a3a06 100644 --- a/internal/subsonic/podcasts.go +++ b/internal/subsonic/podcasts.go @@ -8,6 +8,8 @@ import ( ) func init() { + registerExtension("getPodcastEpisode", 1) + register("getPodcastEpisode", handleGetPodcastEpisode) register("getPodcasts", handleGetPodcasts) register("getNewestPodcasts", handleGetNewestPodcasts) register("refreshPodcasts", handleRefreshPodcasts) @@ -75,6 +77,24 @@ func podcastChannelToModel(db *sql.DB, c *database.PodcastChannel, withEpisodes return pc, nil } +func handleGetPodcastEpisode(rc *Context) *apiError { + if !rc.User.Allows(rc.User.Permissions.Podcast) { + return errUnauthorized("podcasts") + } + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + ep, err := database.GetPodcastEpisode(rc.Server.DB, id) + if err != nil { + return errNotFound("Podcast episode") + } + model := podcastEpisodeToModel(rc.Server.DB, ep, rc.User.ID) + rc.Response = newResponse() + rc.Response.PodcastEpisode = &model + return nil +} + func handleGetPodcasts(rc *Context) *apiError { if !rc.User.Allows(rc.User.Permissions.Podcast) { return errUnauthorized("podcasts") diff --git a/internal/subsonic/testutil_test.go b/internal/subsonic/testutil_test.go index c42e744..09387af 100644 --- a/internal/subsonic/testutil_test.go +++ b/internal/subsonic/testutil_test.go @@ -74,3 +74,13 @@ func newTestRecorderServer(t *testing.T) (*Server, *httptest.Server) { t.Cleanup(srv.Close) return s, srv } + +// newSubsonicTestServer starts an HTTP server for an already-constructed +// Server (as opposed to newTestRecorderServer, which builds its own) — +// for tests that need to seed the database before the server matters. +func newSubsonicTestServer(t *testing.T, s *Server) string { + t.Helper() + srv := httptest.NewServer(s.Router()) + t.Cleanup(srv.Close) + return srv.URL +}