package handler import ( "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/truewhile/MeBox/internal/service" ) func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50")) offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0")) uid := embyEffectiveUserID(c) splitOpt := func(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } return service.ItemsParams{ UserID: uid, ParentID: firstQueryValue(c, "ParentId", "parentId", "parentid"), IDs: splitOpt(firstQueryValue(c, "Ids", "ids")), SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"), IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")), Filters: splitOpt(firstQueryValue(c, "Filters", "filters")), Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"), SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"), SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"), Limit: limit, StartIndex: offset, SeasonIndex: parseEmbySeasonIndexQuery(c), } } // parseEmbySeasonIndexQuery 读取客户端请求的季序号。 // // Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex // (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号, // 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。 func parseEmbySeasonIndexQuery(c *gin.Context) *int { raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex") if raw == "" { return nil } value, err := strconv.Atoi(raw) if err != nil { // 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。 return nil } return &value } func embyFirstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func embyItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") uid := embyEffectiveUserID(c) out, err := svc.Emby.Item(c.Request.Context(), id, uid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if out == nil { embyError(c, http.StatusNotFound, "item not found") return } embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { switch strings.ToLower(c.Param("id")) { case "latest": embyLatestItemsHandler(svc)(c) case "resume": embyResumeItemsHandler(svc)(c) default: embyItemByIDHandler(svc)(c) } } } func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := embyEffectiveUserID(c) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20")) out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid := embyEffectiveUserID(c) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20")) startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0")) // ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。 parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid") out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if svc != nil && svc.Emby != nil { uid := embyEffectiveUserID(c) out, err := svc.Emby.ItemCounts(c.Request.Context(), uid) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, out) return } c.JSON(http.StatusOK, gin.H{ "MovieCount": 0, "SeriesCount": 0, "EpisodeCount": 0, "ItemCount": 0, }) } } func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "Id": c.Param("id"), "ViewType": "Poster", "SortBy": "SortName", "SortOrder": "Ascending", "IndexBy": "SortName", "RememberIndexing": false, "PrimaryImageHeight": 250, "PrimaryImageWidth": 250, "ScrollDirection": "Vertical", "ShowSidebar": true, "CustomPrefs": gin.H{ "homeexploresection": "1", "homesection0": "smalllibrarytiles", "homesection1": "resume", "homesection2": "none", "homesection3": "nextup", "homesection4": "none", "homesection5": "none", "homesection6": "none", "latestItems": "false", "landing-livetv": "false", }, }) } } func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.Status(http.StatusNoContent) } } func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { params := service.ItemsParams{ UserID: embyEffectiveUserID(c), ParentID: c.Param("id"), Limit: 500, } out, err := svc.Emby.Items(c.Request.Context(), params) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } } func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { parentID := firstQueryValue(c, "SeasonId", "seasonId") // 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤, // /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。 seasonIndex := parseEmbySeasonIndexQuery(c) if parentID == "" { parentID = c.Param("id") } else { // SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者 // 不一致时把结果过滤成空集。 seasonIndex = nil } // Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于 // TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远 // 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。 limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500")) if limit <= 0 { limit = 500 } startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0")) if startIndex < 0 { startIndex = 0 } params := service.ItemsParams{ UserID: embyEffectiveUserID(c), ParentID: parentID, IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: limit, StartIndex: startIndex, SeasonIndex: seasonIndex, } out, err := svc.Emby.Items(c.Request.Context(), params) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } embyAttachRequestTokenToMediaSources(c, out) c.JSON(http.StatusOK, out) } }