This commit is contained in:
Kendall Garner 2024-04-28 12:38:43 +02:00 committed by GitHub
commit c151b718d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2203 additions and 35 deletions

View File

@ -75,6 +75,7 @@ func runNavidrome() {
g.Go(startSignaler(ctx))
g.Go(startScheduler(ctx))
g.Go(schedulePeriodicScan(ctx))
g.Go(schedulePlaylistSync(ctx))
if conf.Server.Jukebox.Enabled {
g.Go(startPlaybackServer(ctx))
@ -141,6 +142,35 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
func schedulePlaylistSync(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.PlaylistSyncSchedule
if schedule == "" {
log.Warn("Periodic playlist sync is DISABLED")
return nil
}
playlists := CreatePlaylistRetriever()
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic playlist sync", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() {
_ = playlists.SyncPlaylists(ctx)
})
if err != nil {
log.Error("Error scheduling periodic playlist sync", err)
}
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
log.Debug("Executing initial playlist sync")
if err := playlists.SyncPlaylists(ctx); err != nil {
log.Error("Error executing initial playlist sync", err)
}
log.Debug("Finished initial playlist sync")
return nil
}
}
func startScheduler(ctx context.Context) func() error {
log.Info(ctx, "Starting scheduler")
schedulerInstance := scheduler.GetInstance()

View File

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
@ -40,8 +41,9 @@ func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlistRetriever := external_playlists.NewPlaylistRetriever(dataStore)
playlists := core.NewPlaylists(dataStore)
router := nativeapi.New(dataStore, share, playlists)
router := nativeapi.New(dataStore, share, playlistRetriever, playlists)
return router
}
@ -96,6 +98,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func CreatePlaylistRetriever() external_playlists.PlaylistRetriever {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playlistRetriever := external_playlists.NewPlaylistRetriever(dataStore)
return playlistRetriever
}
func createScanner() scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)

View File

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
@ -71,6 +72,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
func CreatePlaylistRetriever() external_playlists.PlaylistRetriever {
panic(wire.Build(
allProviders,
))
}
// Scanner must be a Singleton
var (
onceScanner sync.Once

View File

@ -83,6 +83,7 @@ type configOptions struct {
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
PlaylistSyncSchedule string
Agents string
LastFM lastfmOptions
@ -193,6 +194,10 @@ func Load() {
os.Exit(1)
}
if err := validateSchedule(&Server.PlaylistSyncSchedule); err != nil {
os.Exit(1)
}
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
@ -250,17 +255,22 @@ func validateScanSchedule() error {
log.Warn("Setting ScanSchedule", "schedule", Server.ScanSchedule)
}
}
if Server.ScanSchedule == "0" || Server.ScanSchedule == "" {
Server.ScanSchedule = ""
return validateSchedule(&Server.ScanSchedule)
}
func validateSchedule(schedule *string) error {
extracted := *schedule
if extracted == "0" || extracted == "" {
*schedule = ""
return nil
}
if _, err := time.ParseDuration(Server.ScanSchedule); err == nil {
Server.ScanSchedule = "@every " + Server.ScanSchedule
if _, err := time.ParseDuration(extracted); err == nil {
*schedule = "@every " + extracted
}
c := cron.New()
_, err := c.AddFunc(Server.ScanSchedule, func() {})
_, err := c.AddFunc(*schedule, func() {})
if err != nil {
log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err)
log.Error("Invalid schedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", *schedule, err)
}
return err
}
@ -350,6 +360,8 @@ func init() {
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("playlistsyncschedule", "")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/model"
)
@ -37,7 +38,8 @@ type Song struct {
}
var (
ErrNotFound = errors.New("not found")
ErrNotFound = errors.New("not found")
StripAllTags = bluemonday.StrictPolicy()
)
// TODO Break up this interface in more specific methods, like artists

View File

@ -3,11 +3,14 @@ package listenbrainz
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -17,6 +20,15 @@ import (
const (
listenBrainzAgentName = "listenbrainz"
sessionKeyProperty = "ListenBrainzSessionKey"
playlistTypeUser = "user"
playlistTypeCollab = "collab"
playlistTypeCreated = "created"
defaultFetch = 25
sourceDaily = "daily-jams"
)
var (
playlistTypes = []string{playlistTypeUser, playlistTypeCollab, playlistTypeCreated}
)
type listenBrainzAgent struct {
@ -104,6 +116,277 @@ func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrob
return scrobbler.ErrUnrecoverable
}
func (l *listenBrainzAgent) GetPlaylistTypes() []string {
return playlistTypes
}
func getIdentifier(url string) string {
split := strings.Split(url, "/")
return split[len(split)-1]
}
func (l *listenBrainzAgent) GetPlaylists(ctx context.Context, offset, count int, userId, playlistType string) (*external_playlists.ExternalPlaylists, error) {
token, err := l.sessionKeys.GetWithUser(ctx, userId)
if errors.Is(agents.ErrNoUsername, err) {
resp, err := l.client.validateToken(ctx, token.Key)
if err != nil {
return nil, err
}
token.User = resp.UserName
err = l.sessionKeys.PutWithUser(ctx, userId, token.Key, resp.UserName)
if err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
resp, err := l.client.getPlaylists(ctx, offset, count, token.Key, token.User, playlistType)
if err != nil {
return nil, err
}
lists := make([]external_playlists.ExternalPlaylist, len(resp.Playlists))
syncable := playlistType != playlistTypeCreated
for i, playlist := range resp.Playlists {
pls := playlist.Playlist
lists[i] = external_playlists.ExternalPlaylist{
Name: pls.Title,
Description: utils.SanitizeText(pls.Annotation),
Creator: pls.Creator,
ID: getIdentifier(pls.Identifier),
Url: pls.Identifier,
CreatedAt: pls.Date,
UpdatedAt: pls.Extension.Extension.LastModified,
Syncable: syncable,
}
}
return &external_playlists.ExternalPlaylists{
Total: resp.PlaylistCount,
Lists: lists,
}, nil
}
func (l *listenBrainzAgent) ImportPlaylist(ctx context.Context, update bool, sync bool, userId, id, name string) error {
token, err := l.sessionKeys.Get(ctx, userId)
if err != nil {
return err
}
pls, err := l.client.getPlaylist(ctx, token, id)
if err != nil {
return err
}
// From observation, playlists that are created by the user `listenbrainz` (Weekly Jams,
// Exploration) are immutable playlists. While `troi-bot` Daily Jams will
// be deleted after some period of time, they can be modified for that duration.
syncable := pls.Playlist.Creator != listenBrainzAgentName
if sync && !syncable {
return external_playlists.ErrSyncUnsupported
}
err = l.ds.WithTx(func(tx model.DataStore) error {
ids := make([]string, len(pls.Playlist.Tracks))
for i, track := range pls.Playlist.Tracks {
ids[i] = getIdentifier(track.Identifier)
}
matched_tracks, err := tx.MediaFile(ctx).FindWithMbid(ids)
if err != nil {
return err
}
var playlist *model.Playlist = nil
comment := agents.StripAllTags.Sanitize(pls.Playlist.Annotation)
if update {
playlist, err = tx.Playlist(ctx).GetByExternalInfo(listenBrainzAgentName, id)
if err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Failed to query for playlist", "error", err)
}
} else if playlist.ExternalAgent != listenBrainzAgentName {
return fmt.Errorf("existing agent %s does not match current agent %s", playlist.ExternalAgent, listenBrainzAgentName)
} else if userId != playlist.OwnerID {
return model.ErrNotAuthorized
} else {
playlist.Name = name
playlist.Comment = comment
}
}
if playlist == nil {
playlist = &model.Playlist{
Name: name,
Comment: comment,
OwnerID: userId,
Public: false,
ExternalAgent: listenBrainzAgentName,
ExternalId: id,
ExternalSync: sync,
ExternalSyncable: syncable,
ExternalUrl: pls.Playlist.Identifier,
}
}
playlist.AddMediaFiles(matched_tracks)
err = tx.Playlist(ctx).Put(playlist)
if err != nil {
log.Error(ctx, "Failed to import playlist", "id", id, err)
}
return err
})
return err
}
func (l *listenBrainzAgent) SyncPlaylist(ctx context.Context, tx model.DataStore, pls *model.Playlist) error {
token, err := l.sessionKeys.Get(ctx, pls.OwnerID)
if err != nil {
return err
}
external, err := l.client.getPlaylist(ctx, token, pls.ExternalId)
if err != nil {
return err
}
ids := make([]string, len(external.Playlist.Tracks))
for i, track := range external.Playlist.Tracks {
ids[i] = getIdentifier(track.Identifier)
}
matched_tracks, err := tx.MediaFile(ctx).FindWithMbid(ids)
if err != nil {
return err
}
comment := agents.StripAllTags.Sanitize(external.Playlist.Annotation)
pls.Comment = comment
pls.AddMediaFiles(matched_tracks)
err = tx.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Failed to sync playlist", "id", pls.ID, err)
}
return err
}
func (l *listenBrainzAgent) SyncRecommended(ctx context.Context, userId string) error {
token, err := l.sessionKeys.GetWithUser(ctx, userId)
if errors.Is(agents.ErrNoUsername, err) {
resp, err := l.client.validateToken(ctx, token.Key)
if err != nil {
return err
}
token.User = resp.UserName
err = l.sessionKeys.PutWithUser(ctx, userId, token.Key, resp.UserName)
if err != nil {
return err
}
} else if err != nil {
return err
}
resp, err := l.client.getPlaylists(ctx, 0, defaultFetch, token.Key, token.User, playlistTypeCreated)
if err != nil {
return err
}
var full_pls *listenBrainzResponse = nil
var id string
for _, pls := range resp.Playlists {
if pls.Playlist.Extension.Extension.AdditionalMetadata.AlgorithmMetadata.SourcePatch == sourceDaily {
id = getIdentifier(pls.Playlist.Identifier)
full_pls, err = l.client.getPlaylist(ctx, token.Key, id)
break
}
}
if err != nil {
return err
} else if full_pls == nil {
return agents.ErrNotFound
}
err = l.ds.WithTx(func(tx model.DataStore) error {
ids := make([]string, len(full_pls.Playlist.Tracks))
for i, track := range full_pls.Playlist.Tracks {
ids[i] = getIdentifier(track.Identifier)
}
matched_tracks, err := tx.MediaFile(ctx).FindWithMbid(ids)
if err != nil {
return err
}
playlist, err := tx.Playlist(ctx).GetRecommended(userId, listenBrainzAgentName)
comment := agents.StripAllTags.Sanitize(full_pls.Playlist.Annotation)
if err != nil {
playlist = &model.Playlist{
Name: "ListenBrainz Daily Playlist",
Comment: comment,
OwnerID: userId,
Public: false,
ExternalAgent: listenBrainzAgentName,
ExternalId: id,
ExternalSync: false,
ExternalSyncable: false,
ExternalRecommended: true,
}
if !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Failed to query for playlist", "error", err)
}
}
playlist.ExternalId = id
playlist.ExternalUrl = full_pls.Playlist.Identifier
playlist.AddMediaFiles(matched_tracks)
err = tx.Playlist(ctx).Put(playlist)
if err != nil {
log.Error(ctx, "Failed to import playlist", "id", id, err)
}
return err
})
return err
}
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
sk, err := l.sessionKeys.Get(ctx, userId)
return err == nil && sk != ""
@ -115,6 +398,10 @@ func init() {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return listenBrainzConstructor(ds)
})
external_playlists.Register(listenBrainzAgentName, func(ds model.DataStore) external_playlists.PlaylistAgent {
return listenBrainzConstructor(ds)
})
}
})
}

