diff --git a/server/ctrlsubsonic/handlers_bookmark.go b/server/ctrlsubsonic/handlers_bookmark.go new file mode 100644 index 0000000..8595205 --- /dev/null +++ b/server/ctrlsubsonic/handlers_bookmark.go @@ -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() +} diff --git a/server/ctrlsubsonic/handlers_common.go b/server/ctrlsubsonic/handlers_common.go index 3cdb922..10aa24e 100644 --- a/server/ctrlsubsonic/handlers_common.go +++ b/server/ctrlsubsonic/handlers_common.go @@ -153,9 +153,12 @@ func (c *Controller) ServeSavePlayQueue(r *http.Request) *spec.Response { if err != nil { return spec.NewError(10, "please provide some `id` parameters") } + // TODO: support other play queue entries other than tracks trackIDs := make([]int, 0, len(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) queue := &db.PlayQueue{UserID: user.ID} diff --git a/server/ctrlsubsonic/spec/spec.go b/server/ctrlsubsonic/spec/spec.go index 9a5536e..3fad0ab 100644 --- a/server/ctrlsubsonic/spec/spec.go +++ b/server/ctrlsubsonic/spec/spec.go @@ -44,7 +44,8 @@ type Response struct { PlayQueue *PlayQueue `xml:"playQueue" json:"playQueue,omitempty"` JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus" json:"jukeboxStatus,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 { @@ -120,6 +121,7 @@ type TracksByGenre struct { } type TrackChild struct { + ID *specid.ID `xml:"id,attr,omitempty" json:"id,omitempty"` Album string `xml:"album,attr,omitempty" json:"album,omitempty"` AlbumID *specid.ID `xml:"albumId,attr,omitempty" json:"albumId,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"` Duration int `xml:"duration,attr,omitempty" json:"duration,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"` IsVideo bool `xml:"isVideo,attr" json:"isVideo"` ParentID *specid.ID `xml:"parent,attr,omitempty" json:"parent,omitempty"` @@ -292,33 +293,51 @@ type Podcasts struct { } type PodcastChannel struct { - ID *specid.ID `xml:"id,attr" json:"id"` - URL string `xml:"url,attr" json:"url"` - Title string `xml:"title,attr" json:"title"` - Description string `xml:"description,attr" json:"description"` - CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt,omitempty"` - OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl,omitempty"` - Status string `xml:"status,attr" json:"status"` - Episode []*PodcastEpisode `xml:"episode" json:"episode,omitempty"` + ID *specid.ID `xml:"id,attr" json:"id"` + URL string `xml:"url,attr" json:"url"` + Title string `xml:"title,attr" json:"title"` + Description string `xml:"description,attr" json:"description"` + CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt,omitempty"` + OriginalImageURL string `xml:"originalImageUrl,attr" json:"originalImageUrl,omitempty"` + Status string `xml:"status,attr" json:"status"` + Episode []*PodcastEpisode `xml:"episode" json:"episode,omitempty"` } type PodcastEpisode struct { - ID *specid.ID `xml:"id,attr" json:"id"` - StreamID *specid.ID `xml:"streamId,attr" json:"streamId"` - ChannelID *specid.ID `xml:"channelId,attr" json:"channelId"` - Title string `xml:"title,attr" json:"title"` - Description string `xml:"description,attr" json:"description"` - PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"` - Status string `xml:"status,attr" json:"status"` - Parent string `xml:"parent,attr" json:"parent"` - IsDir bool `xml:"isDir,attr" json:"isDir"` - Year int `xml:"year,attr" json:"year"` - Genre string `xml:"genre,attr" json:"genre"` - CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt"` - Size int `xml:"size,attr" json:"size"` - ContentType string `xml:"contentType,attr" json:"contentType"` - Suffix string `xml:"suffix,attr" json:"suffix"` - Duration int `xml:"duration,attr" json:"duration"` - BitRate int `xml:"bitRate,attr" json:"bitrate"` - Path string `xml:"path,attr" json:"path"` + ID *specid.ID `xml:"id,attr" json:"id"` + StreamID *specid.ID `xml:"streamId,attr" json:"streamId"` + ChannelID *specid.ID `xml:"channelId,attr" json:"channelId"` + Title string `xml:"title,attr" json:"title"` + Description string `xml:"description,attr" json:"description"` + PublishDate time.Time `xml:"publishDate,attr" json:"publishDate"` + Status string `xml:"status,attr" json:"status"` + Parent string `xml:"parent,attr" json:"parent"` + IsDir bool `xml:"isDir,attr" json:"isDir"` + Year int `xml:"year,attr" json:"year"` + Genre string `xml:"genre,attr" json:"genre"` + CoverArt *specid.ID `xml:"coverArt,attr" json:"coverArt"` + Size int `xml:"size,attr" json:"size"` + ContentType string `xml:"contentType,attr" json:"contentType"` + Suffix string `xml:"suffix,attr" json:"suffix"` + Duration int `xml:"duration,attr" json:"duration"` + BitRate int `xml:"bitRate,attr" json:"bitrate"` + 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"` } diff --git a/server/db/db.go b/server/db/db.go index f1c0c96..0886c7e 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -80,6 +80,7 @@ func New(path string) (*DB, error) { migrateMultiGenre(), migrateListenBrainz(), migratePodcast(), + migrateBookmarks(), )) if err = migr.Migrate(); err != nil { return nil, fmt.Errorf("migrating to latest version: %w", err) diff --git a/server/db/migrations.go b/server/db/migrations.go index 474be48..141c51b 100644 --- a/server/db/migrations.go +++ b/server/db/migrations.go @@ -211,7 +211,6 @@ func migrateMultiGenre() gormigrate.Migration { } } - func migrateListenBrainz() gormigrate.Migration { return gormigrate.Migration{ ID: "202101081149", @@ -231,11 +230,23 @@ func migratePodcast() gormigrate.Migration { return gormigrate.Migration{ ID: "202101111537", Migrate: func(tx *gorm.DB) error { - step := tx.AutoMigrate( + return tx.AutoMigrate( Podcast{}, 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 }, } } diff --git a/server/db/model.go b/server/db/model.go index cfeaf01..fd12038 100644 --- a/server/db/model.go +++ b/server/db/model.go @@ -300,7 +300,7 @@ type Podcast struct { ImageURL string ImagePath string Error string - Episodes []*PodcastEpisode + Episodes []*PodcastEpisode } func (p *Podcast) Fullpath(podcastPath string) string { @@ -354,3 +354,15 @@ func (pe *PodcastEpisode) MIME() string { func (pe *PodcastEpisode) AudioBitrate() int { 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 +} diff --git a/server/server.go b/server/server.go index ec5fea0..11e4016 100644 --- a/server/server.go +++ b/server/server.go @@ -191,6 +191,9 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) { r.Handle("/getRandomSongs{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetRandomSongs)) r.Handle("/getSongsByGenre{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetSongsByGenre)) 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 r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeDownload)) 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("/getArtistInfo{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtistInfo)) // ** begin podcasts - if ctrl.Podcasts.PodcastBasePath != "" { - r.Handle("/getPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPodcasts)) - r.Handle("/downloadPodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDownloadPodcastEpisode)) - r.Handle("/createPodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreatePodcastChannel)) - r.Handle("/refreshPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeRefreshPodcasts)) - r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastChannel)) - r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastEpisode)) - } + r.Handle("/getPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetPodcasts)) + r.Handle("/downloadPodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDownloadPodcastEpisode)) + r.Handle("/createPodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeCreatePodcastChannel)) + r.Handle("/refreshPodcasts{_:(?:\\.view)?}", ctrl.H(ctrl.ServeRefreshPodcasts)) + r.Handle("/deletePodcastChannel{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastChannel)) + r.Handle("/deletePodcastEpisode{_:(?:\\.view)?}", ctrl.H(ctrl.ServeDeletePodcastEpisode)) // middlewares should be run for not found handler // https://github.com/gorilla/mux/issues/416 notFoundHandler := ctrl.H(ctrl.ServeNotFound)