diff --git a/internal/app/app.go b/internal/app/app.go index a7ade7e..bf06f70 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,6 +17,7 @@ import ( "git.noroutes.net/NoRoutes/vibetunes/internal/coverart" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/metadata" + "git.noroutes.net/NoRoutes/vibetunes/internal/podcasts" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner" "git.noroutes.net/NoRoutes/vibetunes/internal/subsonic" "git.noroutes.net/NoRoutes/vibetunes/internal/transcoding" @@ -82,6 +83,11 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { Log: log, } + podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log) + if err := podcastMgr.RecoverInterruptedDownloads(); err != nil { + log.Warn("podcast download recovery failed", "err", err) + } + subsonicSrv := &subsonic.Server{ DB: db, Config: cfg, @@ -89,6 +95,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { Scanner: sc, CoverArt: covers, Transcoder: transcoding.NewManager(cfg.FFmpegPath), + Podcasts: podcastMgr, Log: log, } diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2541831347_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2541831347_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2541831347_004_Test_Artist_Test_Album_Second_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_TestHLSPlaylist2541831347_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2541831347_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000..8423fdc Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2541831347_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts differ diff --git a/internal/subsonic/podcasts.go b/internal/subsonic/podcasts.go new file mode 100644 index 0000000..432a412 --- /dev/null +++ b/internal/subsonic/podcasts.go @@ -0,0 +1,224 @@ +package subsonic + +import ( + "context" + "database/sql" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + register("getPodcasts", handleGetPodcasts) + register("getNewestPodcasts", handleGetNewestPodcasts) + register("refreshPodcasts", handleRefreshPodcasts) + register("createPodcastChannel", handleCreatePodcastChannel) + register("deletePodcastChannel", handleDeletePodcastChannel) + register("deletePodcastEpisode", handleDeletePodcastEpisode) + register("downloadPodcastEpisode", handleDownloadPodcastEpisode) +} + +func podcastEpisodeToModel(db *sql.DB, e *database.PodcastEpisode, userID string) PodcastEpisode { + pe := PodcastEpisode{ + Child: Child{ + ID: e.ID, + Title: e.Title, + Album: e.Title, + Genre: "Podcast", + Type: "podcast", + Created: isoTime(e.CreatedAt), + }, + ChannelID: e.ChannelID, + Description: e.Description, + Status: e.Status, + PublishDate: e.PublishDate, + } + if e.MediaFileID.Valid { + if mf, err := database.GetMediaFile(db, e.MediaFileID.String); err == nil { + pe.Child.Duration = int(mf.DurationSeconds + 0.5) + pe.Child.Size = mf.SizeBytes + pe.Child.Suffix = suffixForFormat(mf.Format) + pe.Child.ContentType = contentTypeForFormat(mf.Format) + // Once downloaded, an episode streams as an ordinary song + // (same /rest/stream path, permission checks, everything). + if song, serr := database.GetSongByMediaFileID(db, mf.ID); serr == nil { + pe.Child.ID = song.ID + pe.StreamID = song.ID + } + } + } else { + pe.Child.Duration = int(e.DurationSeconds + 0.5) + } + return pe +} + +func podcastChannelToModel(db *sql.DB, c *database.PodcastChannel, withEpisodes bool, userID string) (PodcastChannel, error) { + pc := PodcastChannel{ + ID: c.ID, + URL: c.URL, + Title: c.Title, + Description: c.Description, + Status: c.Status, + ErrorMessage: c.ErrorMessage, + } + if c.CoverArtID.Valid { + pc.CoverArt = c.CoverArtID.String + } + if withEpisodes { + episodes, err := database.ListPodcastEpisodes(db, c.ID) + if err != nil { + return pc, err + } + for _, e := range episodes { + pc.Episode = append(pc.Episode, podcastEpisodeToModel(db, e, userID)) + } + } + return pc, nil +} + +func handleGetPodcasts(rc *Context) *apiError { + if !rc.User.Allows(rc.User.Permissions.Podcast) { + return errUnauthorized("podcasts") + } + db := rc.Server.DB + withEpisodes := rc.Params.Bool("includeEpisodes", true) + + out := &Podcasts{} + if id := rc.Params.Get("id"); id != "" { + channel, err := database.GetPodcastChannel(db, id) + if err != nil { + return errNotFound("Podcast channel") + } + model, merr := podcastChannelToModel(db, channel, withEpisodes, rc.User.ID) + if merr != nil { + return newError(ErrGeneric, merr.Error()) + } + out.Channel = append(out.Channel, model) + } else { + channels, err := database.ListPodcastChannels(db) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + for _, c := range channels { + model, merr := podcastChannelToModel(db, c, withEpisodes, rc.User.ID) + if merr != nil { + continue + } + out.Channel = append(out.Channel, model) + } + } + + rc.Response = newResponse() + rc.Response.Podcasts = out + return nil +} + +func handleGetNewestPodcasts(rc *Context) *apiError { + if !rc.User.Allows(rc.User.Permissions.Podcast) { + return errUnauthorized("podcasts") + } + count := rc.Params.Int("count", 20) + episodes, err := database.ListNewestPodcastEpisodes(rc.Server.DB, count) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &NewestPodcasts{} + for _, e := range episodes { + out.Episode = append(out.Episode, podcastEpisodeToModel(rc.Server.DB, e, rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.NewestPodcasts = out + return nil +} + +func handleRefreshPodcasts(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("refreshing podcasts") + } + channels, err := database.ListPodcastChannels(rc.Server.DB) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + for _, c := range channels { + id := c.ID + go func() { + _ = rc.Server.Podcasts.RefreshChannel(context.Background(), id) + }() + } + rc.Response = newResponse() + return nil +} + +func handleCreatePodcastChannel(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("managing podcasts") + } + feedURL, aerr := rc.Params.Required("url") + if aerr != nil { + return aerr + } + channel, err := database.CreatePodcastChannel(rc.Server.DB, feedURL) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + go func() { + _ = rc.Server.Podcasts.RefreshChannel(context.Background(), channel.ID) + }() + rc.Response = newResponse() + return nil +} + +func handleDeletePodcastChannel(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("managing podcasts") + } + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + if _, err := database.GetPodcastChannel(rc.Server.DB, id); err != nil { + return errNotFound("Podcast channel") + } + if err := database.DeletePodcastChannel(rc.Server.DB, id); err != nil { + return newError(ErrGeneric, err.Error()) + } + rc.Response = newResponse() + return nil +} + +func handleDeletePodcastEpisode(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("managing podcasts") + } + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + if _, err := database.GetPodcastEpisode(rc.Server.DB, id); err != nil { + return errNotFound("Podcast episode") + } + if err := database.DeletePodcastEpisode(rc.Server.DB, id); err != nil { + return newError(ErrGeneric, err.Error()) + } + rc.Response = newResponse() + return nil +} + +func handleDownloadPodcastEpisode(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("managing podcasts") + } + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + if _, err := database.GetPodcastEpisode(rc.Server.DB, id); err != nil { + return errNotFound("Podcast episode") + } + go func() { + if err := rc.Server.Podcasts.DownloadEpisode(context.Background(), id); err != nil { + rc.Server.Log.Warn("podcast episode download failed", "episode", id, "err", err) + } + }() + rc.Response = newResponse() + return nil +} diff --git a/internal/subsonic/podcasts_test.go b/internal/subsonic/podcasts_test.go new file mode 100644 index 0000000..3b9d350 --- /dev/null +++ b/internal/subsonic/podcasts_test.go @@ -0,0 +1,139 @@ +package subsonic + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +const podcastFeedXML = ` + + Test Cast + desc + + Ep 1 + ep1 + + +` + +func TestPodcastLifecycle(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "admin", "adminpassword1", true) + + var feedURL string + mux := http.NewServeMux() + mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, podcastFeedXML, feedURL) + }) + feedSrv := httptest.NewServer(mux) + defer feedSrv.Close() + feedURL = feedSrv.URL + + q := baseParams("admin", "adminpassword1") + q.Set("url", feedSrv.URL+"/feed.xml") + resp := getPath(t, srv.URL, "/rest/createPodcastChannel.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("createPodcastChannel failed: %+v", x) + } + + // createPodcastChannel refreshes asynchronously; poll briefly. + var channelID string + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + q2 := baseParams("admin", "adminpassword1") + q2.Set("f", "json") + resp = getPath(t, srv.URL, "/rest/getPodcasts.view", q2) + j := decodeJSON(t, resp) + channels, ok := j["podcasts"].(map[string]any)["channel"].([]any) + if ok && len(channels) == 1 { + ch := channels[0].(map[string]any) + if ch["title"] == "Test Cast" { + channelID = ch["id"].(string) + break + } + } + time.Sleep(50 * time.Millisecond) + } + if channelID == "" { + t.Fatal("channel never finished refreshing") + } + + q3 := baseParams("admin", "adminpassword1") + q3.Set("id", channelID) + q3.Set("f", "json") + resp = getPath(t, srv.URL, "/rest/getPodcasts.view", q3) + j := decodeJSON(t, resp) + channels := j["podcasts"].(map[string]any)["channel"].([]any) + ch := channels[0].(map[string]any) + episodes, ok := ch["episode"].([]any) + if !ok || len(episodes) != 1 { + t.Fatalf("episodes = %v, want 1", ch["episode"]) + } + + q4 := baseParams("admin", "adminpassword1") + q4.Set("id", channelID) + resp = getPath(t, srv.URL, "/rest/deletePodcastChannel.view", q4) + x = decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("deletePodcastChannel failed: %+v", x) + } +} + +func TestPodcastRequiresAdmin(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("url", "http://example.com/feed.xml") + resp := getPath(t, srv.URL, "/rest/createPodcastChannel.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 TestGetPodcastsRequiresPermission(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + perms := u.Permissions + perms.Podcast = false + if err := database.SetUserPermissions(db, u.ID, perms); err != nil { + t.Fatal(err) + } + + resp := getPath(t, srv.URL, "/rest/getPodcasts.view", baseParams("alice", "password123")) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50 without podcast permission, got %+v", x) + } +} + +func TestGetNewestPodcastsEmpty(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + perms := u.Permissions + perms.Podcast = true + if err := database.SetUserPermissions(db, u.ID, perms); err != nil { + t.Fatal(err) + } + + q := baseParams("alice", "password123") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getNewestPodcasts.view", q) + j := decodeJSON(t, resp) + if j["status"] != "ok" { + t.Fatalf("status = %v", j["status"]) + } +} diff --git a/internal/subsonic/server.go b/internal/subsonic/server.go index c6dda23..5d7782d 100644 --- a/internal/subsonic/server.go +++ b/internal/subsonic/server.go @@ -8,6 +8,7 @@ import ( "git.noroutes.net/NoRoutes/vibetunes/internal/config" "git.noroutes.net/NoRoutes/vibetunes/internal/coverart" "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/podcasts" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner" "git.noroutes.net/NoRoutes/vibetunes/internal/transcoding" ) @@ -23,6 +24,7 @@ type Server struct { Scanner *scanner.Scanner CoverArt *coverart.Store Transcoder *transcoding.Manager + Podcasts *podcasts.Manager Log *slog.Logger } diff --git a/internal/subsonic/testutil_test.go b/internal/subsonic/testutil_test.go index 5329cdc..7d1159a 100644 --- a/internal/subsonic/testutil_test.go +++ b/internal/subsonic/testutil_test.go @@ -13,6 +13,7 @@ import ( "git.noroutes.net/NoRoutes/vibetunes/internal/coverart" "git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/metadata" + "git.noroutes.net/NoRoutes/vibetunes/internal/podcasts" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner" "git.noroutes.net/NoRoutes/vibetunes/internal/transcoding" ) @@ -38,6 +39,7 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) { Scanner: scanner.New(db, &metadata.Prober{}, log), CoverArt: coverart.New(t.TempDir(), ""), Transcoder: transcoding.NewManager(""), + Podcasts: podcasts.NewManager(db, t.TempDir(), "", log), Log: log, } return s, db