View File

@ -6,9 +6,12 @@ import (
"encoding/json"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -28,6 +31,7 @@ var _ = Describe("listenBrainzAgent", func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty+agents.UserSuffix, "example")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
@ -160,4 +164,184 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
Describe("GetPlaylistTypes", func() {
It("should return data", func() {
Expect(agent.GetPlaylistTypes()).To(Equal([]string{"user", "collab", "created"}))
})
})
Describe("playlists", func() {
parseTime := func(t string) time.Time {
resp, _ := time.Parse(time.RFC3339Nano, t)
return resp
}
Describe("GetPlaylists", func() {
It("should return playlist", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.playlists.json")
httpClient.Res = http.Response{
Body: f,
StatusCode: 200,
}
resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "user")
Expect(err).To(BeNil())
Expect(resp.Total).To(Equal(3))
match := func(idx int, pls external_playlists.ExternalPlaylist) {
list := resp.Lists[idx]
Expect(list.Name).To(Equal(pls.Name))
Expect(list.Description).To(Equal(pls.Description))
Expect(list.ID).To(Equal(pls.ID))
Expect(list.Url).To(Equal(pls.Url))
Expect(list.Creator).To(Equal(pls.Creator))
Expect(list.CreatedAt).To(Equal(pls.CreatedAt))
Expect(list.UpdatedAt).To(Equal(pls.UpdatedAt))
Expect(list.Existing).To(Equal(pls.Existing))
Expect(list.Tracks).To(BeNil())
}
// IMPORTANT: this must NOT allow the <script> element or onclck js
match(0, external_playlists.ExternalPlaylist{
Name: "Daily Jams for example, 2023-03-18 Sat",
Description: "Daily jams playlist!",
ID: "00000000-0000-0000-0000-000000000000",
Url: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000000",
Creator: "troi-bot",
CreatedAt: parseTime("2023-03-18T11:01:46.635538+00:00"),
UpdatedAt: parseTime("2023-03-18T11:01:46.635538+00:00"),
Existing: false, // This is false, because the agent itself does not check for this property
})
match(1, external_playlists.ExternalPlaylist{
Name: "Top Discoveries of 2022 for example",
Description: "<p>Hi there</p><p>\n This playlist contains the top tracks for example that were first listened to in 2022.\n </p>\n <p>\n For more information on how this playlist is generated, please see our\n <a href=\"https://musicbrainz.org/doc/YIM2022Playlists\" rel=\"nofollow\">Year in Music 2022 Playlists</a> page.\n </p>\n ",
ID: "00000000-0000-0000-0000-000000000001",
Url: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000001",
Creator: "troi-bot",
CreatedAt: parseTime("2023-01-05T04:12:45.668903+00:00"),
UpdatedAt: parseTime("2023-01-05T04:12:45.668903+00:00"),
Existing: false,
})
match(2, external_playlists.ExternalPlaylist{
Name: "Top Missed Recordings of 2022 for example",
Description: "<p>\n This playlist features recordings that were listened to by users similar to example in 2022.\n It is a discovery playlist that aims to introduce you to new music that other similar users\n enjoy. It may require more active listening and may contain tracks that are not to your taste.\n </p>\n <p>\n The users similar to you who contributed to this playlist: <a href=\"https://listenbrainz.org/user/example1/\" rel=\"nofollow\">example1</a>, <a href=\"https://listenbrainz.org/user/example2/\" rel=\"nofollow\">example2</a>, <a href=\"https://listenbrainz.org/user/example3/\" rel=\"nofollow\">example3</a>.\n </p>\n <p>\n For more information on how this playlist is generated, please see our\n <a href=\"https://musicbrainz.org/doc/YIM2022Playlists\" rel=\"nofollow\">Year in Music 2022 Playlists</a> page.\n </p>\n ",
ID: "00000000-0000-0000-0000-000000000003",
Url: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000003",
Creator: "troi-bot",
CreatedAt: parseTime("2023-01-05T04:12:45.317875+00:00"),
UpdatedAt: parseTime("2023-01-05T04:12:45.317875+00:00"),
Existing: false,
})
})
It("should error for nonexistent user", func() {
resp, err := agent.GetPlaylists(ctx, 0, 25, "user-2", "user")
Expect(resp).To(BeNil())
Expect(err).To(MatchError(model.ErrNotFound))
})
It("should error when trying to fetch token and failing", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"Code":400,"Error":"No"}`)),
StatusCode: 400,
}
_ = ds.UserProps(ctx).Delete("user-1", sessionKeyProperty+agents.UserSuffix)
resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "user")
Expect(resp).To(BeNil())
Expect(err).To(Equal(&listenBrainzError{
Code: 400,
Message: "No",
}))
})
It("should update session key when missing (and then fail)", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
_ = ds.UserProps(ctx).Delete("user-1", sessionKeyProperty+agents.UserSuffix)
resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "user")
Expect(resp).To(BeNil())
Expect(err).ToNot(BeNil())
key, err := ds.UserProps(ctx).Get("user-1", sessionKeyProperty+agents.UserSuffix)
Expect(err).To(BeNil())
Expect(key).To(Equal("ListenBrainzUser"))
})
It("should error for invalid type", func() {
resp, err := agent.GetPlaylists(ctx, 0, 25, "user-1", "abcde")
Expect(resp).To(BeNil())
Expect(err).To(MatchError(external_playlists.ErrorUnsupportedType))
})
})
Describe("SyncPlaylist", func() {
var pls model.Playlist
BeforeEach(func() {
pls = model.Playlist{
ID: "1000",
OwnerID: "user-1",
ExternalId: "00000000-0000-0000-0000-000000000004",
Tracks: model.PlaylistTracks{},
}
})
AfterEach(func() {
_ = ds.Playlist(ctx).Delete(pls.ID)
})
Describe("Successful test", func() {
WithMbid := model.MediaFile{ID: "1", Title: "Take Control", MbzRecordingID: "9f42783a-423b-4ed6-8a10-fdf4cb44456f", Artist: "Old Gods of Asgard"}
BeforeEach(func() {
_ = ds.MediaFile(ctx).Put(&WithMbid)
})
AfterEach(func() {
_ = ds.MediaFile(ctx).Delete(WithMbid.ID)
})
It("should return playlist", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.playlist.json")
httpClient.Res = http.Response{
Body: f,
StatusCode: 200,
}
err := agent.SyncPlaylist(ctx, ds, &pls)
Expect(err).To(BeNil())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].ID).To(Equal("1"))
Expect(pls.Comment).To(Equal("\n This playlist contains the top tracks for example that were first listened to in 2022.\n \n \n For more information on how this playlist is generated, please see our\n Year in Music 2022 Playlists page.\n \n "))
})
})
Describe("Failed tests", func() {
It("should error when trying to fetch token and failing", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"Code":404,"Error":"No"}`)),
StatusCode: 404,
}
_ = ds.UserProps(ctx).Delete("user-1", sessionKeyProperty+agents.UserSuffix)
err := agent.SyncPlaylist(ctx, ds, &pls)
Expect(err).To(Equal(model.ErrNotFound))
})
It("should error when playlist is not found", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"Code":404,"Error":"No"}`)),
StatusCode: 404,
}
err := agent.SyncPlaylist(ctx, ds, &pls)
Expect(err).To(Equal(model.ErrNotFound))
})
})
})
})
})

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -20,6 +21,7 @@ import (
type sessionKeysRepo interface {
Put(ctx context.Context, userId, sessionKey string) error
PutWithUser(ctx context.Context, userId, sessionKey, remoteUser string) error
Get(ctx context.Context, userId string) (string, error)
Delete(ctx context.Context, userId string) error
}
@ -54,11 +56,18 @@ func (s *Router) routes() http.Handler {
r.Get("/link", s.getLinkStatus)
r.Put("/link", s.link)
r.Delete("/link", s.unlink)
r.Get("/playlist", s.getSyncStatus)
r.Put("/playlist", s.syncPlaylist)
r.Delete("/playlist", s.unsyncPlaylist)
})
return r
}
const (
userAgentKey = external_playlists.UserAgentKey + listenBrainzAgentName
)
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
@ -100,7 +109,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
return
}
err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token)
err = s.sessionKeys.PutWithUser(r.Context(), u.ID, payload.Token, resp.UserName)
if err != nil {
log.Error("Could not save ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
@ -113,6 +122,49 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
err := s.sessionKeys.Delete(r.Context(), u.ID)
_ = s.ds.UserProps(r.Context()).Delete(u.ID, userAgentKey)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
} else {
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
}
}
func (s *Router) getSyncStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
_, err := s.ds.UserProps(r.Context()).Get(u.ID, userAgentKey)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
resp["status"] = false
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
} else {
resp["error"] = err
resp["status"] = false
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
}
return
}
resp["status"] = true
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
}
func (s *Router) syncPlaylist(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
err := s.ds.UserProps(r.Context()).Put(u.ID, userAgentKey, "")
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
} else {
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
}
}
func (s *Router) unsyncPlaylist(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
err := s.ds.UserProps(r.Context()).Delete(u.ID, userAgentKey)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
} else {

View File

@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -26,6 +27,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
httpClient = &tests.FakeHttpClient{}
cl := newClient("http://localhost/", httpClient)
r = Router{
ds: &tests.MockDataStore{},
sessionKeys: sk,
client: cl,
}
@ -96,6 +98,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
Expect(sk.KeyValue).To(Equal("tok-1"))
Expect(sk.UserName).To(Equal("ListenBrainzUser"))
})
})
@ -113,6 +116,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
type fakeSessionKeys struct {
KeyName string
KeyValue string
UserName string
}
func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
@ -120,11 +124,25 @@ func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) e
return nil
}
func (sk *fakeSessionKeys) PutWithUser(ctx context.Context, userId, sessionKey, remoteUser string) error {
sk.KeyValue = sessionKey
sk.UserName = remoteUser
return nil
}
func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) {
return sk.KeyValue, nil
}
func (sk *fakeSessionKeys) GetWithUser(ctx context.Context, userId string) (agents.KeyWithUser, error) {
return agents.KeyWithUser{
Key: sk.KeyValue,
User: sk.UserName,
}, nil
}
func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error {
sk.KeyValue = ""
sk.UserName = ""
return nil
}

View File

@ -8,8 +8,11 @@ import (
"net/http"
"net/url"
"path"
"time"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type listenBrainzError struct {
@ -35,17 +38,60 @@ type client struct {
}
type listenBrainzResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Status string `json:"status"`
Valid bool `json:"valid"`
UserName string `json:"user_name"`
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Status string `json:"status"`
Valid bool `json:"valid"`
UserName string `json:"user_name"`
PlaylistCount int `json:"playlist_count"`
Playlists []overallPlaylist `json:"playlists,omitempty"`
Playlist lbPlaylist `json:"playlist"`
}
type listenBrainzRequest struct {
ApiKey string
Body listenBrainzRequestBody
Body *listenBrainzRequestBody
}
type overallPlaylist struct {
Playlist lbPlaylist `json:"playlist"`
}
type lbPlaylist struct {
Annotation string `json:"annotation"`
Creator string `json:"creator"`
Date time.Time `json:"date"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Extension plsExtension `json:"extension"`
Tracks []lbTrack `json:"track"`
}
type plsExtension struct {
Extension playlistExtension `json:"https://musicbrainz.org/doc/jspf#playlist"`
}
type playlistExtension struct {
AdditionalMetadata additionalMeta `json:"additional_metadata"`
Collaborators []string `json:"collaborators"`
CreatedFor string `json:"created_for"`
LastModified time.Time `json:"last_modified_at"`
Public bool `json:"public"`
}
type additionalMeta struct {
AlgorithmMetadata algoMeta `json:"algorithm_metadata"`
}
type algoMeta struct {
SourcePatch string `json:"source_patch"`
}
type lbTrack struct {
Creator string `json:"creator"`
Identifier string `json:"identifier"`
Title string `json:"title"`
}
type listenBrainzRequestBody struct {
@ -86,7 +132,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", "", r)
if err != nil {
return nil, err
}
@ -96,13 +142,13 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
Body: &listenBrainzRequestBody{
ListenType: PlayingNow,
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", "", r)
if err != nil {
return err
}
@ -115,12 +161,12 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
Body: &listenBrainzRequestBody{
ListenType: Single,
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", "", r)
if err != nil {
return err
}
@ -130,6 +176,53 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
return nil
}
func (c *client) getPlaylists(ctx context.Context, offset, count int, apiKey, user, plsType string) (*listenBrainzResponse, error) {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: nil,
}
var endpoint string
switch plsType {
case "user":
endpoint = "user/" + user + "/playlists"
case "created":
endpoint = "user/" + user + "/playlists/createdfor"
case "collab":
endpoint = "user/" + user + "/playlists/collaborator"
default:
return nil, external_playlists.ErrorUnsupportedType
}
extra := fmt.Sprintf("?count=%d&offset=%d", count, offset)
resp, err := c.makeRequest(ctx, http.MethodGet, endpoint, extra, r)
if err != nil {
return nil, err
}
return resp, err
}
func (c *client) getPlaylist(ctx context.Context, apiKey, plsId string) (*listenBrainzResponse, error) {
r := &listenBrainzRequest{
ApiKey: apiKey,
}
endpoint := fmt.Sprintf("playlist/%s", plsId)
resp, err := c.makeRequest(ctx, http.MethodGet, endpoint, "", r)
if resp.Code == 404 {
return nil, model.ErrNotFound
} else if err != nil {
return nil, err
}
return resp, nil
}
func (c *client) path(endpoint string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
@ -139,13 +232,25 @@ func (c *client) path(endpoint string) (string, error) {
return u.String(), nil
}
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, query string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
uri, err := c.path(endpoint)
if err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
if query != "" {
uri += query
}
var req *http.Request
if r.Body != nil {
b, _ := json.Marshal(r.Body)
req, _ = http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
} else {
req, _ = http.NewRequestWithContext(ctx, method, uri, nil)
}
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
if r.ApiKey != "" {

View File

@ -4,10 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -34,6 +38,84 @@ var _ = Describe("client", func() {
Expect(response.Status).To(Equal("ok"))
Expect(response.Error).To(Equal("Error"))
})
It("parses playlists response properly", func() {
var response listenBrainzResponse
f, _ := os.ReadFile("tests/fixtures/listenbrainz.playlists.json")
date, _ := time.Parse(time.RFC3339Nano, "2023-03-18T11:01:46.635538+00:00")
modified, _ := time.Parse(time.RFC3339Nano, "2023-03-18T11:01:46.635538+00:00")
err := json.Unmarshal(f, &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.PlaylistCount).To(Equal(3))
Expect(response.Playlists).To(HaveLen(3))
Expect(response.Playlists[0]).To(Equal(overallPlaylist{
Playlist: lbPlaylist{
Annotation: "Daily jams playlist!",
Creator: "troi-bot",
Date: date,
Identifier: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000000",
Title: "Daily Jams for example, 2023-03-18 Sat",
Extension: plsExtension{
Extension: playlistExtension{
AdditionalMetadata: additionalMeta{
AlgorithmMetadata: algoMeta{
SourcePatch: "daily-jams",
},
},
Collaborators: []string{"example"},
CreatedFor: "example",
LastModified: modified,
Public: true,
},
},
Tracks: []lbTrack{},
},
}))
})
It("parses playlist response properly", func() {
var response listenBrainzResponse
f, _ := os.ReadFile("tests/fixtures/listenbrainz.playlist.json")
date, _ := time.Parse(time.RFC3339Nano, "2023-01-05T04:12:45.668903+00:00")
modified, _ := time.Parse(time.RFC3339Nano, "2023-01-05T04:12:45.668903+00:00")
err := json.Unmarshal(f, &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Playlist).To(Equal(lbPlaylist{
Annotation: "<p>\n This playlist contains the top tracks for example that were first listened to in 2022.\n </p>\n <p>\n For more information on how this playlist is generated, please see our\n <a href=\"https://musicbrainz.org/doc/YIM2022Playlists\">Year in Music 2022 Playlists</a> page.\n </p>\n ",
Creator: "troi-bot",
Date: date,
Identifier: "https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000004",
Title: "Top Discoveries of 2022 for example",
Extension: plsExtension{
Extension: playlistExtension{
AdditionalMetadata: additionalMeta{
AlgorithmMetadata: algoMeta{
SourcePatch: "top-discoveries-for-year",
},
},
Collaborators: []string{"example"},
CreatedFor: "example",
LastModified: modified,
Public: true,
},
},
Tracks: []lbTrack{
{
Creator: "Poets of the Fall",
Identifier: "https://musicbrainz.org/recording/684bedbb-78d7-4946-9038-5402d5fa83b0",
Title: "Requiem for My Harlequin",
},
{
Creator: "Old Gods of Asgard",
Identifier: "https://musicbrainz.org/recording/9f42783a-423b-4ed6-8a10-fdf4cb44456f",
Title: "Take Control",
},
},
}))
})
})
Describe("validateToken", func() {
@ -116,4 +198,69 @@ var _ = Describe("client", func() {
})
})
})
Describe("getPlaylists", func() {
Describe("successful responses", func() {
BeforeEach(func() {
f, _ := os.Open("tests/fixtures/listenbrainz.playlists.json")
httpClient.Res = http.Response{
Body: f,
StatusCode: 200,
}
})
test := func(offset, count int, plsType, extra string) {
It("Handles "+plsType+" correctly", func() {
resp, err := client.getPlaylists(context.Background(), offset, count, "LB-TOKEN", "example", plsType)
Expect(err).To(BeNil())
Expect(resp.Playlists).To(HaveLen(3))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(fmt.Sprintf("BASE_URL/user/example/playlists%s?count=%d&offset=%d", extra, count, offset)))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
}
test(10, 30, "user", "")
test(11, 50, "created", "/createdfor")
test(0, 25, "collab", "/collaborator")
})
It("fails when provided an unsupported type", func() {
resp, err := client.getPlaylists(context.Background(), 0, 25, "LB-TOKEN", "example", "notatype")
Expect(resp).To(BeNil())
Expect(err).To(Equal(external_playlists.ErrorUnsupportedType))
})
})
Describe("getPlaylist", func() {
It("Should fetch playlist properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.playlist.json")
httpClient.Res = http.Response{
Body: f,
StatusCode: 200,
}
list, err := client.getPlaylist(context.Background(), "LB-TOKEN", "00000000-0000-0000-0000-000000000004")
Expect(err).To(BeNil())
Expect(list.Playlist.Tracks).To(HaveLen(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/playlist/00000000-0000-0000-0000-000000000004"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("Returns correct error on 404", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"Code":404}`)),
StatusCode: 404,
}
resp, err := client.getPlaylist(context.Background(), "LB-TOKEN", "00000000-0000-0000-0000-000000000004")
Expect(resp).To(BeNil())
Expect(err).To(Equal(model.ErrNotFound))
})
})
})

