Files
MeBox/internal/handler/media_favorite.go
T
truewhile e8c710c261 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>
2026-09-03 08:50:46 +08:00

151 lines
5.2 KiB
Go

// Package handler — alias endpoints used by the Vue UI's media detail
// page that map onto the existing /favourites surface.
//
// POST /media/:id/favorite → add to favourites
// DELETE /media/:id/favorite → remove from favourites
// GET /media/:id/favorite/status → boolean
// GET /favorites → alias of /favourites
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
)
// addMediaFavoriteHandler ensures the (user, media) row exists. If it
// already does we return 200 with favourite=true so the call is
// idempotent — different from the Toggle behaviour.
func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
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
}
if err := svc.Playback.SetFavourite(c.Request.Context(), userID, mediaID, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"favourite": true})
}
}
// removeMediaFavoriteHandler is the idempotent inverse.
func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
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
}
c.JSON(http.StatusOK, gin.H{"favourite": false})
}
}
// getMediaFavoriteStatusHandler returns the current state.
func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
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})
}
}
// listFavoritesAliasHandler is the /favorites alias of /favourites.
// We reuse the existing service method.
func listFavoritesAliasHandler(svc *service.Container) gin.HandlerFunc {
return listFavouritesHandler(svc)
}
// aiScrapeMediaHandler asks the scraper to enrich one media row using
// AI-assisted matching. Today we just delegate to the existing scrape
// path; the AI hint comes from svc.AI when configured.
func aiScrapeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
options, err := scrapeOptionsFromRequest(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scrape options"})
return
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
c.JSON(http.StatusOK, refreshed)
}
}
// scrapeTestHandler validates a (provider, code) pair without touching
// the database. Useful for the "preview" workflow in the Vue UI.
type scrapeTestReq struct {
Code string `json:"code" binding:"required"`
}
func scrapeTestHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req scrapeTestReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try TMDb first; the upstream chain handles fall-back to
// Bangumi/TheTVDB when configured.
match, err := svc.TMDb.SearchMovie(c.Request.Context(), req.Code, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"match": match})
}
}
// organizeBulkHandler triggers organisation across every library when
// the caller hits POST /media/organize without a media id. It mirrors
// the upstream Vue surface.
func organizeBulkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]any, 0, len(libs))
for _, l := range libs {
resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{
Scope: service.OrganizeScopeLibrary,
Trigger: service.OrganizeTriggerManual,
TaskName: "批量整理媒体库:" + l.Name,
LibraryID: l.ID,
})
if err != nil {
out = append(out, gin.H{"library": l.Name, "error": err.Error()})
continue
}
out = append(out, gin.H{"library": l.Name, "result": resp.Result})
}
c.JSON(http.StatusOK, gin.H{"results": out})
}
}