diff --git a/db/db.go b/db/db.go index b45350b..8328f54 100644 --- a/db/db.go +++ b/db/db.go @@ -45,7 +45,6 @@ func New(path string) (*DB, error) { model.Play{}, model.Album{}, model.Playlist{}, - model.PlaylistItem{}, ) // TODO: don't log if user already exists db.FirstOrCreate(&model.User{}, model.User{ diff --git a/model/model.go b/model/model.go index 8a4bfb9..e4e0be6 100644 --- a/model/model.go +++ b/model/model.go @@ -3,6 +3,8 @@ package model import ( "path" + "strconv" + "strings" "time" "senan.xyz/g/gonic/mime" @@ -117,14 +119,28 @@ type Playlist struct { UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"` Name string Comment string - TrackCount int `sql:"-"` + TrackCount int + Items string } -type PlaylistItem struct { - ID int `gorm:"primary_key"` - CreatedAt time.Time - Playlist Playlist - PlaylistID int `sql:"default: null; type:int REFERENCES playlists(id) ON DELETE CASCADE"` - Track Track - TrackID int `sql:"default: null; type:int REFERENCES tracks(id) ON DELETE CASCADE"` +func (p *Playlist) GetItems() []int { + if len(p.Items) == 0 { + return []int{} + } + parts := strings.Split(p.Items, ",") + ret := make([]int, 0, len(parts)) + for _, p := range parts { + i, _ := strconv.Atoi(p) + ret = append(ret, i) + } + return ret +} + +func (p *Playlist) SetItems(items []int) { + strs := make([]string, 0, len(items)) + for _, p := range items { + strs = append(strs, strconv.Itoa(p)) + } + p.TrackCount = len(items) + p.Items = strings.Join(strs, ",") } diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index 1eb7ad3..df7449a 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -22,13 +22,11 @@ func (c *Controller) ServeLogin(r *http.Request) *Response { func (c *Controller) ServeHome(r *http.Request) *Response { data := &templateData{} - // - // stats box + // ** begin stats box c.DB.Table("artists").Count(&data.ArtistCount) c.DB.Table("albums").Count(&data.AlbumCount) c.DB.Table("tracks").Count(&data.TrackCount) - // - // lastfm box + // ** begin lastfm box scheme := firstExisting( "http", // fallback r.Header.Get("X-Forwarded-Proto"), @@ -42,11 +40,9 @@ func (c *Controller) ServeHome(r *http.Request) *Response { ) data.RequestRoot = fmt.Sprintf("%s://%s", scheme, host) data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key") - // - // users box + // ** begin users box c.DB.Find(&data.AllUsers) - // - // recent folders box + // ** begin recent folders box c.DB. Where("tag_artist_id IS NOT NULL"). Order("modified_at DESC"). @@ -57,17 +53,10 @@ func (c *Controller) ServeHome(r *http.Request) *Response { i, _ := strconv.ParseInt(tStr, 10, 64) data.LastScanTime = time.Unix(i, 0) } - // - // playlists box + // ** begin playlists box user := r.Context().Value(CtxUser).(*model.User) c.DB. - Select("*, count(items.id) as track_count"). - Joins(` - LEFT JOIN playlist_items items - ON items.playlist_id = playlists.id - `). Where("user_id = ?", user.ID). - Group("playlists.id"). Limit(20). Find(&data.Playlists) // diff --git a/server/ctrladmin/playlist.go b/server/ctrladmin/playlist.go index 7855c01..1e742d7 100644 --- a/server/ctrladmin/playlist.go +++ b/server/ctrladmin/playlist.go @@ -12,11 +12,11 @@ import ( "senan.xyz/g/gonic/model" ) -func playlistParseLine(c *Controller, playlistID int, path string) error { +func playlistParseLine(c *Controller, path string) (int, error) { if strings.HasPrefix(path, "#") || strings.TrimSpace(path) == "" { - return nil + return 0, nil } - track := &model.Track{} + var track model.Track query := c.DB.Raw(` SELECT tracks.id FROM TRACKS JOIN albums ON tracks.album_id = albums.id @@ -25,15 +25,12 @@ func playlistParseLine(c *Controller, playlistID int, path string) error { err := query.First(&track).Error switch { case gorm.IsRecordNotFoundError(err): - return fmt.Errorf("couldn't match track %q", path) + return 0, fmt.Errorf("couldn't match track %q", path) case err != nil: - return errors.Wrap(err, "while matching") + return 0, errors.Wrap(err, "while matching") + default: + return track.ID, nil } - c.DB.Create(&model.PlaylistItem{ - PlaylistID: playlistID, - TrackID: track.ID, - }) - return nil } func playlistParseUpload(c *Controller, userID int, header *multipart.FileHeader) ([]string, bool) { @@ -49,23 +46,28 @@ func playlistParseUpload(c *Controller, userID int, header *multipart.FileHeader if !(contentType == "audio/x-mpegurl" || contentType == "application/octet-stream") { return []string{fmt.Sprintf("invalid content-type %q", contentType)}, false } + var trackIDs []int + var errors []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + trackID, err := playlistParseLine(c, scanner.Text()) + if err != nil { + // trim length of error to not overflow cookie flash + errors = append(errors, fmt.Sprintf("%.100s", err.Error())) + } + if trackID != 0 { + trackIDs = append(trackIDs, trackID) + } + } + if err := scanner.Err(); err != nil { + return []string{fmt.Sprintf("iterating playlist file: %v", err)}, true + } playlist := &model.Playlist{} c.DB.FirstOrCreate(playlist, model.Playlist{ Name: playlistName, UserID: userID, }) - c.DB.Delete(&model.PlaylistItem{}, "playlist_id = ?", playlist.ID) - var errors []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - path := scanner.Text() - if err := playlistParseLine(c, playlist.ID, path); err != nil { - // trim length of error to not overflow cookie flash - errors = append(errors, fmt.Sprintf("%.100s", err.Error())) - } - } - if err := scanner.Err(); err != nil { - return []string{fmt.Sprintf("scanning line of playlist: %v", err)}, true - } + playlist.SetItems(trackIDs) + c.DB.Save(playlist) return errors, true } diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index e0879b7..7534ca5 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -3,6 +3,7 @@ package ctrlsubsonic import ( "log" "net/http" + "sort" "strconv" "time" "unicode" @@ -24,55 +25,6 @@ func lowerUDecOrHash(in string) string { return string(lower) } -type playlistOpValues struct { - c *Controller - r *http.Request - user *model.User - id int -} - -func playlistDelete(opts playlistOpValues) { - indexes, ok := opts.r.URL.Query()["songIndexToRemove"] - if !ok { - return - } - trackIDs := []int{} - opts.c.DB. - Order("created_at"). - Model(&model.PlaylistItem{}). - Where("playlist_id = ?", opts.id). - Pluck("track_id", &trackIDs) - for _, indexStr := range indexes { - i, err := strconv.Atoi(indexStr) - if err != nil { - continue - } - opts.c.DB.Delete(&model.PlaylistItem{}, - "track_id = ?", trackIDs[i]) - } -} - -func playlistAdd(opts playlistOpValues) { - var toAdd []string - for _, val := range []string{"songId", "songIdToAdd"} { - var ok bool - toAdd, ok = opts.r.URL.Query()[val] - if ok { - break - } - } - for _, trackIDStr := range toAdd { - trackID, err := strconv.Atoi(trackIDStr) - if err != nil { - continue - } - opts.c.DB.Save(&model.PlaylistItem{ - PlaylistID: opts.id, - TrackID: trackID, - }) - } -} - func (c *Controller) ServeGetLicence(r *http.Request) *spec.Response { sub := spec.NewResponse() sub.Licence = &spec.Licence{ @@ -170,9 +122,7 @@ func (c *Controller) ServeNotFound(r *http.Request) *spec.Response { func (c *Controller) ServeGetPlaylists(r *http.Request) *spec.Response { user := r.Context().Value(CtxUser).(*model.User) var playlists []*model.Playlist - c.DB. - Where("user_id = ?", user.ID). - Find(&playlists) + c.DB.Where("user_id = ?", user.ID).Find(&playlists) sub := spec.NewResponse() sub.Playlists = &spec.Playlists{ List: make([]*spec.Playlist, len(playlists)), @@ -180,6 +130,7 @@ func (c *Controller) ServeGetPlaylists(r *http.Request) *spec.Response { for i, playlist := range playlists { sub.Playlists.List[i] = spec.NewPlaylist(playlist) sub.Playlists.List[i].Owner = user.Name + sub.Playlists.List[i].SongCount = playlist.TrackCount } return sub } @@ -198,50 +149,58 @@ func (c *Controller) ServeGetPlaylist(r *http.Request) *spec.Response { if gorm.IsRecordNotFoundError(err) { return spec.NewError(70, "playlist with id `%d` not found", playlistID) } - var tracks []*model.Track - c.DB. - Joins(` - JOIN playlist_items - ON playlist_items.track_id = tracks.id - `). - Where("playlist_items.playlist_id = ?", playlistID). - Group("tracks.id"). - Order("playlist_items.created_at"). - Preload("Album"). - Find(&tracks) user := r.Context().Value(CtxUser).(*model.User) sub := spec.NewResponse() sub.Playlist = spec.NewPlaylist(&playlist) sub.Playlist.Owner = user.Name - sub.Playlist.List = make([]*spec.TrackChild, len(tracks)) - for i, track := range tracks { - sub.Playlist.List[i] = spec.NewTCTrackByFolder(track, track.Album) + sub.Playlist.SongCount = playlist.TrackCount + trackIDs := playlist.GetItems() + sub.Playlist.List = make([]*spec.TrackChild, len(trackIDs)) + for i, id := range trackIDs { + track := model.Track{} + c.DB. + Where("id = ?", id). + Preload("Album"). + Find(&track) + sub.Playlist.List[i] = spec.NewTCTrackByFolder(&track, track.Album) } return sub } func (c *Controller) ServeUpdatePlaylist(r *http.Request) *spec.Response { - params := r.Context().Value(CtxParams).(params.Params) user := r.Context().Value(CtxUser).(*model.User) + params := r.Context().Value(CtxParams).(params.Params) var playlistID int - for _, key := range []string{"id", "playlistId"} { - if val, err := params.GetInt(key); err != nil { - playlistID = val - } + if p := params.GetFirstList("id", "playlistId"); p != nil { + playlistID, _ = strconv.Atoi(p[0]) } - playlist := model.Playlist{ID: playlistID} - c.DB.Where(playlist).First(&playlist) + // playlistID may be 0 from above. in that case we get a new playlist + // as intended + playlist := &model.Playlist{ID: playlistID} + c.DB.Where(playlist).First(playlist) + // ** begin update meta info playlist.UserID = user.ID - if val := r.URL.Query().Get("name"); val != "" { + if val := params.Get("name"); val != "" { playlist.Name = val } - if val := r.URL.Query().Get("comment"); val != "" { + if val := params.Get("comment"); val != "" { playlist.Comment = val } - c.DB.Save(&playlist) - opts := playlistOpValues{c, r, user, playlist.ID} - playlistDelete(opts) - playlistAdd(opts) + trackIDs := playlist.GetItems() + // ** begin delete items + if p := params.GetFirstListInt("songIndexToRemove"); p != nil { + sort.Sort(sort.Reverse(sort.IntSlice(trackIDs))) + for _, i := range p { + trackIDs = append(trackIDs[:i], trackIDs[i+1:]...) + } + } + // ** begin add items + if p := params.GetFirstListInt("songId", "songIdToAdd"); p != nil { + trackIDs = append(trackIDs, p...) + } + // + playlist.SetItems(trackIDs) + c.DB.Save(playlist) return spec.NewResponse() } diff --git a/server/ctrlsubsonic/params/params.go b/server/ctrlsubsonic/params/params.go index 66220fd..e786152 100644 --- a/server/ctrlsubsonic/params/params.go +++ b/server/ctrlsubsonic/params/params.go @@ -55,3 +55,25 @@ func (p Params) GetIntOr(key string, or int) int { } return val } + +func (p Params) GetFirstList(keys ...string) []string { + for _, key := range keys { + if v, ok := p.values[key]; ok && len(v) > 0 { + return v + } + } + return nil +} + +func (p Params) GetFirstListInt(keys ...string) []int { + v := p.GetFirstList(keys...) + if v == nil { + return nil + } + ret := make([]int, 0, len(v)) + for _, p := range v { + i, _ := strconv.Atoi(p) + ret = append(ret, i) + } + return ret +} diff --git a/server/ctrlsubsonic/spec/construct_by_folder.go b/server/ctrlsubsonic/spec/construct_by_folder.go index 23d31c1..285196c 100644 --- a/server/ctrlsubsonic/spec/construct_by_folder.go +++ b/server/ctrlsubsonic/spec/construct_by_folder.go @@ -8,12 +8,13 @@ import ( func NewAlbumByFolder(f *model.Album) *Album { return &Album{ - Artist: f.Parent.RightPath, - CoverID: f.ID, - ID: f.ID, - IsDir: true, - ParentID: f.ParentID, - Title: f.RightPath, + Artist: f.Parent.RightPath, + CoverID: f.ID, + ID: f.ID, + IsDir: true, + ParentID: f.ParentID, + Title: f.RightPath, + TrackCount: f.ChildCount, } } diff --git a/server/ctrlsubsonic/spec/construct_by_tags.go b/server/ctrlsubsonic/spec/construct_by_tags.go index 3e04f7d..1eaa43e 100644 --- a/server/ctrlsubsonic/spec/construct_by_tags.go +++ b/server/ctrlsubsonic/spec/construct_by_tags.go @@ -8,9 +8,10 @@ import ( func NewAlbumByTags(a *model.Album, artist *model.Artist) *Album { ret := &Album{ - Created: a.ModifiedAt, - ID: a.ID, - Name: a.TagTitle, + Created: a.ModifiedAt, + ID: a.ID, + Name: a.TagTitle, + TrackCount: a.ChildCount, } if a.Cover != "" { ret.CoverID = a.ID