View File

@ -2,10 +2,15 @@ package agents
import (
"context"
"errors"
"github.com/navidrome/navidrome/model"
)
const (
UserSuffix = "-user"
)
// SessionKeys is a simple wrapper around the UserPropsRepository
type SessionKeys struct {
model.DataStore
@ -16,10 +21,53 @@ func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
}
func (sk *SessionKeys) PutWithUser(ctx context.Context, userId, sessionKey, remoteUser string) error {
return sk.WithTx(func(tx model.DataStore) error {
err := tx.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
if err != nil {
return err
}
return tx.UserProps(ctx).Put(userId, sk.KeyName+UserSuffix, remoteUser)
})
}
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
}
type KeyWithUser struct {
Key, User string
}
var (
ErrNoUsername = errors.New("Token with no username")
)
func (sk *SessionKeys) GetWithUser(ctx context.Context, userId string) (KeyWithUser, error) {
result := KeyWithUser{}
err := sk.WithTx(func(tx model.DataStore) error {
key, err := tx.UserProps(ctx).Get(userId, sk.KeyName)
if err != nil {
return err
}
result.Key = key
user, err := tx.UserProps(ctx).Get(userId, sk.KeyName+UserSuffix)
if err != nil {
return ErrNoUsername
}
result.User = user
return nil
})
return result, err
}
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
}

