support subsonic bookmarks

This commit is contained in:
sentriz
2021-02-03 22:51:16 +00:00
parent f027d5a486
commit 7a1d57a43c
7 changed files with 168 additions and 42 deletions

View File

@@ -0,0 +1,79 @@
package ctrlsubsonic
import (
"net/http"
"github.com/jinzhu/gorm"
"go.senan.xyz/gonic/server/ctrlsubsonic/params"
"go.senan.xyz/gonic/server/ctrlsubsonic/spec"
"go.senan.xyz/gonic/server/ctrlsubsonic/specid"
"go.senan.xyz/gonic/server/db"
)
func (c *Controller) ServeGetBookmarks(r *http.Request) *spec.Response {
user := r.Context().Value(CtxUser).(*db.User)
bookmarks := []*db.Bookmark{}
err := c.DB.
Where("user_id=?", user.ID).
Find(&bookmarks).
Error
if gorm.IsRecordNotFoundError(err) {
return spec.NewResponse()
}
sub := spec.NewResponse()
sub.Bookmarks = &spec.Bookmarks{
List: []*spec.Bookmark{},
}
for _, bookmark := range bookmarks {
specid := &specid.ID{
Type: specid.IDT(bookmark.EntryIDType),
Value: bookmark.EntryID,
}
entries := []*spec.BookmarkEntry{{
ID: specid,
Type: bookmark.EntryIDType,
}}
sub.Bookmarks.List = append(sub.Bookmarks.List, &spec.Bookmark{
Username: user.Name,
Position: bookmark.Position,
Comment: bookmark.Comment,
Created: bookmark.CreatedAt,
Changed: bookmark.UpdatedAt,
Entries: entries,
})
}
return sub
}
func (c *Controller) ServeCreateBookmark(r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
user := r.Context().Value(CtxUser).(*db.User)
id, err := params.GetID("id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
bookmark := &db.Bookmark{}
c.DB.FirstOrCreate(bookmark, db.Bookmark{
UserID: user.ID,
EntryIDType: string(id.Type),
EntryID: id.Value,
})
bookmark.Comment = params.GetOr("comment", "")
bookmark.Position = params.GetOrInt("position", 0)
c.DB.Save(bookmark)
return spec.NewResponse()
}
func (c *Controller) ServeDeleteBookmark(r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
user := r.Context().Value(CtxUser).(*db.User)
id, err := params.GetID("id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
c.DB.
Where("user_id=? AND entry_id_type=? AND entry_id=?", user.ID, id.Type, id.Value).
Delete(&db.Bookmark{})
return spec.NewResponse()
}

View File

@@ -153,9 +153,12 @@ func (c *Controller) ServeSavePlayQueue(r *http.Request) *spec.Response {
if err != nil { if err != nil {
return spec.NewError(10, "please provide some `id` parameters") return spec.NewError(10, "please provide some `id` parameters")
} }
// TODO: support other play queue entries other than tracks
trackIDs := make([]int, 0, len(tracks)) trackIDs := make([]int, 0, len(tracks))
for _, id := range tracks { for _, id := range tracks {
trackIDs = append(trackIDs, id.Value) if id.Type == specid.Track {
trackIDs = append(trackIDs, id.Value)
}
} }
user := r.Context().Value(CtxUser).(*db.User) user := r.Context().Value(CtxUser).(*db.User)
queue := &db.PlayQueue{UserID: user.ID} queue := &db.PlayQueue{UserID: user.ID}

View File

@@ -44,7 +44,8 @@ type Response struct {
PlayQueue *PlayQueue `xml:"playQueue" json:"playQueue,omitempty"` PlayQueue *PlayQueue `xml:"playQueue" json:"playQueue,omitempty"`
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus" json:"jukeboxStatus,omitempty"` JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus" json:"jukeboxStatus,omitempty"`
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist" json:"jukeboxPlaylist,omitempty"` JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist" json:"jukeboxPlaylist,omitempty"`
Podcasts *Podcasts `xml:"podcasts" json:"podcasts,omitempty"` Podcasts *Podcasts `xml:"podcasts" json:"podcasts,omitempty"`
Bookmarks *Bookmarks `xml:"bookmarks" json:"bookmarks,omitempty"`
} }
func NewResponse() *Response { func NewResponse() *Response {
@@ -120,6 +121,7 @@ type TracksByGenre struct {
} }
type TrackChild struct { type TrackChild struct {
ID *specid.ID `xml:"id,attr,omitempty" json:"id,omitempty"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"` Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
AlbumID *specid.ID `xml:"albumId,attr,omitempty" json:"albumId,omitempty"` AlbumID *specid.ID `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
@@ -130,7 +132,6 @@ type TrackChild struct {
CreatedAt time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` CreatedAt time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
ID *specid.ID `xml:"id,attr,omitempty" json:"id,omitempty"`
IsDir bool `xml:"isDir,attr" json:"isDir"` IsDir bool `xml:"isDir,attr" json:"isDir"`
IsVideo bool `xml:"isVideo,attr" json:"isVideo"` IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
ParentID *specid.ID `xml:"parent,attr,omitempty" json:"parent,omitempty"` ParentID *specid.ID `xml:"parent,attr,omitempty" json:"parent,omitempty"`
@@ -292,33 +293,51 @@ type Podcasts struct {
} }
type PodcastChannel struct { type PodcastChannel struct {
ID *specid.ID `xml:"id,attr" json:"id"` ID *specid.ID `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"` URL string `xml:"url,attr" json:"url"`
Title string `xml:"title,attr" json:"title"` Title string `xml:"title,attr" json:"title"`
Description string `xml:"description,attr" json:"description"` Description string `xml:"description,attr" json:"description"`
CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt,omitempty"` CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt,omitempty"`
OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl,omitempty"` OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl,omitempty"`
Status string `xml:"status,attr" json:"status"` Status string `xml:"status,attr" json:"status"`
Episode []*PodcastEpisode `xml:"episode" json:"episode,omitempty"` Episode []*PodcastEpisode `xml:"episode" json:"episode,omitempty"`
} }
type PodcastEpisode struct { type PodcastEpisode struct {
ID *specid.ID `xml:"id,attr" json:"id"` ID *specid.ID `xml:"id,attr" json:"id"`
StreamID *specid.ID `xml:"streamId,attr" json:"streamId"` StreamID *specid.ID `xml:"streamId,attr" json:"streamId"`
ChannelID *specid.ID `xml:"channelId,attr" json:"channelId"` ChannelID *specid.ID `xml:"channelId,attr" json:"channelId"`
Title string `xml:"title,attr" json:"title"` Title string `xml:"title,attr" json:"title"`
Description string `xml:"description,attr" json:"description"` Description string `xml:"description,attr" json:"description"`
PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"` PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"`
Status string `xml:"status,attr" json:"status"` Status string `xml:"status,attr" json:"status"`
Parent string `xml:"parent,attr" json:"parent"` Parent string `xml:"parent,attr" json:"parent"`
IsDir bool `xml:"isDir,attr" json:"isDir"` IsDir bool `xml:"isDir,attr" json:"isDir"`
Year int `xml:"year,attr" json:"year"` Year int `xml:"year,attr" json:"year"`
Genre string `xml:"genre,attr" json:"genre"` Genre string `xml:"genre,attr" json:"genre"`
CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt"` CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt"`
Size int `xml:"size,attr" json:"size"` Size int `xml:"size,attr" json:"size"`
ContentType string `xml:"contentType,attr" json:"contentType"` ContentType string `xml:"contentType,attr" json:"contentType"`
Suffix string `xml:"suffix,attr" json:"suffix"` Suffix string `xml:"suffix,attr" json:"suffix"`
Duration int `xml:"duration,attr" json:"duration"` Duration int `xml:"duration,attr" json:"duration"`
BitRate int `xml:"bitRate,attr" json:"bitrate"` BitRate int `xml:"bitRate,attr" json:"bitrate"`
Path string `xml:"path,attr" json:"path"` Path string `xml:"path,attr" json:"path"`
}
type Bookmarks struct {
List []*Bookmark `xml:"bookmark" json:"bookmark"`
}
type Bookmark struct {
Entries []*BookmarkEntry `xml:"entry,omitempty" json:"entry,omitempty"`
Username string `xml:"username,attr" json:"username"`
Position int `xml:"position,attr" json:"position"`
Comment string `xml:"comment,attr" json:"comment"`
Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"`
}
type BookmarkEntry struct {
ID *specid.ID `xml:"id,attr" json:"id"`
Type string `xml:"type,attr" json:"type"`
} }

View File

@@ -80,6 +80,7 @@ func New(path string) (*DB, error) {
migrateMultiGenre(), migrateMultiGenre(),
migrateListenBrainz(), migrateListenBrainz(),
migratePodcast(), migratePodcast(),
migrateBookmarks(),
)) ))
if err = migr.Migrate(); err != nil { if err = migr.Migrate(); err != nil {
return nil, fmt.Errorf("migrating to latest version: %w", err) return nil, fmt.Errorf("migrating to latest version: %w", err)

View File

@@ -211,7 +211,6 @@ func migrateMultiGenre() gormigrate.Migration {
} }
} }
func migrateListenBrainz() gormigrate.Migration { func migrateListenBrainz() gormigrate.Migration {
return gormigrate.Migration{ return gormigrate.Migration{
ID: "202101081149", ID: "202101081149",
@@ -231,11 +230,23 @@ func migratePodcast() gormigrate.Migration {
return gormigrate.Migration{ return gormigrate.Migration{
ID: "202101111537", ID: "202101111537",
Migrate: func(tx *gorm.DB) error { Migrate: func(tx *gorm.DB) error {
step := tx.AutoMigrate( return tx.AutoMigrate(
Podcast{}, Podcast{},
PodcastEpisode{}, PodcastEpisode{},
) ).
return step.Error Error
},
}
}
func migrateBookmarks() gormigrate.Migration {
return gormigrate.Migration{
ID: "202102032210",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
Bookmark{},
).
Error
}, },
} }
} }

View File

@@ -300,7 +300,7 @@ type Podcast struct {
ImageURL string ImageURL string
ImagePath string ImagePath string
Error string Error string
Episodes []*PodcastEpisode Episodes []*PodcastEpisode
} }
func (p *Podcast) Fullpath(podcastPath string) string { func (p *Podcast) Fullpath(podcastPath string) string {
@@ -354,3 +354,15 @@ func (pe *PodcastEpisode) MIME() string {
func (pe *PodcastEpisode) AudioBitrate() int { func (pe *PodcastEpisode) AudioBitrate() int {
return pe.Bitrate return pe.Bitrate
} }
type Bookmark struct {
ID int `gorm:"primary_key"`
User *User
UserID int `sql:"default: null; type:int REFERENCES users(id) ON DELETE CASCADE"`
Position int
Comment string
EntryIDType string
EntryID int
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@@ -191,6 +191,9 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) {
r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs)) r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs))
r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre)) r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre))
r.Handle("/jukeboxControl{_:(?:\\.view)?}", ctrl.H(ctrl.ServeJukebox)) r.Handle("/jukeboxControl{_:(?:\\.view)?}", ctrl.H(ctrl.ServeJukebox))
r.Handle("/getBookmarks{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetBookmarks))
r.Handle("/createBookmark{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreateBookmark))
r.Handle("/deleteBookmark{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeleteBookmark))
// ** begin raw // ** begin raw
r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload)) r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload))
r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt)) r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt))
@@ -210,14 +213,12 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) {
r.Handle("/getGenres{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetGenres)) r.Handle("/getGenres{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetGenres))
r.Handle("/getArtistInfo{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtistInfo)) r.Handle("/getArtistInfo{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtistInfo))
// ** begin podcasts // ** begin podcasts
if ctrl.Podcasts.PodcastBasePath != "" { r.Handle("/getPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPodcasts))
r.Handle("/getPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPodcasts)) r.Handle("/downloadPodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDownloadPodcastEpisode))
r.Handle("/downloadPodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDownloadPodcastEpisode)) r.Handle("/createPodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreatePodcastChannel))
r.Handle("/createPodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreatePodcastChannel)) r.Handle("/refreshPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeRefreshPodcasts))
r.Handle("/refreshPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeRefreshPodcasts)) r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastChannel))
r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastChannel)) r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastEpisode))
r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastEpisode))
}
// middlewares should be run for not found handler // middlewares should be run for not found handler
// https://github.com/gorilla/mux/issues/416 // https://github.com/gorilla/mux/issues/416
notFoundHandler := ctrl.H(ctrl.ServeNotFound) notFoundHandler := ctrl.H(ctrl.ServeNotFound)