diff --git a/cmd/server/router.go b/cmd/server/router.go index cef84eb..a073a8b 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -173,6 +173,11 @@ func isFrontendLibraryRoute(path string) bool { if strings.Contains(id, "/") { return false } + // 远程 Emby 挂载库的伪装 ID(embyremote~account~remote)也是前端库路由, + // 需要交给 SPA 而非当作 Emby API 路径 404。 + if strings.HasPrefix(id, "embyremote~") { + return true + } if len(id) != 36 { return false } diff --git a/internal/handler/media.go b/internal/handler/media.go index 12bdec7..1ff61e2 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -25,9 +25,32 @@ type createLibraryReq struct { CreatePerSubfolder bool `json:"create_per_subfolder"` } +// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库 +// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。 +type webLibraryPayload struct { + model.Library + IsRemoteEmby bool `json:"is_remote_emby,omitempty"` + RemoteSource string `json:"remote_source,omitempty"` + Total int64 `json:"total,omitempty"` + Cards []service.SeriesCard `json:"cards,omitempty"` +} + +// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目, +// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。 +func remoteLibraryItemTypes(collectionType string) string { + switch collectionType { + case "movies": + return "Movie" + case "tvshows": + return "Series" + } + return "" +} + func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - libs, err := svc.Media.ListLibraries(c.Request.Context()) + ctx := c.Request.Context() + libs, err := svc.Media.ListLibraries(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -35,39 +58,81 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") if !includeHidden { - libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs) + libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs) visibility := mediaVisibilityForRequest(c, svc) filtered := libs[:0] for _, lib := range libs { - if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { + if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) { filtered = append(filtered, lib) } } libs = filtered } withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true" + limit := 10 if withPreview { - limit, _ := strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10"))) + limit, _ = strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10"))) if limit <= 0 { limit = 10 } else if limit > 100 { limit = 100 } - previews, err := svc.Media.ListLibrariesWithPreview(c.Request.Context(), libs, mediaVisibilityForRequest(c, svc), limit) + } + out := make([]webLibraryPayload, 0, len(libs)+8) + if withPreview { + previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, previews) - return + for _, p := range previews { + out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards}) + } + } else { + for _, l := range libs { + out = append(out, webLibraryPayload{Library: l}) + } } - c.JSON(http.StatusOK, libs) + // 远程 Emby 挂载库追加在本地库之后。 + if svc.EmbyRemote != nil { + if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { + for _, v := range views { + wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} + if withPreview { + if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil { + itemTypes := remoteLibraryItemTypes(v.CollectionType) + if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, v.RemoteID, itemTypes, 0, 1); err == nil { + wl.Total = total + } + if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, acct, v.RemoteID, limit); err == nil { + wl.Cards = cards + } + } + } + out = append(out, wl) + } + } + } + c.JSON(http.StatusOK, out) } } func getLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id")) + ctx := c.Request.Context() + id := c.Param("id") + // 远程 Emby 挂载库详情。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + acctID, remoteID, _ := service.DecodeEmbyRemoteID(id) + view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID) + if err != nil || view == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName}) + return + } + lib, err := svc.Repo.Library.FindByID(ctx, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -79,14 +144,14 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") if !includeHidden { - libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib}) - if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { + libs := service.FilterDisplayCloudLibraries(ctx, svc.Repo, []model.Library{*lib}) + if len(libs) == 0 || !service.LibraryVisibleForUser(ctx, svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - c.JSON(http.StatusOK, libs[0]) + c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]}) } else { - c.JSON(http.StatusOK, lib) + c.JSON(http.StatusOK, webLibraryPayload{Library: *lib}) } } } @@ -232,8 +297,37 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { func listMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + ctx := c.Request.Context() page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + // 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + acctID, remoteID, _ := service.DecodeEmbyRemoteID(id) + acct := svc.EmbyRemote.AccountByID(ctx, acctID) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + itemTypes := "" + if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID); err == nil && view != nil { + itemTypes = remoteLibraryItemTypes(view.CollectionType) + } + items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, remoteID, itemTypes, (page-1)*size, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if items == nil { + items = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "page_size": size, + }) + return + } groupVersions := c.DefaultQuery("group_versions", "1") != "0" if !groupVersions { items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc)) @@ -271,7 +365,33 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { func getMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + ctx := c.Request.Context() + id := c.Param("id") + // 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + acctID, remoteID, _ := service.DecodeEmbyRemoteID(id) + acct := svc.EmbyRemote.AccountByID(ctx, acctID) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, acct, remoteID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if m == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, m) + return + } + m, err := svc.Media.GetMedia(ctx, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -365,7 +485,25 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc { func streamHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + ctx := c.Request.Context() + id := c.Param("id") + // 远程 Emby 条目:302 直连远程流(网页播放器 video 标签跟随重定向)。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + acctID, remoteID, _ := service.DecodeEmbyRemoteID(id) + acct := svc.EmbyRemote.AccountByID(ctx, acctID) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.Redirect(http.StatusFound, target) + return + } + m, err := svc.Media.GetMedia(ctx, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return diff --git a/internal/handler/series.go b/internal/handler/series.go index 7b7ada2..c1e6f61 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -65,7 +65,49 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc { func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { libID := c.Param("id") - if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil { + ctx := c.Request.Context() + // 远程剧集库:远程 Series 映射为系列卡片。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) { + acctID, remoteID, _ := service.DecodeEmbyRemoteID(libID) + acct := svc.EmbyRemote.AccountByID(ctx, acctID) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, acct, remoteID) + if err != nil { + writeInternalOrCanceled(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500")) + if page < 1 { + page = 1 + } + if size <= 0 || size > 1000 { + size = 500 + } + start := (page - 1) * size + if start > len(cards) { + start = len(cards) + } + end := start + size + if end > len(cards) { + end = len(cards) + } + pageItems := cards[start:end] + if pageItems == nil { + pageItems = []service.SeriesCard{} + } + c.JSON(http.StatusOK, gin.H{ + "items": pageItems, + "total": len(cards), + "page": page, + "page_size": size, + }) + return + } + if lib, err := svc.Repo.Library.FindByID(ctx, libID); err == nil && lib != nil { if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -114,7 +156,27 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "key is required"}) return } - if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil { + ctx := c.Request.Context() + // 远程系列 key(伪装系列 ID):转发远程该系列全部剧集。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(key) { + acctID, remoteSeriesID, _ := service.DecodeEmbyRemoteID(key) + acct := svc.EmbyRemote.AccountByID(ctx, acctID) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + items, err := svc.EmbyRemote.RemoteEpisodes(ctx, acct, remoteSeriesID) + if err != nil { + writeInternalOrCanceled(c, err) + return + } + if items == nil { + items = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) + return + } + if lib, err := svc.Repo.Library.FindByID(ctx, libID); err == nil && lib != nil { if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -136,7 +198,27 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } - items, err := svc.Media.ListMediaEpisodes(c.Request.Context(), id, mediaVisibilityForRequest(c, svc)) + ctx := c.Request.Context() + // 远程条目:单集→同系列集列表;系列/季/文件夹→子集;电影→自身单条。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + acctID, remoteID, _ := service.DecodeEmbyRemoteID(id) + acct := svc.EmbyRemote.AccountByID(ctx, acctID) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + items, err := svc.EmbyRemote.RemoteEpisodes(ctx, acct, remoteID) + if err != nil { + writeInternalOrCanceled(c, err) + return + } + if items == nil { + items = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) + return + } + items, err := svc.Media.ListMediaEpisodes(ctx, id, mediaVisibilityForRequest(c, svc)) if err != nil { writeInternalOrCanceled(c, err) return diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go new file mode 100644 index 0000000..1f6a55a --- /dev/null +++ b/internal/service/emby_remote_web.go @@ -0,0 +1,497 @@ +// 网页端远程 Emby 库映射。 +// +// 网页端(React UI)的媒体库/媒体浏览走项目自有 REST API(/api/libraries、 +// /api/libraries/:id/media、/api/media/:id 等),数据结构为 model.Library / +// model.Media / SeriesCard。远程 Emby 挂载的数据不落库,因此这里把远程 +// Emby 的 JSON item 映射为与本地完全一致的结构,让网页端无感知地浏览 +// 远程库;播放统一走 /api/stream/{伪装ID}(302 到远程 Emby 原地址)。 +package service + +import ( + "context" + "net/url" + "sort" + "strconv" + "strings" + + "go.uber.org/zap" + + "github.com/ShukeBta/MMTL/internal/model" +) + +// RemoteLibraryView 是一个网页端可见的远程媒体库(对应远程 Emby 一个 View)。 +type RemoteLibraryView struct { + Library model.Library + AccountID string + RemoteID string + CollectionType string + AccountName string +} + +// RemoteLibraries 把所有启用远程账号的 View 映射为网页媒体库列表。 +func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) { + accounts, err := r.ListAccounts(ctx) + if err != nil || len(accounts) == 0 { + return nil, err + } + out := make([]RemoteLibraryView, 0, len(accounts)*2) + for i := range accounts { + acct := &accounts[i] + cfg, cfgErr := r.configOf(acct) + if cfgErr != nil { + continue + } + views, viewErr := r.RemoteViews(ctx, acct) + if viewErr != nil { + if r.log != nil { + r.log.Warn("web remote emby views failed", + zap.String("account", acct.Name), zap.Error(viewErr)) + } + continue + } + for _, v := range views { + lib := r.mapRemoteViewToLibrary(acct, cfg, v) + if lib == nil { + continue + } + out = append(out, RemoteLibraryView{ + Library: *lib, + AccountID: acct.ID, + RemoteID: remoteItemString(v, "Id"), + CollectionType: remoteItemString(v, "CollectionType"), + AccountName: acct.Name, + }) + } + } + return out, nil +} + +// RemoteLibraryByID 按伪装 ID 查远程库视图(详情接口用)。 +func (r *EmbyRemoteService) RemoteLibraryByID(ctx context.Context, accountID, remoteID string) (*RemoteLibraryView, error) { + views, err := r.RemoteLibraries(ctx) + if err != nil { + return nil, err + } + for _, v := range views { + if v.AccountID == accountID && v.RemoteID == remoteID { + cp := v + return &cp, nil + } + } + return nil, nil +} + +// mapRemoteViewToLibrary 把远程 View item 映射为网页库结构。 +func (r *EmbyRemoteService) mapRemoteViewToLibrary(acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) *model.Library { + remoteID := remoteItemString(item, "Id") + if remoteID == "" { + return nil + } + name := remoteItemString(item, "Name") + if name == "" { + name = acct.Name + } + libType := "movie" + switch remoteItemString(item, "CollectionType") { + case "tvshows": + libType = "tv" + case "music": + libType = "music" + } + return &model.Library{ + Base: model.Base{ID: EncodeEmbyRemoteID(acct.ID, remoteID)}, + Name: acct.Name + " · " + name, + Type: libType, + CoverURL: r.remoteItemImageURL(cfg, remoteID, "Primary"), + Enabled: true, + SortOrder: 1000, // 远程库排在本地库之后 + } +} + +// MapRemoteItemToMedia 把远程 Emby item JSON 映射为本地 Media 结构。 +// poster/backdrop 填远程图片绝对地址(前端经 /api/img 同源代理拉取)。 +func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) model.Media { + remoteID := remoteItemString(item, "Id") + // 条目可能已被 RewriteEmbyRemoteIDs 伪装(图片/嵌套 ID 需要原始远程 ID)。 + if _, rid, ok := DecodeEmbyRemoteID(remoteID); ok { + remoteID = rid + } + seriesID := remoteItemString(item, "SeriesId") + if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok { + seriesID = rid + } + media := model.Media{ + Base: model.Base{ID: EncodeEmbyRemoteID(acct.ID, remoteID)}, + Title: remoteItemString(item, "Name"), + OriginalName: remoteItemString(item, "OriginalTitle"), + Overview: remoteItemString(item, "Overview"), + Year: remoteItemInt(item, "ProductionYear"), + Rating: float32(remoteItemFloat(item, "CommunityRating")), + Path: remoteItemString(item, "Path"), + PosterURL: r.remoteItemImageURL(cfg, remoteID, "Primary"), + BackdropURL: r.remoteItemImageURL(cfg, remoteID, "Backdrop"), + Genres: remoteItemGenres(item), + ScrapeStatus: "done", + } + if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 { + media.DurationSec = int(ticks / 10_000_000) + } + if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok { + media.ReleaseDate = date.Format("2006-01-02") + } + if providerIDs, ok := item["ProviderIds"].(map[string]any); ok { + if v := anyString(providerIDs["Tmdb"]); v != "" { + media.TMDbID, _ = strconv.Atoi(v) + } + if v := anyString(providerIDs["Imdb"]); v != "" { + media.TheTVDBID = v + } + if v := anyString(providerIDs["Douban"]); v != "" { + media.DoubanID = v + } + } + switch remoteItemString(item, "Type") { + case "Episode": + media.SeasonNum = remoteItemInt(item, "ParentIndexNumber") + media.EpisodeNum = remoteItemInt(item, "IndexNumber") + media.EpisodeTitle = remoteItemString(item, "Name") + if seriesName := remoteItemString(item, "SeriesName"); seriesName != "" { + media.Title = seriesName + } + // Emby 单集通常无独立海报:回退到系列海报,避免网页端空图。 + if media.PosterURL == "" && seriesID != "" { + media.PosterURL = r.remoteItemImageURL(cfg, seriesID, "Primary") + } + default: // Movie / Series / Season / Folder + media.SeasonNum = 0 + media.EpisodeNum = 0 + } + return media +} + +// RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。 +func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, 0, err + } + if itemTypes == "" { + itemTypes = "Movie,Series" // 未知类型时两者都取(前端自行按 episode-like 分组) + } + q := url.Values{} + q.Set("ParentId", remoteViewID) + q.Set("IncludeItemTypes", itemTypes) + q.Set("Recursive", "false") + q.Set("StartIndex", strconv.Itoa(offset)) + q.Set("Limit", strconv.Itoa(limit)) + q.Set("Fields", "Overview,Genres,ProviderIds,Path") + var body struct { + Items []map[string]any `json:"Items"` + TotalRecordCount int64 `json:"TotalRecordCount"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, 0, err + } + items := make([]model.Media, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, acct.ID) // 嵌套/关联 ID 一并伪装 + items = append(items, r.MapRemoteItemToMedia(ctx, acct, cfg, it)) + } + return items, body.TotalRecordCount, nil +} + +// RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。 +func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, acct *model.StrmAccount, remoteID string) (*model.Media, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) + path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path" + var out map[string]any + if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { + return nil, err + } + RewriteEmbyRemoteIDs(out, acct.ID) + m := r.MapRemoteItemToMedia(ctx, acct, cfg, out) + return &m, nil +} + +// RemoteEpisodes 拉远程条目下的集列表(Series/Season/Folder→子集;Episode→同系列; +// Movie→自身单条),按季/集排序,与本地 ListMediaEpisodes 行为一致。 +func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, acct *model.StrmAccount, remoteID string) ([]model.Media, error) { + detail, err := r.RemoteMediaDetail(ctx, acct, remoteID) + if err != nil { + return nil, err + } + // 用远程详情载荷精判类型(Episode→同系列;Series/Season/Folder→子集;Movie→单条)。 + itemType := r.remoteItemType(ctx, acct, remoteID) + if itemType == "" { + itemType = remoteItemTypeOf(detail) + } + var parentID string + switch itemType { + case "Episode": + parentID = r.remoteItemSeriesID(ctx, acct, remoteID) + if parentID == "" { + parentID = remoteID + } + case "Season", "Folder", "Series": + parentID = remoteID + default: // Movie + return []model.Media{*detail}, nil + } + rows, _, err := r.remoteEpisodesOf(ctx, acct, parentID) + if err != nil { + return nil, err + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].SeasonNum != rows[j].SeasonNum { + return rows[i].SeasonNum < rows[j].SeasonNum + } + if rows[i].EpisodeNum != rows[j].EpisodeNum { + return rows[i].EpisodeNum < rows[j].EpisodeNum + } + return rows[i].Title < rows[j].Title + }) + return rows, nil +} + +func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, 0, err + } + q := url.Values{} + q.Set("ParentId", parentID) + q.Set("IncludeItemTypes", "Episode") + q.Set("Recursive", "true") + q.Set("StartIndex", "0") + q.Set("Limit", "500") + q.Set("Fields", "Overview,Genres,ProviderIds,Path") + var body struct { + Items []map[string]any `json:"Items"` + TotalRecordCount int64 `json:"TotalRecordCount"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, 0, err + } + items := make([]model.Media, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, acct.ID) + m := r.MapRemoteItemToMedia(ctx, acct, cfg, it) + items = append(items, m) + } + return items, body.TotalRecordCount, nil +} + +// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。 +func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("ParentId", remoteViewID) + q.Set("IncludeItemTypes", "Series") + q.Set("Recursive", "false") + q.Set("StartIndex", "0") + q.Set("Limit", "1000") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount") + var body struct { + Items []map[string]any `json:"Items"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { + return nil, err + } + cards := make([]SeriesCard, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, acct.ID) + m := r.MapRemoteItemToMedia(ctx, acct, cfg, it) + // 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。 + count := remoteItemInt(it, "RecursiveItemCount") + if count == 0 { + count = remoteItemInt(it, "ChildCount") + } + if count == 0 { + count = 1 + } + cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count}) + } + return cards, nil +} + +// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。 +func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) { + cfg, err := r.configOf(acct) + if err != nil { + return nil, err + } + items, err := r.RemoteLatest(ctx, acct, remoteViewID, limit) + if err != nil { + return nil, err + } + cards := make([]SeriesCard, 0, len(items)) + for _, it := range items { + m := r.MapRemoteItemToMedia(ctx, acct, cfg, it) + cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) + } + return cards, nil +} + +// WebStreamURL 远程条目的网页播放地址(302 直连远程 Emby 流端点)。 +func (r *EmbyRemoteService) WebStreamURL(ctx context.Context, acct *model.StrmAccount, remoteID string) (string, error) { + cfg, err := r.configOf(acct) + if err != nil { + return "", err + } + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return "", err + } + return r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + + "/stream?api_key=" + url.QueryEscape(cfg.Token) + "&Static=true", nil +} + +// remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。 +func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.StrmAccount, remoteID string) string { + cfg, err := r.configOf(acct) + if err != nil { + return "" + } + var out map[string]any + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &out); err != nil { + return "" + } + return remoteItemString(out, "Type") +} + +// remoteItemSeriesID 轻量查询 Episode 的 SeriesId。 +func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.StrmAccount, remoteID string) string { + cfg, err := r.configOf(acct) + if err != nil { + return "" + } + var out map[string]any + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &out); err != nil { + return "" + } + return remoteItemString(out, "SeriesId") +} + +// ─── 远程 item JSON 取值辅助 ──────────────────────────────────────────────── + +func anyString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func remoteItemString(item map[string]any, key string) string { + if item == nil { + return "" + } + if s, ok := item[key].(string); ok { + return s + } + return "" +} + +func remoteItemInt(item map[string]any, key string) int { + if item == nil { + return 0 + } + switch v := item[key].(type) { + case float64: + return int(v) + case int: + return v + case string: + n, _ := strconv.Atoi(v) + return n + } + return 0 +} + +func remoteItemInt64(item map[string]any, key string) int64 { + if item == nil { + return 0 + } + switch v := item[key].(type) { + case float64: + return int64(v) + case int: + return int64(v) + case string: + n, _ := strconv.ParseInt(v, 10, 64) + return n + } + return 0 +} + +func remoteItemFloat(item map[string]any, key string) float64 { + if item == nil { + return 0 + } + switch v := item[key].(type) { + case float64: + return v + case int: + return float64(v) + case string: + f, _ := strconv.ParseFloat(v, 64) + return f + } + return 0 +} + +// remoteItemGenres 合并 GenreItems / Genres 数组为逗号分隔字符串(前端 parseCSV 消费)。 +func remoteItemGenres(item map[string]any) string { + seen := map[string]bool{} + var parts []string + collect := func(arr any) { + list, ok := arr.([]any) + if !ok { + return + } + for _, it := range list { + var name string + if m, isMap := it.(map[string]any); isMap { + name = remoteItemString(m, "Name") + } else if s, isStr := it.(string); isStr { + name = s + } + if name != "" && !seen[name] { + seen[name] = true + parts = append(parts, name) + } + } + } + collect(item["GenreItems"]) + collect(item["Genres"]) + return strings.Join(parts, ",") +} + +// remoteItemImageURL 构造远程条目图片绝对地址(带 api_key;前端经 /api/img 代理)。 +func (r *EmbyRemoteService) remoteItemImageURL(cfg *EmbyRemoteConfig, remoteID, imageType string) string { + if remoteID == "" { + return "" + } + imageType = strings.ToLower(imageType) + if imageType == "" { + imageType = "primary" + } + return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(imageType) + + "?api_key=" + url.QueryEscape(cfg.Token) +} + +// remoteItemTypeOf 从映射后的 Media 推断远程类型(无详情载荷时兜底)。 +func remoteItemTypeOf(m *model.Media) string { + if m == nil { + return "Movie" + } + if m.EpisodeNum > 0 || m.SeasonNum > 0 { + return "Episode" + } + return "Movie" +} \ No newline at end of file diff --git a/web/src/pages/LibraryMovieActions.tsx b/web/src/pages/LibraryMovieActions.tsx index b48afed..47bc801 100644 --- a/web/src/pages/LibraryMovieActions.tsx +++ b/web/src/pages/LibraryMovieActions.tsx @@ -1,6 +1,7 @@ import { Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react' import type { Media } from '../types' +import { isRemoteEmbyID } from '../utils/remoteEmby' type LibraryMovieActionsProps = { media: Media @@ -23,6 +24,11 @@ export function LibraryMovieActions({ }: LibraryMovieActionsProps) { const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50' + // 远程 Emby 条目为只读视图:刮削/探测/NFO/回收站不适用。 + if (isRemoteEmbyID(media.id)) { + return null + } + return ( <>