View File

@ -0,0 +1,257 @@
package external_playlists
import (
"context"
"errors"
"fmt"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
const (
UserAgentKey = "agent-"
)
type playlistImportData struct {
Name string `json:"name" xml:"name"`
Sync bool `json:"sync" xml:"sync"`
}
type ImportMap = map[string]playlistImportData
type PlaylistRetriever interface {
GetAvailableAgents(ctx context.Context, userId string) []PlaylistSourceInfo
GetPlaylists(ctx context.Context, offset, count int, userId, agent, playlistType string) (*ExternalPlaylists, error)
ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping ImportMap) error
SyncPlaylist(ctx context.Context, playlistId string) error
SyncPlaylists(ctx context.Context) error
}
type playlistRetriever struct {
ds model.DataStore
retrievers map[string]PlaylistAgent
supportedTypes map[string]map[string]bool
}
var (
ErrorMissingAgent = errors.New("agent not found")
ErrSyncUnsupported = errors.New("cannot sync playlist")
ErrorUnsupportedType = errors.New("unsupported playlist type")
)
func NewPlaylistRetriever(ds model.DataStore) PlaylistRetriever {
p := &playlistRetriever{
ds: ds,
retrievers: make(map[string]PlaylistAgent),
supportedTypes: make(map[string]map[string]bool),
}
for name, constructor := range constructors {
s := constructor(ds)
p.retrievers[name] = s
mapping := map[string]bool{}
for _, plsType := range s.GetPlaylistTypes() {
mapping[plsType] = true
}
p.supportedTypes[name] = mapping
}
return p
}
func (p *playlistRetriever) GetAvailableAgents(ctx context.Context, userId string) []PlaylistSourceInfo {
user, _ := request.UserFrom(ctx)
agents := []PlaylistSourceInfo{}
for name, agent := range p.retrievers {
if agent.IsAuthorized(ctx, user.ID) {
agents = append(agents, PlaylistSourceInfo{
Name: name,
Types: agent.GetPlaylistTypes(),
})
}
}
return agents
}
func (p *playlistRetriever) validateTypeAgent(ctx context.Context, agent, playlistType string) (PlaylistAgent, error) {
ag, ok := p.retrievers[agent]
if !ok {
log.Error(ctx, "Agent not found", "agent", agent)
return nil, ErrorMissingAgent
}
_, ok = p.supportedTypes[agent][playlistType]
if !ok {
log.Error(ctx, "Unsupported playlist type", "agent", agent, "playlist type", playlistType)
return nil, ErrorUnsupportedType
}
return ag, nil
}
func (p *playlistRetriever) GetPlaylists(ctx context.Context, offset, count int, userId, agent, playlistType string) (*ExternalPlaylists, error) {
ag, err := p.validateTypeAgent(ctx, agent, playlistType)
if err != nil {
return nil, err
}
pls, err := ag.GetPlaylists(ctx, offset, count, userId, playlistType)
if err != nil {
log.Error(ctx, "Error retrieving playlist", "agent", agent, "user", userId, err)
return nil, err
}
ids := make([]string, len(pls.Lists))
for i, list := range pls.Lists {
ids[i] = list.ID
}
existingIDs, err := p.ds.Playlist(ctx).CheckExternalIds(agent, ids)
if err != nil {
log.Error(ctx, "Error checking for existing ids", "agent", agent, "user", userId, err)
return nil, err
}
existingMap := map[string]bool{}
for _, id := range existingIDs {
existingMap[id] = true
}
for i, list := range pls.Lists {
_, ok := existingMap[list.ID]
pls.Lists[i].Existing = ok
}
return pls, nil
}
func (p *playlistRetriever) ImportPlaylists(ctx context.Context, update bool, userId, agent string, mapping ImportMap) error {
ag, ok := p.retrievers[agent]
if !ok {
return ErrorMissingAgent
}
fail := 0
var err error
for id, data := range mapping {
err = ag.ImportPlaylist(ctx, update, data.Sync, userId, id, data.Name)
if err != nil {
fail++
log.Error(ctx, "Could not import playlist", "agent", agent, "id", id, "error", err)
}
}
if err != nil {
return fmt.Errorf("failed to sync %d playlist(s): %w", fail, err)
}
return nil
}
func (p *playlistRetriever) SyncPlaylist(ctx context.Context, playlistId string) error {
return p.ds.WithTx(func(tx model.DataStore) error {
pls, err := tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if pls.ExternalId == "" {
return model.ErrNotAvailable
}
user, _ := request.UserFrom(ctx)
if user.ID != pls.OwnerID {
return model.ErrNotAuthorized
}
ag, ok := p.retrievers[pls.ExternalAgent]
if !ok {
log.Error(ctx, "No retriever for playlist", "type", ag, "id", playlistId)
return ErrorMissingAgent
}
return ag.SyncPlaylist(ctx, tx, pls)
})
}
func (p *playlistRetriever) SyncRecommended(ctx context.Context, userId, agent string) error {
ag, ok := p.retrievers[agent]
if !ok {
return ErrorMissingAgent
}
err := ag.SyncRecommended(ctx, userId)
if err != nil {
log.Error(ctx, "Failed to sync recommended playlists", "agent", agent, "user", userId, err)
}
return err
}
func (p *playlistRetriever) SyncPlaylists(ctx context.Context) error {
playlists, err := p.ds.Playlist(ctx).GetSyncedPlaylists()
if err != nil {
return err
}
for _, playlist := range playlists {
user := model.User{
ID: playlist.OwnerID,
}
nestedCtx := request.WithUser(ctx, user)
err = p.SyncPlaylist(nestedCtx, playlist.ID)
if err != nil {
log.Error(nestedCtx, "Failed to sync playlist", "id", playlist.ID, err)
}
}
props, err := p.ds.UserProps(ctx).GetAllWithPrefix(UserAgentKey)
if err != nil {
return err
}
for _, prop := range props {
split := strings.Split(prop.Key, UserAgentKey)
user := model.User{
ID: prop.UserID,
}
nestedCtx := request.WithUser(ctx, user)
err = p.SyncRecommended(nestedCtx, prop.UserID, split[1])
if err != nil {
log.Error(ctx, "Failed to fetch recommended playlists", "user", prop.UserID, "agent", split[1])
}
}
return err
}
var constructors map[string]Constructor
func Register(name string, init Constructor) {
if constructors == nil {
constructors = make(map[string]Constructor)
}
constructors[name] = init
}

