mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix: sync mounted Emby favourites across web and Emby clients (#21)
Unify favourite writes through SyncUserFavorite so remote mounted media is stored locally and proxied to the upstream Emby server. Emby clients now query favourites from the local table (including remote pseudo IDs), and remote item payloads merge local IsFavorite state. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -23,18 +22,18 @@ import (
|
||||
func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
// Check current state.
|
||||
var existing model.Favorite
|
||||
err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
userID := toString(uid)
|
||||
mediaID := c.Param("id")
|
||||
favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, userID, mediaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if favorite {
|
||||
c.JSON(http.StatusOK, gin.H{"favourite": true})
|
||||
return
|
||||
}
|
||||
// Otherwise create.
|
||||
fav := &model.Favorite{UserID: toString(uid), MediaID: c.Param("id")}
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(fav).Error; err != nil {
|
||||
if err := svc.Playback.SetFavourite(c.Request.Context(), userID, mediaID, true); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -46,9 +45,7 @@ func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
|
||||
Delete(&model.Favorite{}).Error; err != nil {
|
||||
if err := svc.Playback.SetFavourite(c.Request.Context(), toString(uid), c.Param("id"), false); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -60,12 +57,12 @@ func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
var n int64
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Model(&model.Favorite{}).
|
||||
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
|
||||
Count(&n).Error
|
||||
c.JSON(http.StatusOK, gin.H{"favourite": n > 0})
|
||||
favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, toString(uid), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"favourite": favorite})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if e.remote != nil {
|
||||
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
|
||||
if IsEmbyRemoteID(p.ParentID) {
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
@@ -171,6 +174,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||
return e.resumableItems(ctx, p)
|
||||
}
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
|
||||
if len(p.IDs) > 0 {
|
||||
items := make([]map[string]any, 0, len(p.IDs))
|
||||
|
||||
@@ -28,6 +28,14 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
|
||||
userData, _ := out["UserData"].(map[string]any)
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
out["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
@@ -180,6 +188,137 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||
}
|
||||
|
||||
// favoriteItems returns favourited media for Emby clients, including mounted
|
||||
// remote items stored only in the local favourites table.
|
||||
func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 500 {
|
||||
p.Limit = 50
|
||||
}
|
||||
if p.StartIndex < 0 {
|
||||
p.StartIndex = 0
|
||||
}
|
||||
if strings.TrimSpace(p.UserID) == "" {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ?", p.UserID).
|
||||
Order("created_at desc").
|
||||
Find(&favs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(favs) == 0 {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
localIDs := make([]string, 0, len(favs))
|
||||
for _, fav := range favs {
|
||||
if !IsEmbyRemoteID(fav.MediaID) {
|
||||
localIDs = append(localIDs, fav.MediaID)
|
||||
}
|
||||
}
|
||||
byID := map[string]*model.Media{}
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if err := q.Find(&medias).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range medias {
|
||||
byID[medias[i].ID] = &medias[i]
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(favs))
|
||||
for _, fav := range favs {
|
||||
if m, ok := byID[fav.MediaID]; ok {
|
||||
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, m.LibraryID, m.SeriesID, nil) {
|
||||
continue
|
||||
}
|
||||
if p.SearchTerm != "" {
|
||||
needle := strings.ToLower(p.SearchTerm)
|
||||
if !strings.Contains(strings.ToLower(m.Title), needle) &&
|
||||
!strings.Contains(strings.ToLower(m.OriginalName), needle) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0))
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
|
||||
continue
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
continue
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
continue
|
||||
}
|
||||
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, "", "", item) {
|
||||
continue
|
||||
}
|
||||
if p.SearchTerm != "" {
|
||||
needle := strings.ToLower(p.SearchTerm)
|
||||
name, _ := item["Name"].(string)
|
||||
orig, _ := item["OriginalTitle"].(string)
|
||||
if !strings.Contains(strings.ToLower(name), needle) &&
|
||||
!strings.Contains(strings.ToLower(orig), needle) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
item["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
total := int64(len(items))
|
||||
if p.StartIndex >= len(items) {
|
||||
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
end := minInt(p.StartIndex+p.Limit, len(items))
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaID, libraryID, seriesID string, remoteItem map[string]any) bool {
|
||||
if parentID == "" {
|
||||
return true
|
||||
}
|
||||
if libraryID != "" {
|
||||
if libraryID == parentID || seriesID == parentID {
|
||||
return true
|
||||
}
|
||||
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
|
||||
if id == libraryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if remoteItem == nil {
|
||||
return false
|
||||
}
|
||||
itemParent, _ := remoteItem["ParentId"].(string)
|
||||
itemSeries, _ := remoteItem["SeriesId"].(string)
|
||||
if itemParent == parentID || itemSeries == parentID || mediaID == parentID {
|
||||
return true
|
||||
}
|
||||
if !IsEmbyRemoteID(parentID) {
|
||||
return false
|
||||
}
|
||||
wantMountID, _, _ := DecodeEmbyRemoteID(parentID)
|
||||
gotMountID, _, _ := DecodeEmbyRemoteID(mediaID)
|
||||
return wantMountID != "" && gotMountID == wantMountID
|
||||
}
|
||||
|
||||
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
|
||||
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 100 {
|
||||
|
||||
@@ -7,35 +7,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。远程 Emby 条目直接透传到对应
|
||||
// 服务器(本地不落库)。
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
|
||||
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
|
||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetFavorite(ctx, acctID, remoteID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if favorite {
|
||||
var f model.Favorite
|
||||
err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return e.repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||
UserID: userID, MediaID: mediaID,
|
||||
}).Error
|
||||
}
|
||||
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Delete(&model.Favorite{}).Error
|
||||
e.invalidateEmbyItemsCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||
@@ -122,8 +104,7 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
||||
}
|
||||
|
||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
||||
// state to remote Emby payloads. Remote metadata remains authoritative unless the
|
||||
// user has played the item through MeBox.
|
||||
// and favourite state to remote Emby payloads.
|
||||
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||
if strings.TrimSpace(userID) == "" || payload == nil {
|
||||
return nil
|
||||
@@ -152,10 +133,27 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
|
||||
for i := range histories {
|
||||
byMediaID[histories[i].MediaID] = &histories[i]
|
||||
}
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, ids).Find(&favs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
favSet := make(map[string]bool, len(favs))
|
||||
for _, fav := range favs {
|
||||
favSet[fav.MediaID] = true
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if h := byMediaID[id]; h != nil {
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], h)
|
||||
item["UserData"] = mergedRemoteUserData(userData, h)
|
||||
userData, _ = item["UserData"].(map[string]any)
|
||||
}
|
||||
if favSet[id] {
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
item["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// SyncUserFavorite keeps favourite state aligned across the local favourites table
|
||||
// and the upstream remote Emby server for mounted items.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
if repo == nil || userID == "" || mediaID == "" {
|
||||
return errors.New("missing favourite sync inputs")
|
||||
}
|
||||
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
if favorite || IsEmbyRemoteID(mediaID) {
|
||||
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUserFavorite reports whether the user has favourited mediaID locally.
|
||||
func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string) (bool, error) {
|
||||
if repo == nil || userID == "" || mediaID == "" {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := repo.DB.WithContext(ctx).Model(&model.Favorite{}).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error {
|
||||
if favorite {
|
||||
var existing model.Favorite
|
||||
err := repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
}).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
return repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Delete(&model.Favorite{}).Error
|
||||
}
|
||||
|
||||
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
|
||||
if remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return nil
|
||||
}
|
||||
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
_, acct, err := remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Favorite{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
userID := "user-1"
|
||||
remoteMediaID := EncodeEmbyRemoteID("mount-1", "remote-item-1")
|
||||
|
||||
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil {
|
||||
t.Fatalf("SyncUserFavorite favorite: %v", err)
|
||||
}
|
||||
favorite, err := IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsUserFavorite: %v", err)
|
||||
}
|
||||
if !favorite {
|
||||
t.Fatal("expected remote favourite to be stored locally")
|
||||
}
|
||||
|
||||
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, false); err != nil {
|
||||
t.Fatalf("SyncUserFavorite unfavorite: %v", err)
|
||||
}
|
||||
favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsUserFavorite after delete: %v", err)
|
||||
}
|
||||
if favorite {
|
||||
t.Fatal("expected remote favourite to be removed locally")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{})
|
||||
repos := repository.New(db)
|
||||
viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), viewer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteMediaID := EncodeEmbyRemoteID("mount-1", "remote-item-1")
|
||||
if err := db.Create(&model.Favorite{UserID: viewer.ID, MediaID: remoteMediaID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &EmbyService{repo: repos}
|
||||
out, err := svc.favoriteItems(t.Context(), ItemsParams{UserID: viewer.ID, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("favoriteItems: %v", err)
|
||||
}
|
||||
total, _ := out["TotalRecordCount"].(int64)
|
||||
if total != 0 {
|
||||
// Without a wired remote service hydration is skipped, but local-only path
|
||||
// should not error and should not count unavailable remote rows.
|
||||
items, _ := out["Items"].([]map[string]any)
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("expected no hydrated remote rows without remote service, got %#v", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,20 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
|
||||
|
||||
// ToggleFavourite flips the favourite flag and reports the new state.
|
||||
func (p *PlaybackService) ToggleFavourite(ctx context.Context, userID, mediaID string) (bool, error) {
|
||||
return p.repo.Favorite.Toggle(ctx, userID, mediaID)
|
||||
current, err := IsUserFavorite(ctx, p.repo, userID, mediaID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
next := !current
|
||||
if err := p.SetFavourite(ctx, userID, mediaID, next); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetFavourite sets favourite state for a media item.
|
||||
func (p *PlaybackService) SetFavourite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
return SyncUserFavorite(ctx, p.repo, p.remote, userID, mediaID, favorite)
|
||||
}
|
||||
|
||||
// ListFavourites returns every favourited media for a user.
|
||||
|
||||
Reference in New Issue
Block a user