mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 12:26:36 +08:00
4dc9cbe2e7
- 后端新增远程库→本地结构映射(emby_remote_web.go):Library/Media/SeriesCard 与本地完全同构,前端无感知 - /api/libraries(含 with_preview)、/api/libraries/:id、/api/libraries/:id/media、 /api/libraries/:id/series、/api/libraries/:id/series/episodes、/api/media/:id、 /api/media/:id/episodes 全部支持远程伪装 ID 透传映射 - /api/stream/:id 对远程条目 302 直连远程 Emby 原地址(播放不经过本机) - 库内容按 CollectionType 过滤(电影库取 Movie、剧集库取 Series), RecursiveItemCount 作为系列集数;修复伪装 ID 双重编码导致的图片/详情 500 - SPA fallback 放行 /library/embyremote~ 前缀(不再被当作 Emby API 路由 404) - 前端:远程库/条目标记 is_remote_emby,隐藏扫描/刮削/编辑/NFO/回收站/ HLS 转码等不适用操作;后台管理面板过滤远程库
497 lines
16 KiB
Go
497 lines
16 KiB
Go
// 网页端远程 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"
|
||
} |