View File

@ -0,0 +1,48 @@
package external_playlists
import (
"context"
"time"
"github.com/navidrome/navidrome/model"
)
type PlaylistSourceInfo struct {
Name string `json:"name"`
Types []string `json:"types"`
}
type ExternalPlaylist struct {
Name string `json:"name"`
Description string `json:"description"`
ID string `json:"id"`
Url string `json:"url,omitempty"`
Creator string `json:"creator"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Existing bool `json:"existing"`
Syncable bool `json:"syncable"`
Tracks []ExternalTrack `json:"-"`
}
type ExternalTrack struct {
Title string
Artist string
ID string
}
type ExternalPlaylists struct {
Total int
Lists []ExternalPlaylist
}
type PlaylistAgent interface {
GetPlaylistTypes() []string
GetPlaylists(ctx context.Context, offset, count int, userId, playlistType string) (*ExternalPlaylists, error)
ImportPlaylist(ctx context.Context, update bool, sync bool, userId, id, name string) error
IsAuthorized(ctx context.Context, userId string) bool
SyncPlaylist(ctx context.Context, tx model.DataStore, pls *model.Playlist) error
SyncRecommended(ctx context.Context, userId string) error
}
type Constructor func(ds model.DataStore) PlaylistAgent

View File

@ -3,6 +3,7 @@ package core
import (
"github.com/google/wire"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/scrobbler"
)
@ -18,4 +19,5 @@ var Set = wire.NewSet(
agents.New,
ffmpeg.New,
scrobbler.GetPlayTracker,
external_playlists.NewPlaylistRetriever,
)

View File

@ -0,0 +1,36 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPlaylistExternalInfo, downAddPlaylistExternalInfo)
}
func upAddPlaylistExternalInfo(_ context.Context, tx *sql.Tx) error {
// Note: Ideally, we would also change the type of "comment" to be longer than 255
// characters, but since this is Sqlite, the length doesn't matter
_, err := tx.Exec(`
alter table playlist
add external_agent varchar default '' not null;
alter table playlist
add external_id varchar default '' not null;
alter table playlist
add external_url varchar default '' not null;
alter table playlist
add external_sync bool default false;
alter table playlist
add external_syncable bool default false;
alter table playlist
add external_recommended bool default false;
`)
return err
}
func downAddPlaylistExternalInfo(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -267,6 +267,7 @@ type MediaFileRepository interface {
FindAllByPath(path string) (MediaFiles, error)
FindByPath(path string) (*MediaFile, error)
FindPathsRecursively(basePath string) ([]string, error)
FindWithMbid(ids []string) (MediaFiles, error)
DeleteByPath(path string) (int64, error)
AnnotatedRepository

View File

@ -26,6 +26,14 @@ type Playlist struct {
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// External Info
ExternalAgent string `structs:"external_agent" json:"external_agent"`
ExternalId string `structs:"external_id" json:"externalId"`
ExternalSync bool `structs:"external_sync" json:"externalSync"`
ExternalSyncable bool `structs:"external_syncable" json:"externalSyncable"`
ExternalUrl string `structs:"external_url" json:"externalUrl"`
ExternalRecommended bool `structs:"external_recommended" json:"externalRecommended"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
@ -106,8 +114,12 @@ type PlaylistRepository interface {
Exists(id string) (bool, error)
Put(pls *Playlist) error
Get(id string) (*Playlist, error)
GetSyncedPlaylists() (Playlists, error)
GetWithTracks(id string, refreshSmartPlaylist bool) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
CheckExternalIds(agent string, ids []string) ([]string, error)
GetByExternalInfo(agent, id string) (*Playlist, error)
GetRecommended(userId, agent string) (*Playlist, error)
FindByPath(path string) (*Playlist, error)
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository

View File

@ -1,8 +1,17 @@
package model
type UserProp struct {
UserID string `structs:"user_id" orm:"column(user_id)"`
Key string `structs:"key"`
Value string `structs:"value"`
}
type UserProps []UserProp
type UserPropsRepository interface {
Put(userId, key string, value string) error
Get(userId, key string) (string, error)
GetAllWithPrefix(key string) (UserProps, error)
Delete(userId, key string) error
DefaultGet(userId, key string, defaultValue string) (string, error)
}

View File

@ -172,6 +172,15 @@ func (r *mediaFileRepository) deleteNotInPath(basePath string) error {
return err
}
func (r *mediaFileRepository) FindWithMbid(ids []string) (model.MediaFiles, error) {
sel := r.newSelect().Column("id").Where(Eq{"mbz_recording_id": ids})
res := model.MediaFiles{}
err := r.queryAll(sel, &res)
return res, err
}
func (r *mediaFileRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
}

View File

@ -9,6 +9,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
@ -104,6 +105,10 @@ func (r *playlistRepository) Delete(id string) error {
}
func (r *playlistRepository) Put(p *model.Playlist) error {
if p.ExternalSync && !p.ExternalSyncable {
return external_playlists.ErrSyncUnsupported
}
pls := dbPlaylist{Playlist: *p}
if pls.ID == "" {
pls.CreatedAt = time.Now()
@ -139,6 +144,17 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()})
}
func (r *playlistRepository) GetSyncedPlaylists() (model.Playlists, error) {
sel := r.newSelect().Columns("id", "owner_id")
var res model.Playlists
err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
return res, err
}
func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist bool) (*model.Playlist, error) {
pls, err := r.Get(id)
if err != nil {
@ -476,6 +492,55 @@ func (r *playlistRepository) isWritable(playlistId string) bool {
return err == nil && pls.OwnerID == usr.ID
}
func (r *playlistRepository) GetByExternalInfo(agent, id string) (*model.Playlist, error) {
sql := Select("*").From(r.tableName).Where(Eq{"external_agent": agent, "external_id": id})
var pls model.Playlist
err := r.queryOne(sql, &pls)
if err != nil {
return nil, err
}
return &pls, nil
}
func (r *playlistRepository) GetRecommended(userId, agent string) (*model.Playlist, error) {
sql := Select("*").From(r.tableName).Where(Eq{"external_agent": agent, "owner_id": userId, "external_recommended": true})
var pls model.Playlist
err := r.queryOne(sql, &pls)
if err != nil {
return nil, err
}
return &pls, nil
}
func (r *playlistRepository) CheckExternalIds(agent string, ids []string) ([]string, error) {
// Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
chunks := slice.BreakUp(ids, 200)
var lists []string
for _, chunk := range chunks {
sql := Select("external_id").From(r.tableName).Where(Eq{"external_agent": agent, "external_id": chunk})
var partial []struct {
ExternalId string `json:"external_id"`
}
err := r.queryAll(sql, &partial)
if err != nil {
return nil, err
}
for _, item := range partial {
lists = append(lists, item.ExternalId)
}
}
return lists, nil
}
var _ model.PlaylistRepository = (*playlistRepository)(nil)
var _ rest.Repository = (*playlistRepository)(nil)
var _ rest.Persistable = (*playlistRepository)(nil)

View File

@ -47,6 +47,16 @@ func (r userPropsRepository) Get(userId, key string) (string, error) {
return resp.Value, nil
}
func (r userPropsRepository) GetAllWithPrefix(key string) (model.UserProps, error) {
sel := Select("*").From(r.tableName).Where(Like{"key": key + "%"})
resp := model.UserProps{}
err := r.queryAll(sel, &resp)
if err != nil {
return nil, err
}
return resp, nil
}
func (r userPropsRepository) DefaultGet(userId, key string, defaultValue string) (string, error) {
value, err := r.Get(userId, key)
if errors.Is(err, model.ErrNotFound) {

View File

@ -57,7 +57,12 @@ type scanStatus struct {
lastUpdate time.Time
}
func New(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, broker events.Broker) Scanner {
func New(
ds model.DataStore,
playlists core.Playlists,
cacheWarmer artwork.CacheWarmer,
broker events.Broker,
) Scanner {
s := &scanner{
ds: ds,
pls: playlists,

View File

@ -0,0 +1,177 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req"
)
var (
errBadRange = errors.New("end must me greater than start")
)
type webError struct {
Error string `json:"error"`
}
func replyError(ctx context.Context, w http.ResponseWriter, err error, status int) {
w.WriteHeader(status)
w.Header().Set("Content-Type", "application/json")
error := webError{Error: err.Error()}
resp, _ := json.Marshal(error)
_, writeErr := w.Write(resp)
if writeErr != nil {
log.Error(ctx, "Error sending json", "Error", err)
}
}
func replyJson(ctx context.Context, w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
resp, _ := json.Marshal(data)
_, err := w.Write(resp)
if err != nil {
log.Error(ctx, "Error sending json", "Error", err)
}
}
func (n *Router) getAgents() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(r.Context())
agents := n.pls.GetAvailableAgents(ctx, user.ID)
replyJson(ctx, w, agents)
}
}
func (n *Router) getPlaylists() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(r.Context())
param := req.Params(r)
start := param.IntOr("_start", 0)
end := param.IntOr("_end", 0)
if start >= end {
replyError(ctx, w, errBadRange, http.StatusBadRequest)
return
}
count := end - start
agent, err := param.String("agent")
if err != nil {
replyError(ctx, w, err, http.StatusBadRequest)
return
}
plsType, err := param.String("type")
if err != nil {
replyError(ctx, w, err, http.StatusBadRequest)
return
}
lists, err := n.pls.GetPlaylists(ctx, start, count, user.ID, agent, plsType)
if err != nil {
replyError(ctx, w, err, http.StatusInternalServerError)
} else {
w.Header().Set("X-Total-Count", strconv.Itoa(lists.Total))
replyJson(ctx, w, lists.Lists)
}
}
}
type externalImport struct {
Agent string `json:"agent"`
Playlists external_playlists.ImportMap `json:"playlists"`
Update bool `json:"update"`
}
func (n *Router) fetchPlaylists() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(r.Context())
defer r.Body.Close()
data, err := io.ReadAll(r.Body)
if err != nil {
replyError(ctx, w, err, http.StatusBadRequest)
return
}
var plsImport externalImport
err = json.Unmarshal(data, &plsImport)
if err != nil {
replyError(ctx, w, err, http.StatusBadRequest)
return
}
err = n.pls.ImportPlaylists(
ctx, plsImport.Update, user.ID, plsImport.Agent, plsImport.Playlists)
if err != nil {
if errors.Is(model.ErrNotAuthorized, err) {
replyError(ctx, w, err, http.StatusForbidden)
} else {
replyError(ctx, w, err, http.StatusInternalServerError)
}
return
}
replyJson(ctx, w, "")
}
}
func (n *Router) syncPlaylist() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plsId := chi.URLParam(r, "playlistId")
err := n.pls.SyncPlaylist(ctx, plsId)
if err != nil {
log.Error(ctx, "Failed to sync playlist", "id", plsId, err)
var code int
if errors.Is(err, model.ErrNotAuthorized) {
code = http.StatusForbidden
} else if errors.Is(err, model.ErrNotFound) {
code = http.StatusNotFound
} else {
code = http.StatusInternalServerError
}
replyError(ctx, w, err, code)
} else {
replyJson(ctx, w, "")
}
}
}
func (n *Router) externalPlaylistRoutes(r chi.Router) {
r.Route("/externalPlaylist", func(r chi.Router) {
r.Get("/", n.getPlaylists())
r.Post("/", n.fetchPlaylists())
r.Put("/sync/{playlistId}", n.syncPlaylist())
r.Get("/agents", n.getAgents())
})
}

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external_playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
)
@ -16,11 +17,13 @@ type Router struct {
http.Handler
ds model.DataStore
share core.Share
pls external_playlists.PlaylistRetriever
playlists core.Playlists
}
func New(ds model.DataStore, share core.Share, playlists core.Playlists) *Router {
r := &Router{ds: ds, share: share, playlists: playlists}
func New(ds model.DataStore, share core.Share, pls external_playlists.PlaylistRetriever, playlists core.Playlists) *Router {
r := &Router{ds: ds, share: share, pls: pls, playlists: playlists}
r.Handler = r.routes()
return r
}
@ -50,6 +53,8 @@ func (n *Router) routes() http.Handler {
n.addPlaylistRoute(r)
n.addPlaylistTrackRoute(r)
n.externalPlaylistRoutes(r)
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))

