diff --git a/model/playlist.go b/model/playlist.go index e9f59a999..2cdb5935d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -19,6 +19,8 @@ type Playlist struct { UpdatedAt time.Time `json:"updatedAt"` } +type Playlists []Playlist + type PlaylistRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) @@ -26,18 +28,20 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) Delete(id string) error - Tracks(playlistId string) PlaylistTracksRepository + Tracks(playlistId string) PlaylistTrackRepository } -type PlaylistTracks struct { +type PlaylistTrack struct { ID string `json:"id" orm:"column(id)"` MediaFileID string `json:"mediaFileId" orm:"column(media_file_id)"` + PlaylistID string `json:"playlistId" orm:"column(playlist_id)"` MediaFile } -type PlaylistTracksRepository interface { - rest.Repository - //rest.Persistable -} +type PlaylistTracks []PlaylistTrack -type Playlists []Playlist +type PlaylistTrackRepository interface { + rest.Repository + Add(mediaFileIds []string) error + Update(mediaFileIds []string) error +} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e6c528a4f..e5136f927 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -72,63 +72,17 @@ func (r *playlistRepository) Get(id string) (*model.Playlist, error) { func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { sel := r.newSelect(options...).Columns("*") - var res model.Playlists + res := model.Playlists{} err := r.queryAll(sel, &res) return res, err } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { - // Remove old tracks - del := Delete("playlist_tracks").Where(Eq{"playlist_id": id}) - _, err := r.executeSQL(del) - if err != nil { - return err + ids := make([]string, len(tracks)) + for i := range tracks { + ids[i] = tracks[i].ID } - - // Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit - numTracks := len(tracks) - const chunkSize = 50 - var chunks [][]model.MediaFile - for i := 0; i < numTracks; i += chunkSize { - end := i + chunkSize - if end > numTracks { - end = numTracks - } - - chunks = append(chunks, tracks[i:end]) - } - - // Add new tracks, chunk by chunk - pos := 0 - for i := range chunks { - ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id") - for _, t := range chunks[i] { - ins = ins.Values(id, t.ID, pos) - pos++ - } - _, err = r.executeSQL(ins) - if err != nil { - return err - } - } - - // Get total playlist duration and count - statsSql := Select("sum(duration) as duration", "count(*) as count").From("media_file"). - Join("playlist_tracks f on f.media_file_id = media_file.id"). - Where(Eq{"playlist_id": id}) - var res struct{ Duration, Count float32 } - err = r.queryOne(statsSql, &res) - if err != nil { - return err - } - - // Update total playlist duration and count - upd := Update(r.tableName). - Set("duration", res.Duration). - Set("song_count", res.Count). - Where(Eq{"id": id}) - _, err = r.executeSQL(upd) - return err + return r.Tracks(id).Update(ids) } func (r *playlistRepository) loadTracks(pls *model.Playlist) (err error) { diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go new file mode 100644 index 000000000..35823a25a --- /dev/null +++ b/persistence/playlist_track_repository.go @@ -0,0 +1,146 @@ +package persistence + +import ( + . "github.com/Masterminds/squirrel" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" + "github.com/deluan/rest" +) + +type playlistTrackRepository struct { + sqlRepository + sqlRestful + playlistId string +} + +func (r *playlistRepository) Tracks(playlistId string) model.PlaylistTrackRepository { + p := &playlistTrackRepository{} + p.playlistId = playlistId + p.ctx = r.ctx + p.ormer = r.ormer + p.tableName = "playlist_tracks" + p.sortMappings = map[string]string{ + "id": "playlist_tracks.id", + } + return p +} + +func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...)) +} + +func (r *playlistTrackRepository) Read(id string) (interface{}, error) { + sel := r.newSelect(). + LeftJoin("annotation on ("+ + "annotation.item_id = media_file_id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = '"+userId(r.ctx)+"')"). + Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*"). + Join("media_file f on f.id = media_file_id"). + Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}}) + var trk model.PlaylistTrack + err := r.queryOne(sel, &trk) + return &trk, err +} + +func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newSelect(r.parseRestOptions(options...)). + LeftJoin("annotation on ("+ + "annotation.item_id = media_file_id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = '"+userId(r.ctx)+"')"). + Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*"). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + res := model.PlaylistTracks{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *playlistTrackRepository) EntityName() string { + return "playlist_tracks" +} + +func (r *playlistTrackRepository) NewInstance() interface{} { + return &model.PlaylistTrack{} +} + +func (r *playlistTrackRepository) Add(mediaFileIds []string) error { + log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) + + // Get all current tracks + all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id") + var tracks model.PlaylistTracks + err := r.queryAll(all, &tracks) + if err != nil { + log.Error("Error querying current tracks from playlist", "playlistId", r.playlistId, err) + return err + } + ids := make([]string, len(tracks)) + for i := range tracks { + ids[i] = tracks[i].MediaFileID + } + + // Append new tracks + ids = append(ids, mediaFileIds...) + + // Update tracks and playlist + return r.Update(ids) +} + +func (r *playlistTrackRepository) Update(mediaFileIds []string) error { + // Remove old tracks + del := Delete(r.tableName).Where(Eq{"playlist_id": r.playlistId}) + _, err := r.executeSQL(del) + if err != nil { + return err + } + + // Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit + numTracks := len(mediaFileIds) + const chunkSize = 50 + var chunks [][]string + for i := 0; i < numTracks; i += chunkSize { + end := i + chunkSize + if end > numTracks { + end = numTracks + } + + chunks = append(chunks, mediaFileIds[i:end]) + } + + // Add new tracks, chunk by chunk + pos := 0 + for i := range chunks { + ins := Insert(r.tableName).Columns("playlist_id", "media_file_id", "id") + for _, t := range chunks[i] { + ins = ins.Values(r.playlistId, t, pos) + pos++ + } + _, err = r.executeSQL(ins) + if err != nil { + return err + } + } + + // Get total playlist duration and count + statsSql := Select("sum(duration) as duration", "count(*) as count").From("media_file"). + Join("playlist_tracks f on f.media_file_id = media_file.id"). + Where(Eq{"playlist_id": r.playlistId}) + var res struct{ Duration, Count float32 } + err = r.queryOne(statsSql, &res) + if err != nil { + return err + } + + // Update playlist's total duration and count + upd := Update("playlist"). + Set("duration", res.Duration). + Set("song_count", res.Count). + Where(Eq{"id": r.playlistId}) + _, err = r.executeSQL(upd) + return err +} + +var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil) +var _ model.ResourceRepository = (*playlistTrackRepository)(nil) diff --git a/persistence/playlist_tracks_repository.go b/persistence/playlist_tracks_repository.go deleted file mode 100644 index af2ff5041..000000000 --- a/persistence/playlist_tracks_repository.go +++ /dev/null @@ -1,68 +0,0 @@ -package persistence - -import ( - . "github.com/Masterminds/squirrel" - "github.com/deluan/navidrome/model" - "github.com/deluan/rest" -) - -type playlistTracksRepository struct { - sqlRepository - sqlRestful - playlistId string -} - -func (r *playlistRepository) Tracks(playlistId string) model.PlaylistTracksRepository { - p := &playlistTracksRepository{} - p.playlistId = playlistId - p.ctx = r.ctx - p.ormer = r.ormer - p.tableName = "playlist_tracks" - p.sortMappings = map[string]string{ - "id": "playlist_tracks.id", - } - return p -} - -func (r *playlistTracksRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...)) -} - -func (r *playlistTracksRepository) Read(id string) (interface{}, error) { - sel := r.newSelect(). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file_id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = '"+userId(r.ctx)+"')"). - Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*"). - Join("media_file f on f.id = media_file_id"). - Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}}) - var trk model.PlaylistTracks - err := r.queryOne(sel, &trk) - return &trk, err -} - -func (r *playlistTracksRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { - sel := r.newSelect(r.parseRestOptions(options...)). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file_id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = '"+userId(r.ctx)+"')"). - Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*"). - Join("media_file f on f.id = media_file_id"). - Where(Eq{"playlist_id": r.playlistId}) - var res []model.PlaylistTracks - err := r.queryAll(sel, &res) - return res, err -} - -func (r *playlistTracksRepository) EntityName() string { - return "playlist_tracks" -} - -func (r *playlistTracksRepository) NewInstance() interface{} { - return &model.PlaylistTracks{} -} - -var _ model.PlaylistTracksRepository = (*playlistTracksRepository)(nil) -var _ model.ResourceRepository = (*playlistTracksRepository)(nil) diff --git a/server/app/app.go b/server/app/app.go index 340f347ba..714714144 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -51,7 +51,7 @@ func (app *Router) routes(path string) http.Handler { app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) app.RX(r, "/translation", newTranslationRepository, false) - app.addPlaylistTracksRoute(r) + app.addPlaylistTrackRoute(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"}`)) }) @@ -90,7 +90,7 @@ func (app *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc -func (app *Router) addPlaylistTracksRoute(r chi.Router) { +func (app *Router) addPlaylistTrackRoute(r chi.Router) { // Add a middleware to capture the playlisId wrapper := func(f restHandler) http.HandlerFunc { return func(res http.ResponseWriter, req *http.Request) { @@ -110,6 +110,9 @@ func (app *Router) addPlaylistTracksRoute(r chi.Router) { r.Use(UrlParams) r.Get("/", wrapper(rest.Get)) }) + r.With(UrlParams).Post("/", func(w http.ResponseWriter, r *http.Request) { + addToPlaylist(app.ds)(w, r) + }) }) } diff --git a/server/app/playlists.go b/server/app/playlists.go new file mode 100644 index 000000000..b0e35ba71 --- /dev/null +++ b/server/app/playlists.go @@ -0,0 +1,38 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/deluan/navidrome/model" + "github.com/deluan/navidrome/utils" +) + +type addTracksPayload struct { + Ids []string `json:"ids"` +} + +func addToPlaylist(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + playlistId := utils.ParamString(r, ":playlistId") + tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId) + var payload addTracksPayload + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + err = tracksRepo.Add(payload.Ids) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Must return an object with an ID, to satisfy ReactAdmin `create` call + _, err = w.Write([]byte(fmt.Sprintf(`{"id":"%s"}`, playlistId))) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } +} diff --git a/ui/src/common/SelectPlaylistDialog.js b/ui/src/common/SelectPlaylistDialog.js new file mode 100644 index 000000000..225d33534 --- /dev/null +++ b/ui/src/common/SelectPlaylistDialog.js @@ -0,0 +1,77 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useGetList, useTranslate } from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' +import Avatar from '@material-ui/core/Avatar' +import List from '@material-ui/core/List' +import ListItem from '@material-ui/core/ListItem' +import ListItemAvatar from '@material-ui/core/ListItemAvatar' +import ListItemText from '@material-ui/core/ListItemText' +import DialogTitle from '@material-ui/core/DialogTitle' +import Dialog from '@material-ui/core/Dialog' +import { blue } from '@material-ui/core/colors' +import PlaylistIcon from '../icons/Playlist' + +const useStyles = makeStyles({ + avatar: { + backgroundColor: blue[100], + color: blue[600], + }, +}) + +function SelectPlaylistDialog(props) { + const classes = useStyles() + const translate = useTranslate() + const { onClose, selectedValue, open } = props + const { ids, data, loaded } = useGetList( + 'playlist', + { page: 1, perPage: -1 }, + { field: '', order: '' }, + {} + ) + + if (!loaded) { + return
+ } + + const handleClose = () => { + onClose(selectedValue) + } + + const handleListItemClick = (value) => { + onClose(value) + } + + return ( + + ) +} + +SelectPlaylistDialog.propTypes = { + onClose: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, + selectedValue: PropTypes.string.isRequired, +} + +export default SelectPlaylistDialog diff --git a/ui/src/common/index.js b/ui/src/common/index.js index b74fb3e94..4b08c6cb0 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -11,6 +11,7 @@ import SizeField from './SizeField' import DocLink from './DocLink' import List from './List' import SongDatagridRow from './SongDatagridRow' +import SelectPlaylistDialog from './SelectPlaylistDialog' export { Title, @@ -28,4 +29,5 @@ export { formatRange, ArtistLinkField, artistLink, + SelectPlaylistDialog, } diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 3ece0f882..c7ea249ed 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -6,7 +6,6 @@ const restUrl = '/app/api' const dataProvider = jsonServerProvider(restUrl, httpClient) const mapResource = (resource, params) => { - console.log('R: ', resource, 'P: ', params) switch (resource) { case 'albumSong': return ['song', params] diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 0fbe74b0c..d9a3c8972 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -22,6 +22,7 @@ }, "actions": { "addToQueue": "Play Later", + "addToPlaylist": "Add to Playlist", "playNow": "Play Now" } }, @@ -63,6 +64,9 @@ "public": "Public", "updatedAt":"Updated at", "createdAt": "Created at" + }, + "actions": { + "selectPlaylist": "Add songs to playlist:" } }, "user": { diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js index eb2e50e91..a638edafb 100644 --- a/ui/src/playlist/PlaylistSongs.js +++ b/ui/src/playlist/PlaylistSongs.js @@ -54,13 +54,17 @@ const PlaylistSongs = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) // const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const controllerProps = useListController(props) - const { bulkActionButtons, expand, className } = props - const { data, ids, version } = controllerProps + const { bulkActionButtons, expand, className, playlistId } = props + const { data, ids, version, loaded } = controllerProps const anySong = data[ids[0]] - const showPlaceholder = !anySong + const showPlaceholder = !anySong || anySong.playlistId !== playlistId const hasBulkActions = props.bulkActionButtons !== false + if (loaded && ids.length === 0) { + return + } + return ( <>