diff --git a/engine/ratings.go b/engine/ratings.go index 717d1e22e..0807602cb 100644 --- a/engine/ratings.go +++ b/engine/ratings.go @@ -26,9 +26,9 @@ func (r ratings) SetRating(ctx context.Context, id string, rating int) error { return err } if exist { - return r.ds.Annotation(ctx).SetRating(rating, model.AlbumItemType, id) + return r.ds.Album(ctx).SetRating(rating, id) } - return r.ds.Annotation(ctx).SetRating(rating, model.MediaItemType, id) + return r.ds.MediaFile(ctx).SetRating(rating, id) } func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error { @@ -44,7 +44,7 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error { return err } if exist { - err = tx.Annotation(ctx).SetStar(star, model.AlbumItemType, ids...) + err = tx.Album(ctx).SetStar(star, ids...) if err != nil { return err } @@ -55,13 +55,13 @@ func (r ratings) SetStar(ctx context.Context, star bool, ids ...string) error { return err } if exist { - err = tx.Annotation(ctx).SetStar(star, model.ArtistItemType, ids...) + err = tx.Artist(ctx).SetStar(star, ids...) if err != nil { return err } continue } - err = tx.Annotation(ctx).SetStar(star, model.MediaItemType, ids...) + err = tx.MediaFile(ctx).SetStar(star, ids...) if err != nil { return err } diff --git a/engine/scrobbler.go b/engine/scrobbler.go index 1d73e7ded..931156203 100644 --- a/engine/scrobbler.go +++ b/engine/scrobbler.go @@ -31,11 +31,15 @@ func (s *scrobbler) Register(ctx context.Context, playerId int, trackId string, if err != nil { return err } - err = s.ds.Annotation(ctx).IncPlayCount(model.MediaItemType, trackId, playTime) + err = s.ds.MediaFile(ctx).IncPlayCount(trackId, playTime) if err != nil { return err } - err = s.ds.Annotation(ctx).IncPlayCount(model.AlbumItemType, mf.AlbumID, playTime) + err = s.ds.Album(ctx).IncPlayCount(mf.AlbumID, playTime) + if err != nil { + return err + } + err = s.ds.Artist(ctx).IncPlayCount(mf.ArtistID, playTime) return err }) return mf, err diff --git a/model/album.go b/model/album.go index 81c376102..fd4027b0e 100644 --- a/model/album.go +++ b/model/album.go @@ -40,4 +40,5 @@ type AlbumRepository interface { Search(q string, offset int, size int) (Albums, error) Refresh(ids ...string) error PurgeEmpty() error + AnnotatedRepository } diff --git a/model/annotation.go b/model/annotation.go index d76c0eef7..2293934da 100644 --- a/model/annotation.go +++ b/model/annotation.go @@ -2,29 +2,8 @@ package model import "time" -const ( - ArtistItemType = "artist" - AlbumItemType = "album" - MediaItemType = "media_file" -) - -type Annotation struct { - AnnID string `json:"annID" orm:"pk;column(ann_id)"` - UserID string `json:"userID" orm:"pk;column(user_id)"` - ItemID string `json:"itemID" orm:"pk;column(item_id)"` - ItemType string `json:"itemType"` - PlayCount int `json:"playCount"` - PlayDate time.Time `json:"playDate"` - Rating int `json:"rating"` - Starred bool `json:"starred"` - StarredAt time.Time `json:"starredAt"` -} - -type AnnotationMap map[string]Annotation - -type AnnotationRepository interface { - Delete(itemType string, itemID ...string) error - IncPlayCount(itemType, itemID string, ts time.Time) error - SetStar(starred bool, itemType string, ids ...string) error - SetRating(rating int, itemType, itemID string) error +type AnnotatedRepository interface { + IncPlayCount(itemID string, ts time.Time) error + SetStar(starred bool, itemIDs ...string) error + SetRating(rating int, itemID string) error } diff --git a/model/artist.go b/model/artist.go index 77421ae81..ea5040ace 100644 --- a/model/artist.go +++ b/model/artist.go @@ -33,4 +33,5 @@ type ArtistRepository interface { Refresh(ids ...string) error GetIndex() (ArtistIndexes, error) PurgeEmpty() error + AnnotatedRepository } diff --git a/model/datastore.go b/model/datastore.go index b38fe1757..96cf1ed9d 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -28,7 +28,6 @@ type DataStore interface { Playlist(ctx context.Context) PlaylistRepository Property(ctx context.Context) PropertyRepository User(ctx context.Context) UserRepository - Annotation(ctx context.Context) AnnotationRepository Resource(ctx context.Context, model interface{}) ResourceRepository diff --git a/model/mediafile.go b/model/mediafile.go index 1410d69be..29fb05844 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -28,11 +28,11 @@ type MediaFile struct { UpdatedAt time.Time `json:"updatedAt"` // Annotations - PlayCount int `json:"-" orm:"-"` - PlayDate time.Time `json:"-" orm:"-"` - Rating int `json:"-" orm:"-"` - Starred bool `json:"-" orm:"-"` - StarredAt time.Time `json:"-" orm:"-"` + PlayCount int `json:"-" orm:"-"` + PlayDate time.Time `json:"-" orm:"-"` + Rating int `json:"-" orm:"-"` + Starred bool `json:"-" orm:"-"` + StarredAt time.Time `json:"-" orm:"-"` } func (mf *MediaFile) ContentType() string { @@ -53,4 +53,6 @@ type MediaFileRepository interface { Search(q string, offset int, size int) (MediaFiles, error) Delete(id string) error DeleteByPath(path string) error + + AnnotatedRepository } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 2d8074708..9d027a76b 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -42,7 +42,7 @@ func (r *albumRepository) Put(a *model.Album) error { } func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { - return r.newSelectWithAnnotation(model.AlbumItemType, "id", options...).Columns("*") + return r.newSelectWithAnnotation("id", options...).Columns("*") } func (r *albumRepository) Get(id string) (*model.Album, error) { diff --git a/persistence/annotation.go b/persistence/annotation.go new file mode 100644 index 000000000..7a2607d3b --- /dev/null +++ b/persistence/annotation.go @@ -0,0 +1,97 @@ +package persistence + +import ( + "time" + + . "github.com/Masterminds/squirrel" + "github.com/astaxie/beego/orm" + "github.com/deluan/navidrome/model" + "github.com/google/uuid" +) + +type annotation struct { + AnnID string `json:"annID" orm:"pk;column(ann_id)"` + UserID string `json:"userID" orm:"pk;column(user_id)"` + ItemID string `json:"itemID" orm:"pk;column(item_id)"` + ItemType string `json:"itemType"` + PlayCount int `json:"playCount"` + PlayDate time.Time `json:"playDate"` + Rating int `json:"rating"` + Starred bool `json:"starred"` + StarredAt time.Time `json:"starredAt"` +} + +const annotationTable = "annotation" + +func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). + LeftJoin("annotation on ("+ + "annotation.item_id = "+idField+ + " AND annotation.item_type = '"+r.tableName+"'"+ + " AND annotation.user_id = '"+userId(r.ctx)+"')"). + Columns("starred", "starred_at", "play_count", "play_date", "rating") +} + +func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error { + upd := Update(annotationTable).Where(r.annId(itemIDs...)) + for f, v := range values { + upd = upd.Set(f, v) + } + c, err := r.executeSQL(upd) + if c == 0 || err == orm.ErrNoRows { + for _, itemID := range itemIDs { + id, _ := uuid.NewRandom() + values["ann_id"] = id.String() + values["user_id"] = userId(r.ctx) + values["item_type"] = r.tableName + values["item_id"] = itemID + ins := Insert(annotationTable).SetMap(values) + _, err = r.executeSQL(ins) + if err != nil { + return err + } + } + } + return err +} + +func (r sqlRepository) annId(itemID ...string) And { + return And{ + Eq{"user_id": userId(r.ctx)}, + Eq{"item_type": r.tableName}, + Eq{"item_id": itemID}, + } +} + +func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { + upd := Update(annotationTable).Where(r.annId(itemID)). + Set("play_count", Expr("play_count+1")). + Set("play_date", ts) + c, err := r.executeSQL(upd) + + if c == 0 || err == orm.ErrNoRows { + id, _ := uuid.NewRandom() + values := map[string]interface{}{} + values["ann_id"] = id.String() + values["user_id"] = userId(r.ctx) + values["item_type"] = r.tableName + values["item_id"] = itemID + values["play_count"] = 1 + values["play_date"] = ts + ins := Insert(annotationTable).SetMap(values) + _, err = r.executeSQL(ins) + if err != nil { + return err + } + } + return err +} + +func (r sqlRepository) SetStar(starred bool, ids ...string) error { + starredAt := time.Now() + return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...) +} + +func (r sqlRepository) SetRating(rating int, itemID string) error { + return r.annUpsert(map[string]interface{}{"rating": rating}, itemID) +} diff --git a/persistence/annotation_repository.go b/persistence/annotation_repository.go deleted file mode 100644 index e60acc79a..000000000 --- a/persistence/annotation_repository.go +++ /dev/null @@ -1,91 +0,0 @@ -package persistence - -import ( - "context" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/astaxie/beego/orm" - "github.com/deluan/navidrome/model" - "github.com/google/uuid" -) - -type annotationRepository struct { - sqlRepository -} - -func NewAnnotationRepository(ctx context.Context, o orm.Ormer) model.AnnotationRepository { - r := &annotationRepository{} - r.ctx = ctx - r.ormer = o - r.tableName = "annotation" - return r -} - -func (r *annotationRepository) upsert(values map[string]interface{}, itemType string, itemIDs ...string) error { - upd := Update(r.tableName).Where(r.getId(itemType, itemIDs...)) - for f, v := range values { - upd = upd.Set(f, v) - } - c, err := r.executeSQL(upd) - if c == 0 || err == orm.ErrNoRows { - for _, itemID := range itemIDs { - id, _ := uuid.NewRandom() - values["ann_id"] = id.String() - values["user_id"] = userId(r.ctx) - values["item_type"] = itemType - values["item_id"] = itemID - ins := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(ins) - if err != nil { - return err - } - } - } - return err -} - -func (r *annotationRepository) IncPlayCount(itemType, itemID string, ts time.Time) error { - upd := Update(r.tableName).Where(r.getId(itemType, itemID)). - Set("play_count", Expr("play_count+1")). - Set("play_date", ts) - c, err := r.executeSQL(upd) - - if c == 0 || err == orm.ErrNoRows { - id, _ := uuid.NewRandom() - values := map[string]interface{}{} - values["ann_id"] = id.String() - values["user_id"] = userId(r.ctx) - values["item_type"] = itemType - values["item_id"] = itemID - values["play_count"] = 1 - values["play_date"] = ts - ins := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(ins) - if err != nil { - return err - } - } - return err -} - -func (r *annotationRepository) getId(itemType string, itemID ...string) And { - return And{ - Eq{"user_id": userId(r.ctx)}, - Eq{"item_type": itemType}, - Eq{"item_id": itemID}, - } -} - -func (r *annotationRepository) SetStar(starred bool, itemType string, ids ...string) error { - starredAt := time.Now() - return r.upsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, itemType, ids...) -} - -func (r *annotationRepository) SetRating(rating int, itemType, itemID string) error { - return r.upsert(map[string]interface{}{"rating": rating}, itemType, itemID) -} - -func (r *annotationRepository) Delete(itemType string, itemIDs ...string) error { - return r.delete(r.getId(itemType, itemIDs...)) -} diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 826203d25..861123140 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -30,7 +30,7 @@ func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepositor } func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { - return r.newSelectWithAnnotation(model.ArtistItemType, "id", options...).Columns("*") + return r.newSelectWithAnnotation("id", options...).Columns("*") } func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index dfdbb6f34..28169c239 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -41,7 +41,7 @@ func (r mediaFileRepository) Put(m *model.MediaFile) error { } func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { - return r.newSelectWithAnnotation(model.MediaItemType, "media_file.id", options...).Columns("media_file.*") + return r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*") } func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) { diff --git a/persistence/mock_persistence.go b/persistence/mock_persistence.go index f2069dfd1..91b3bd7fd 100644 --- a/persistence/mock_persistence.go +++ b/persistence/mock_persistence.go @@ -61,10 +61,6 @@ func (db *MockDataStore) User(context.Context) model.UserRepository { return db.MockedUser } -func (db *MockDataStore) Annotation(context.Context) model.AnnotationRepository { - return struct{ model.AnnotationRepository }{} -} - func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error { return block(db) } diff --git a/persistence/persistence.go b/persistence/persistence.go index 2f24a2f41..65ccadcf4 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -69,10 +69,6 @@ func (db *NewSQLStore) User(ctx context.Context) model.UserRepository { return NewUserRepository(ctx, db.getOrmer()) } -func (db *NewSQLStore) Annotation(ctx context.Context) model.AnnotationRepository { - return NewAnnotationRepository(ctx, db.getOrmer()) -} - func (db *NewSQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 231400989..c137e4ef9 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -1,11 +1,11 @@ package persistence import ( + "context" "os" "strings" "testing" - "github.com/Masterminds/squirrel" "github.com/astaxie/beego/orm" "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/db" @@ -30,41 +30,38 @@ func TestPersistence(t *testing.T) { RunSpecs(t, "Persistence Suite") } -var artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1} -var artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, Starred: true} -var testArtists = model.Artists{ - artistKraftwerk, - artistBeatles, -} +var ( + artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1} + artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2} + testArtists = model.Artists{ + artistKraftwerk, + artistBeatles, + } +) -var albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967} -var albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969} -var albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, Starred: true} -var testAlbums = model.Albums{ - albumSgtPeppers, - albumAbbeyRoad, - albumRadioactivity, -} +var ( + albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, Year: 1967} + albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, Year: 1969} + albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2} + testAlbums = model.Albums{ + albumSgtPeppers, + albumAbbeyRoad, + albumRadioactivity, + } +) -var songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")} -var songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), Starred: true} -var songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")} -var songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")} -var testSongs = model.MediaFiles{ - songDayInALife, - songComeTogether, - songRadioactivity, - songAntenna, -} - -var annArtistBeatles = model.Annotation{AnnID: "3", UserID: "userid", ItemType: model.ArtistItemType, ItemID: artistBeatles.ID, Starred: true} -var annAlbumRadioactivity = model.Annotation{AnnID: "1", UserID: "userid", ItemType: model.AlbumItemType, ItemID: albumRadioactivity.ID, Starred: true} -var annSongComeTogether = model.Annotation{AnnID: "2", UserID: "userid", ItemType: model.MediaItemType, ItemID: songComeTogether.ID, Starred: true} -var testAnnotations = []model.Annotation{ - annArtistBeatles, - annAlbumRadioactivity, - annSongComeTogether, -} +var ( + songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")} + songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3")} + songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")} + songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")} + testSongs = model.MediaFiles{ + songDayInALife, + songComeTogether, + songRadioactivity, + songAntenna, + } +) var ( plsBest = model.Playlist{ @@ -85,9 +82,11 @@ func P(path string) string { } var _ = Describe("Initialize test DB", func() { + + // TODO Load this data setup from file(s) BeforeSuite(func() { o := orm.NewOrm() - ctx := log.NewContext(nil) + ctx := context.WithValue(log.NewContext(nil), "user", &model.User{ID: "userid"}) mr := NewMediaFileRepository(ctx, o) for _, s := range testSongs { err := mr.Put(&s) @@ -112,19 +111,6 @@ var _ = Describe("Initialize test DB", func() { } } - for _, a := range testAnnotations { - values, _ := toSqlArgs(a) - ins := squirrel.Insert("annotation").SetMap(values) - query, args, err := ins.ToSql() - if err != nil { - panic(err) - } - _, err = o.Raw(query, args...).Exec() - if err != nil { - panic(err) - } - } - pr := NewPlaylistRepository(ctx, o) for _, pls := range testPlaylists { err := pr.Put(&pls) @@ -132,5 +118,31 @@ var _ = Describe("Initialize test DB", func() { panic(err) } } + + // Prepare annotations + if err := arr.SetStar(true, artistBeatles.ID); err != nil { + panic(err) + } + ar, _ := arr.Get(artistBeatles.ID) + artistBeatles.Starred = true + artistBeatles.StarredAt = ar.StarredAt + testArtists[1] = artistBeatles + + if err := alr.SetStar(true, albumRadioactivity.ID); err != nil { + panic(err) + } + al, _ := alr.Get(albumRadioactivity.ID) + albumRadioactivity.Starred = true + albumRadioactivity.StarredAt = al.StarredAt + testAlbums[2] = albumRadioactivity + + if err := mr.SetStar(true, songComeTogether.ID); err != nil { + panic(err) + } + mf, _ := mr.Get(songComeTogether.ID) + songComeTogether.Starred = true + songComeTogether.StarredAt = mf.StarredAt + testSongs[1] = songComeTogether + }) }) diff --git a/persistence/sql_repository.go b/persistence/sql_repository.go index 7e06ff67a..84b76faf4 100644 --- a/persistence/sql_repository.go +++ b/persistence/sql_repository.go @@ -30,15 +30,6 @@ func userId(ctx context.Context) string { return usr.ID } -func (r sqlRepository) newSelectWithAnnotation(itemType, idField string, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...). - LeftJoin("annotation on ("+ - "annotation.item_id = "+idField+ - " AND annotation.item_type = '"+itemType+"'"+ - " AND annotation.user_id = '"+userId(r.ctx)+"')"). - Columns("starred", "starred_at", "play_count", "play_date", "rating") -} - func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { sq := Select().From(r.tableName) sq = r.applyOptions(sq, options...)