View File

@ -0,0 +1 @@
{"playlist":{"annotation":"<p>\n This playlist contains the top tracks for example that were first listened to in 2022.\n </p>\n <p>\n For more information on how this playlist is generated, please see our\n <a href=\"https://musicbrainz.org/doc/YIM2022Playlists\">Year in Music 2022 Playlists</a> page.\n </p>\n ","creator":"troi-bot","date":"2023-01-05T04:12:45.668903+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"top-discoveries-for-year"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-01-05T04:12:45.668903+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000004","title":"Top Discoveries of 2022 for example","track":[{"creator":"Poets of the Fall","extension":{"https://musicbrainz.org/doc/jspf#track":{"added_at":"2023-01-05T04:12:45.682643+00:00","added_by":"troi-bot","additional_metadata":{"caa_id":32371992730,"caa_release_mbid":"ed46e340-e9e0-49a5-b0fa-702beedc9b0c"},"artist_identifiers":["https://musicbrainz.org/artist/482a5456-7d8a-4366-a2e9-6f38853df632"]}},"identifier":"https://musicbrainz.org/recording/684bedbb-78d7-4946-9038-5402d5fa83b0","title":"Requiem for My Harlequin"},{"creator":"Old Gods of Asgard","extension":{"https://musicbrainz.org/doc/jspf#track":{"added_at":"2023-01-05T04:12:45.682643+00:00","added_by":"troi-bot","additional_metadata":{"caa_id":24118518720,"caa_release_mbid":"4a042f5c-0808-44e1-94af-2a7bb1dd6ea8"},"artist_identifiers":["https://musicbrainz.org/artist/3586e8bd-0c38-43dd-bd2a-b2dd93dbc8bb"]}},"identifier":"https://musicbrainz.org/recording/9f42783a-423b-4ed6-8a10-fdf4cb44456f","title":"Take Control"}]}}

View File

@ -0,0 +1 @@
{"count":25,"offset":0,"playlist_count":3,"playlists":[{"playlist":{"annotation":"Daily jams playlist!","creator":"troi-bot","date":"2023-03-18T11:01:46.635538+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"daily-jams"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-03-18T11:01:46.635538+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000000","title":"Daily Jams for example, 2023-03-18 Sat","track":[]}},{"playlist":{"annotation":"<p onclick=\"console.log(1)\">Hi there</p><p>\n This playlist contains the top tracks for example that were first listened to in 2022.\n <script>console.log(1)</script> </p>\n <p>\n For more information on how this playlist is generated, please see our\n <a href=\"https://musicbrainz.org/doc/YIM2022Playlists\">Year in Music 2022 Playlists</a> page.\n </p>\n ","creator":"troi-bot","date":"2023-01-05T04:12:45.668903+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"top-discoveries-for-year"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-01-05T04:12:45.668903+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000001","title":"Top Discoveries of 2022 for example","track":[]}},{"playlist":{"annotation":"<p>\n This playlist features recordings that were listened to by users similar to example in 2022.\n It is a discovery playlist that aims to introduce you to new music that other similar users\n enjoy. It may require more active listening and may contain tracks that are not to your taste.\n </p>\n <p>\n The users similar to you who contributed to this playlist: <a href=\"https://listenbrainz.org/user/example1/\">example1</a>, <a href=\"https://listenbrainz.org/user/example2/\">example2</a>, <a href=\"https://listenbrainz.org/user/example3/\">example3</a>.\n </p>\n <p>\n For more information on how this playlist is generated, please see our\n <a href=\"https://musicbrainz.org/doc/YIM2022Playlists\">Year in Music 2022 Playlists</a> page.\n </p>\n ","creator":"troi-bot","date":"2023-01-05T04:12:45.317875+00:00","extension":{"https://musicbrainz.org/doc/jspf#playlist":{"additional_metadata":{"algorithm_metadata":{"source_patch":"top-missed-recordings-for-year"}},"collaborators":["example"],"created_for":"example","creator":"troi-bot","last_modified_at":"2023-01-05T04:12:45.317875+00:00","public":true}},"identifier":"https://listenbrainz.org/playlist/00000000-0000-0000-0000-000000000003","title":"Top Missed Recordings of 2022 for example","track":[]}}]}

View File

@ -101,4 +101,38 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro
return res, nil
}
func (m *MockMediaFileRepo) FindWithMbid(ids []string) (model.MediaFiles, error) {
if m.err {
return nil, errors.New("error")
}
res := make(model.MediaFiles, 0)
id_set := map[string]bool{}
for _, id := range ids {
id_set[id] = true
}
for _, file := range m.data {
if _, ok := m.data[file.ID]; ok {
res = append(res, *file)
}
}
return res, nil
}
func (m *MockMediaFileRepo) Delete(id string) error {
if m.err {
return errors.New("Error!")
}
_, found := m.data[id]
if !found {
return errors.New("not found")
}
delete(m.data, id)
return nil
}
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)

View File

