Merge 789b83d76e
into 92a98cd558
This commit is contained in:
commit
c151b718d1
30
cmd/root.go
30
cmd/root.go
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
|
@ -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"}`))
|
||||
|
|
|
@ -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"}]}}
|
|
@ -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":[]}}]}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" />,
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
import ExternalPlaylistCreate from './ExternalPlaylistCreate'
|
||||
|
||||
export default {
|
||||
create: ExternalPlaylistCreate,
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
)
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue