diff --git a/db/migrations/20260305051806_add_plugin_playlist_fields.go b/db/migrations/20260305051806_add_plugin_playlist_fields.go index 9429e19b3..cadc7ec8e 100644 --- a/db/migrations/20260305051806_add_plugin_playlist_fields.go +++ b/db/migrations/20260305051806_add_plugin_playlist_fields.go @@ -12,27 +12,21 @@ func init() { } func upAddPluginPlaylistFields(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN plugin_id VARCHAR(255) NOT NULL DEFAULT '';`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN plugin_playlist_id VARCHAR(255) NOT NULL DEFAULT '';`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_playlist_plugin ON playlist(plugin_id, plugin_playlist_id, owner_id) WHERE plugin_id != '';`) + _, err := tx.ExecContext(ctx, ` +ALTER TABLE playlist ADD COLUMN plugin_id VARCHAR(255) NOT NULL DEFAULT ''; +ALTER TABLE playlist ADD COLUMN plugin_playlist_id VARCHAR(255) NOT NULL DEFAULT ''; +ALTER TABLE playlist ADD COLUMN valid_until DATETIME DEFAULT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_playlist_plugin ON playlist(plugin_id, plugin_playlist_id, owner_id) WHERE plugin_id != ''; +`) return err } func downAddPluginPlaylistFields(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `DROP INDEX IF EXISTS idx_playlist_plugin;`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN plugin_playlist_id;`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN plugin_id;`) + _, err := tx.ExecContext(ctx, ` +DROP INDEX IF EXISTS idx_playlist_plugin; +ALTER TABLE playlist DROP COLUMN valid_until; +ALTER TABLE playlist DROP COLUMN plugin_playlist_id; +ALTER TABLE playlist DROP COLUMN plugin_id; +`) return err } diff --git a/model/playlist.go b/model/playlist.go index d9db3f4d2..7ed3e259b 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -32,8 +32,9 @@ type Playlist struct { EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"` // Plugin playlist attributes - PluginID string `structs:"plugin_id" json:"pluginId,omitempty"` - PluginPlaylistID string `structs:"plugin_playlist_id" json:"pluginPlaylistId,omitempty"` + PluginID string `structs:"plugin_id" json:"pluginId,omitempty"` + PluginPlaylistID string `structs:"plugin_playlist_id" json:"pluginPlaylistId,omitempty"` + ValidUntil *time.Time `structs:"valid_until" json:"validUntil,omitempty"` } func (pls Playlist) IsReadOnly() bool { diff --git a/plugins/playlist_provider.go b/plugins/playlist_provider.go index ab220ba1f..a1e894873 100644 --- a/plugins/playlist_provider.go +++ b/plugins/playlist_provider.go @@ -200,6 +200,10 @@ func (p *playlistSyncer) syncPlaylist(info capabilities.PlaylistInfo, dbID strin PluginID: p.pluginName, PluginPlaylistID: info.ID, } + if resp.ValidUntil > 0 { + t := time.Unix(resp.ValidUntil, 0) + pls.ValidUntil = &t + } // Set tracks from matched media files pls.AddMediaFiles(matched) diff --git a/plugins/playlist_provider_test.go b/plugins/playlist_provider_test.go index a72ef357f..c11c7b6c9 100644 --- a/plugins/playlist_provider_test.go +++ b/plugins/playlist_provider_test.go @@ -75,6 +75,7 @@ var _ = Describe("PlaylistProvider", Ordered, func() { Expect(dailyMix1.PluginID).To(Equal("test-playlist-provider")) Expect(dailyMix1.PluginPlaylistID).To(Equal("daily-mix-1")) Expect(dailyMix1.Public).To(BeFalse()) + Expect(dailyMix1.ValidUntil).To(BeNil()) }) It("generates deterministic playlist IDs", func() { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index fc0b1cd91..92576a66d 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -168,9 +168,10 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso if p.IsReadOnly() { pls.Readonly = true - // ValidUntil only applies to smart playlists if p.IsSmartPlaylist() && p.EvaluatedAt != nil { pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + } else if p.IsPluginPlaylist() && p.ValidUntil != nil { + pls.ValidUntil = p.ValidUntil } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 294f0f816..a9fd29b40 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -177,13 +177,23 @@ var _ = Describe("buildPlaylist", func() { } }) - It("marks plugin playlist as readonly", func() { + It("marks plugin playlist as readonly with no ValidUntil when not set", func() { ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) result := router.buildPlaylist(ctx, playlist) Expect(result.Readonly).To(BeTrue()) Expect(result.ValidUntil).To(BeNil()) }) + It("exposes ValidUntil when set on the model", func() { + validUntil := time.Date(2023, 3, 1, 12, 0, 0, 0, time.UTC) + playlist.ValidUntil = &validUntil + + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + result := router.buildPlaylist(ctx, playlist) + Expect(result.Readonly).To(BeTrue()) + Expect(result.ValidUntil).To(Equal(&validUntil)) + }) + It("marks plugin playlist as readonly even for non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", UserName: "other"}) result := router.buildPlaylist(ctx, playlist)