@ -22,6 +22,22 @@ func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
return m.Entity, nil
}
func (m *MockPlaylistRepo) Put(entity *model.Playlist) error {
if m.Error != nil {
return m.Error
}
m.Entity = entity
return nil
}
func (m *MockPlaylistRepo) Delete(_ string) error {
if m.Error != nil {
return m.Error
}
m.Entity = nil
return nil
}
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
if m.Error != nil {
return 0, m.Error

View File

@ -15,6 +15,7 @@ import artist from './artist'
import playlist from './playlist'
import radio from './radio'
import share from './share'
import externalPlaylist from './externalPlaylist'
import { Player } from './audioplayer'
import customRoutes from './routes'
import {
@ -118,6 +119,7 @@ const Admin = (props) => {
<Resource name="transcoding" />
),
<Resource name="translation" />,
<Resource name="externalPlaylist" {...externalPlaylist} />,
<Resource name="genre" />,
<Resource name="playlistTrack" />,
<Resource name="keepalive" />,

View File

@ -0,0 +1,298 @@
import { Link } from '@material-ui/core'
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
BooleanField,
BooleanInput,
Create,
Datagrid,
DateField,
List,
Loading,
SaveButton,
SelectInput,
SimpleForm,
TextField,
TextInput,
Toolbar,
useMutation,
useNotify,
useRecordContext,
useRedirect,
useRefresh,
useTranslate,
} from 'react-admin'
import { Title } from '../common'
import { REST_URL } from '../consts'
import { httpClient } from '../dataProvider'
const Expand = ({ record }) => (
<div>
<div dangerouslySetInnerHTML={{ __html: record.description }} />
<Link href={record.url} target="_blank" rel="noopener noreferrer">
{record.url}
</Link>
</div>
)
const NameInput = (props) => {
const { id, name } = useRecordContext(props)
return (
<TextInput
multiline
fullWidth
name={`name[${id}]`}
defaultValue={name}
parse={(val) => val || ''}
placeholder={name}
onClick={(event) => {
event.stopPropagation()
}}
/>
)
}
const SyncInput = (props) => {
const { id } = useRecordContext(props)
return (
<BooleanInput
sortable={false}
name={`sync[${id}]`}
label=""
onClick={(event) => {
event.stopPropagation()
}}
/>
)
}
const MyDataGrid = forwardRef(
({ onUnselectItems, selectedIds, setIds, ...props }, ref) => {
useEffect(() => {
setIds(selectedIds)
}, [selectedIds, setIds])
useEffect(() => {
return () => {
// This will run on dismount to clear up state
onUnselectItems()
}
}, [onUnselectItems])
const canSync = useMemo(() => {
let canSync = false
for (const id of props.ids) {
canSync ||= props.data[id].syncable
}
return canSync
}, [props.data, props.ids])
ref.current = onUnselectItems
return (
<Datagrid
{...props}
expand={<Expand />}
rowClick="toggleSelection"
selectedIds={selectedIds}
>
<NameInput source="name" sortable={false} />
{canSync && <SyncInput source="sync" sortable={false} />}
<TextField source="creator" sortable={false} />
<DateField source="createdAt" sortable={false} />
<DateField source="updatedAt" sortable={false} />
<BooleanField source="existing" sortable={false} />
</Datagrid>
)
},
)
const Dummy = () => <span></span>
const ExternalPlaylistSelect = forwardRef(
({ fullWidth, playlists, setIds, filter, ...props }, ref) => {
return (
<>
<List
{...props}
filter={filter}
title={<span></span>}
bulkActionButtons={<Dummy />}
exporter={false}
actions={<Dummy />}
>
<MyDataGrid setIds={setIds} ref={ref} />
</List>
</>
)
},
)
const ExternalPlaylistCreate = (props) => {
const clearRef = useRef()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
const refresh = useRefresh()
const translate = useTranslate()
const [agents, setAgents] = useState(null)
const [selectedAgent, setSelectedAgent] = useState(null)
const [selectedType, setSelectedType] = useState(null)
const [ids, setIds] = useState([])
const resourceName = translate('resources.externalPlaylist.name', {
smart_count: 1,
})
const title = translate('ra.page.create', {
name: `${resourceName}`,
})
useEffect(() => {
httpClient(`${REST_URL}/externalPlaylist/agents`)
.then((resp) => {
const mapping = {}
for (const agent of resp.json) {
mapping[agent.name] = agent.types
}
setAgents(mapping)
})
.catch((err) => {
console.log(err)
})
}, [])
useEffect(() => {
if (clearRef.current) {
clearRef.current()
}
}, [])
const allAgents = useMemo(
() =>
agents === null
? []
: Object.keys(agents).map((k) => ({ id: k, name: k })),
[agents],
)
const agentKeys = useMemo(
() =>
selectedAgent === null
? []
: agents[selectedAgent].map((type) => ({
id: type,
name: translate(
`resources.externalPlaylist.agent.${selectedAgent}.${type}`,
),
})),
[agents, selectedAgent, translate],
)
const changeAgent = (event) => {
if (clearRef.current) {
clearRef.current()
}
setSelectedAgent(event.target.value)
}
const changeType = (event) => {
if (clearRef.current) {
clearRef.current()
}
setSelectedType(event.target.value)
}
const save = useCallback(
async (values) => {
const { agent, name, type, update } = values
const playlists = {}
let sync = values.sync ?? {}
let count = 0
for (const id of ids) {
playlists[id] = {
name: name[id],
sync: sync[id],
}
count++
}
try {
await mutate(
{
type: 'create',
resource: 'externalPlaylist',
payload: {
data: { agent, type, update, playlists },
},
},
{ returnPromise: true },
)
notify('resources.externalPlaylist.notifications.created', 'info', {
smart_count: count,
})
refresh()
redirect('/playlist')
} catch (error) {
notify('resources.externalPlaylist.notifications.failed', 'error', {
cause: error.body.error,
})
}
},
[ids, mutate, notify, redirect, refresh],
)
let formBody
if (allAgents.length === 0) {
formBody = <div>{translate('message.noPlaylistAgent')}</div>
} else {
formBody = [
<SelectInput source="agent" choices={allAgents} onChange={changeAgent} />,
<SelectInput source="type" choices={agentKeys} onChange={changeType} />,
<BooleanInput source="update" defaultValue={true} />,
selectedType && (
<ExternalPlaylistSelect
filter={{ agent: selectedAgent, type: selectedType }}
setIds={setIds}
fullWidth
ref={clearRef}
/>
),
]
}
return (
<Create title={<Title subTitle={title} />} {...props}>
{agents === null ? (
<Loading />
) : (
<SimpleForm
toolbar={
<Toolbar>
<SaveButton disabled={ids.length === 0} />
</Toolbar>
}
save={save}
>
{formBody}
</SimpleForm>
)}
</Create>
)
}
export default ExternalPlaylistCreate

View File

@ -0,0 +1,5 @@
import ExternalPlaylistCreate from './ExternalPlaylistCreate'
export default {
create: ExternalPlaylistCreate,
}

View File

@ -155,14 +155,18 @@
"songCount": "Songs",
"comment": "Comment",
"sync": "Auto-import",
"path": "Import from"
"path": "Import from",
"external": "External",
"externalSync": "Sync from source"
},
"actions": {
"selectPlaylist": "Select a playlist:",
"addNewPlaylist": "Create \"%{name}\"",
"export": "Export",
"makePublic": "Make Public",
"makePrivate": "Make Private"
"makePrivate": "Make Private",
"viewOriginal": "View original",
"sync": "Sync from source"
},
"message": {
"duplicate_song": "Add duplicated songs",
@ -202,6 +206,31 @@
},
"actions": {
}
},
"externalPlaylist": {
"name": "External Playlist |||| External Playlists",
"fields": {
"name": "Name",
"agent": "Provider",
"type": "Playlist type",
"creator": "Creator",
"updatedAt": "Updated at",
"createdAt": "Created at",
"existing": "Existing",
"sync": "Periodically fetch",
"update": "Update existing playlists"
},
"notifications": {
"created": "External playlist imported |||| %{smart_count} external playlists imported",
"failed": "Could not import playlists: %{cause}"
},
"agent": {
"listenbrainz": {
"user": "Created by you",
"collab": "Playlist you collaborated",
"created": "Recommended playlist"
}
}
}
},
"ra": {
@ -263,7 +292,8 @@
"unselect": "Unselect",
"skip": "Skip",
"share": "Share",
"download": "Download"
"download": "Download",
"import": "Import"
},
"boolean": {
"true": "Yes",
@ -366,6 +396,10 @@
"listenBrainzLinkFailure": "ListenBrainz could not be linked: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz unlinked and scrobbling disabled",
"listenBrainzUnlinkFailure": "ListenBrainz could not be unlinked",
"agentRecommendedSyncSuccess": "%{agent} enabled to sync recommended playlists",
"agentRecommendedSyncFail": "%{agent} could not enable playlist syncing: %{error}",
"agentRecommendedUnsyncSuccess": "%{agent} disabled syncing recommended playlists",
"agentRecommendedUnsyncFail": "%{agent} could not disable playlist syncing: %{error}",
"openIn": {
"lastfm": "Open in Last.fm",
"musicbrainz": "Open in MusicBrainz"
@ -378,7 +412,12 @@
"shareSuccess": "URL copied to clipboard: %{url}",
"shareFailure": "Error copying URL %{url} to clipboard",
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Download in original format"
"downloadOriginalFormat": "Download in original format",
"playlistSyncConfirmTitle": "Are you sure you want to sync %{name}?",
"playlistSyncConfirmBody": "This will overwrite your current playlist",
"playlistSyncSuccess": "Successfully synced playlist",
"playlistSyncError": "Failed to sync playlist: %{error}",
"noPlaylistAgent": "You have no accounts to sync playlists. Please link a ListenBrainz account to proceed."
},
"menu": {
"library": "Library",
@ -394,6 +433,7 @@
"desktop_notifications": "Desktop Notifications",
"lastfmScrobbling": "Scrobble to Last.fm",
"listenBrainzScrobbling": "Scrobble to ListenBrainz",
"listenBrainzPlaylistSync": "Sync daily playlists (this requires following troi-bot)",
"replaygain": "ReplayGain Mode",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { useNotify, useTranslate } from 'react-admin'
import { FormControl, FormControlLabel, Switch } from '@material-ui/core'
import { httpClient } from '../dataProvider'
export const ListenBrainzPlaylistToggle = () => {
const notify = useNotify()
const translate = useTranslate()
const [linked, setLinked] = useState(null)
const togglePlaylist = () => {
if (linked) {
httpClient('/api/listenbrainz/playlist', { method: 'DELETE' })
.then(() => {
setLinked(false)
notify('message.agentRecommendedUnsyncSuccess', 'success', {
agent: 'ListenBrainz',
})
})
.catch((error) =>
notify('message.agentRecommendedUnsyncFail', 'warning', {
agent: 'ListenBrainz',
error: error.body?.error || error.message,
}),
)
} else {
httpClient('/api/listenbrainz/playlist', { method: 'PUT' })
.then(() => {
setLinked(true)
notify('message.agentRecommendedSyncSuccess', 'success', {
agent: 'ListenBrainz',
})
})
.catch((error) =>
notify('message.agentRecommendedSyncFail', 'warning', {
agent: 'ListenBrainz',
error: error.body?.error || error.message,
}),
)
}
}
useEffect(() => {
httpClient('/api/listenbrainz/playlist')
.then((response) => {
setLinked(response.json.status === true)
})
.catch(() => {
setLinked(false)
})
}, [])
return (
<>
<FormControl>
<FormControlLabel
control={
<Switch
id={'listenbrainz-playlist'}
color="primary"
checked={linked === true}
disabled={linked === null}
onChange={togglePlaylist}
/>
}
label={
<span>
{translate('menu.personal.options.listenBrainzPlaylistSync')}
</span>
}
/>
</FormControl>
</>
)
}

View File

@ -5,6 +5,7 @@ import { httpClient } from '../dataProvider'
import { ListenBrainzTokenDialog } from '../dialogs'
import { useDispatch } from 'react-redux'
import { openListenBrainzTokenDialog } from '../actions'
import { ListenBrainzPlaylistToggle } from './ListenBrainzPlaylistToggle'
export const ListenBrainzScrobbleToggle = () => {
const dispatch = useDispatch()
@ -56,6 +57,11 @@ export const ListenBrainzScrobbleToggle = () => {
/>
</FormControl>
<ListenBrainzTokenDialog setLinked={setLinked} />
{linked && (
<div>
<ListenBrainzPlaylistToggle />
</div>
)}
</>
)
}

View File

@ -0,0 +1,11 @@
import GetAppIcon from '@material-ui/icons/GetApp'
import { CreateButton } from 'react-admin'
export const ImportButton = (props) => (
<CreateButton
{...props}
icon={<GetAppIcon />}
basePath="externalPlaylist"
label={'ra.action.import'}
/>
)

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import {
Button,
@ -7,10 +7,13 @@ import {
useTranslate,
useDataProvider,
useNotify,
Confirm,
useRefresh,
} from 'react-admin'
import { useMediaQuery, makeStyles } from '@material-ui/core'
import { useMediaQuery, makeStyles, Link } from '@material-ui/core'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import ShuffleIcon from '@material-ui/icons/Shuffle'
import SyncIcon from '@material-ui/icons/Sync'
import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined'
import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri'
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
@ -30,6 +33,7 @@ import PropTypes from 'prop-types'
import { formatBytes } from '../utils'
import config from '../config'
import { ToggleFieldsMenu } from '../common'
import { OpenInNewOutlined } from '@material-ui/icons'
const useStyles = makeStyles({
toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' },
@ -41,9 +45,13 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => {
const classes = useStyles()
const dataProvider = useDataProvider()
const notify = useNotify()
const refresh = useRefresh()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const [shouldConfirm, setShouldConfirm] = useState(false)
const [syncing, setSyncing] = useState(false)
const getAllSongsAndDispatch = React.useCallback(
(action) => {
if (ids?.length === record.songCount) {
@ -111,6 +119,27 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => {
[record],
)
const handleResync = React.useCallback(async () => {
setShouldConfirm(false)
if (!syncing) {
setSyncing(true)
httpClient(`${REST_URL}/externalPlaylist/sync/${record.id}`, {
method: 'PUT',
})
.then(() => {
notify('message.playlistSyncSuccess', 'success')
refresh()
})
.catch((error) => {
notify('message.playlistSyncError', 'warning', {
error: error.message,
})
})
.finally(() => setSyncing(false))
}
}, [notify, record.id, refresh, syncing])
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<div className={classes.toolbar}>
@ -161,6 +190,36 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => {
>
<QueueMusicIcon />
</Button>
{record.externalUrl && (
<>
<Button
component={Link}
href={record.externalUrl}
target="_blank"
rel="noopener noreferrer"
label={translate('resources.playlist.actions.viewOriginal')}
>
<OpenInNewOutlined />
</Button>
<Button
disabled={shouldConfirm || syncing}
onClick={() => setShouldConfirm(true)}
label={translate('resources.playlist.actions.sync')}
>
<SyncIcon />
</Button>
<Confirm
isOpen={shouldConfirm}
title="message.playlistSyncConfirmTitle"
content="message.playlistSyncConfirmBody"
translateOptions={{
name: record.name,
}}
onConfirm={handleResync}
onClose={() => setShouldConfirm(false)}
/>
</>
)}
</div>
<div>{isNotSmall && <ToggleFieldsMenu resource="playlistTrack" />}</div>
</div>

View File

@ -53,6 +53,7 @@ const PlaylistEditForm = (props) => {
<TextField source="ownerName" />
)}
<BooleanInput source="public" disabled={!isWritable(record.ownerId)} />
{record.externalSyncable && <BooleanInput source="externalSync" />}
<FormDataConsumer>
{(formDataProps) => <SyncFragment {...formDataProps} />}
</FormDataConsumer>

View File

@ -14,9 +14,13 @@ import {
useRecordContext,
BulkDeleteButton,
usePermissions,
useListContext,
CreateButton,
useTranslate,
BooleanField,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
import { useMediaQuery } from '@material-ui/core'
import { styled, Typography, useMediaQuery } from '@material-ui/core'
import {
DurationField,
List,
@ -27,6 +31,77 @@ import {
} from '../common'
import PlaylistListActions from './PlaylistListActions'
import ChangePublicStatusButton from './ChangePublicStatusButton'
import { Inbox } from '@material-ui/icons'
import config from '../config'
import { ImportButton } from './ImportButton'
const PREFIX = 'RaEmpty'
export const EmptyClasses = {
message: `${PREFIX}-message`,
icon: `${PREFIX}-icon`,
toolbar: `${PREFIX}-toolbar`,
}
const Root = styled('span', {
name: PREFIX,
overridesResolver: (props, styles) => styles.root,
})(({ theme }) => ({
flex: 1,
[`& .${EmptyClasses.message}`]: {
textAlign: 'center',
opacity: theme.palette.mode === 'light' ? 0.5 : 0.8,
margin: '0 1em',
color:
theme.palette.mode === 'light' ? 'inherit' : theme.palette.text.primary,
},
[`& .${EmptyClasses.icon}`]: {
width: '9em',
height: '9em',
},
[`& .${EmptyClasses.toolbar}`]: {
textAlign: 'center',
marginTop: '1em',
},
}))
const Empty = () => {
const translate = useTranslate()
const { resource } = useListContext()
const resourceName = translate(`resources.${resource}.forcedCaseName`, {
smart_count: 0,
_: resource,
})
const emptyMessage = translate('ra.page.empty', { name: resourceName })
const inviteMessage = translate('ra.page.invite')
return (
<Root>
<div className={EmptyClasses.message}>
<Inbox className={EmptyClasses.icon} />
<Typography variant="h4" paragraph>
{translate(`resources.${resource}.empty`, {
_: emptyMessage,
})}
</Typography>
<Typography variant="body1">
{translate(`resources.${resource}.invite`, {
_: inviteMessage,
})}
</Typography>
</div>
<div className={EmptyClasses.toolbar}>
<CreateButton variant="contained" />{' '}
{config.listenBrainzEnabled && <ImportButton />}
</div>
<div className={EmptyClasses.toolbar}></div>
</Root>
)
}
const PlaylistFilter = (props) => {
const { permissions } = usePermissions()
@ -107,6 +182,8 @@ const PlaylistList = (props) => {
<TogglePublicInput source="public" sortByOrder={'DESC'} />
),
comment: <TextField source="comment" />,
external: <BooleanField source="externalId" looseValue />,
externalSync: <BooleanField source="externalSync" />,
}),
[isDesktop, isXsmall],
)
@ -114,12 +191,13 @@ const PlaylistList = (props) => {
const columns = useSelectedFields({
resource: 'playlist',
columns: toggleableFields,
defaultOff: ['comment'],
defaultOff: ['comment', 'external', 'externalSync'],
})
return (
<List
{...props}
empty={<Empty />}
exporter={false}
filters={<PlaylistFilter />}
actions={<PlaylistListActions />}

View File

@ -7,6 +7,8 @@ import {
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { ToggleFieldsMenu } from '../common'
import { ImportButton } from './ImportButton'
import config from '../config'
const PlaylistListActions = ({ className, ...rest }) => {
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
@ -18,6 +20,8 @@ const PlaylistListActions = ({ className, ...rest }) => {
<CreateButton basePath="/playlist">
{translate('ra.action.create')}
</CreateButton>
{config.listenBrainzEnabled && <ImportButton />}
{isNotSmall && <ToggleFieldsMenu resource="playlist" />}
</TopToolbar>
)