Files
MeBox/internal/service/emby_artwork.go
T
truewhile 73b95a8e38 feat: Emby 挂载独立管理(媒体库级选择挂载 + 每库代理开关)
- 新增 EmbyMount 表与 /admin/emby/mounts CRUD:把远程 Emby 的媒体库按需
  挂载到本项目(同一服务器可选择性挂载/全量挂载),代理开关下沉到每个
  挂载(一个 Emby 服务器可部分库代理、部分库直连)
- 伪装 ID 改为 embyremote~{mountID}~{remoteID}:播放/详情/状态/图片链路
  全部经 ResolveMount 解析挂载与账号
- 新增「Emby 挂载」独立菜单页(/emby-mount):账号管理(添加/测试/编辑/
  删除)+ 挂载列表(代理切换/停用/取消)+ 选择挂载对话框(多选+全量)
- STRM 管理页过滤 emby_remote 账号;账号表单移除 emby 类型
- Boot 幂等 AutoSeedMounts:旧 emby 账号自动全量挂载(沿用旧代理配置),
  升级后媒体库不消失
- 修复:远程流请求注入浏览器 UA(防 Cloudflare 风控)+ 代理强制 Static=true
  (阻止远程 ffmpeg 转码调度,反代纯字节中继)
- 全局搜索改为按挂载逐个搜索(结果归属与伪装 ID 正确)
2026-09-01 13:53:15 +08:00

178 lines
4.9 KiB
Go

package service
import (
"context"
"strings"
"time"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
)
// ImageURL returns artwork for a media/series/season item id.
func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (string, error) {
// 远程 Emby 条目:直接返回远程图片绝对地址,由 ImageProxy 拉取透传。
if e.remote != nil && IsEmbyRemoteID(id) {
mountID, remoteID, _ := DecodeEmbyRemoteID(id)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return "", nil
}
return e.remote.RemoteImageURL(ctx, acct, remoteID, imageType)
}
pick := func(primary, backdrop string) string {
switch strings.ToLower(imageType) {
case "backdrop", "art":
if backdrop != "" {
return backdrop
}
}
if primary != "" {
return primary
}
return backdrop
}
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
return "", nil
}
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
return "", nil
}
m, err := e.repo.Media.FindByID(ctx, id)
if err == nil && m != nil {
if e.mediaShouldBeEpisode(ctx, m) {
switch strings.ToLower(imageType) {
case "backdrop", "art":
return "", nil
}
}
return pick(e.mediaPrimaryArtwork(ctx, m), e.mediaBackdropArtwork(ctx, m)), nil
}
if err != nil {
return "", err
}
if series, ok, err := e.findSeriesGroup(ctx, id, ""); err != nil {
return "", err
} else if ok {
return pick(series.PosterURL, series.BackdropURL), nil
}
// id 既不是媒体也不是剧集组时,还可能是一个媒体库(Emby 客户端通过
// /Items/{libraryID}/Images/Primary 请求库封面)。复用网页的封面来源:
// cover_url 优先,否则挑库内评分最高的一张成员海报。
if raw := e.libraryCoverArtwork(ctx, id); raw != "" {
return raw, nil
}
return "", nil
}
// cachedLibraryCover returns a previously resolved library cover URL within TTL.
func (e *EmbyService) cachedLibraryCover(id string) (string, bool) {
if e == nil || strings.TrimSpace(id) == "" {
return "", false
}
now := time.Now()
e.libraryCoverMu.Lock()
defer e.libraryCoverMu.Unlock()
entry, ok := e.libraryCoverCache[id]
if !ok || now.After(entry.expiresAt) {
if ok {
delete(e.libraryCoverCache, id)
}
return "", false
}
return entry.primary, true
}
func (e *EmbyService) rememberLibraryCover(id, cover string) {
if e == nil || strings.TrimSpace(id) == "" || strings.TrimSpace(cover) == "" {
return
}
e.libraryCoverMu.Lock()
defer e.libraryCoverMu.Unlock()
if e.libraryCoverCache == nil {
e.libraryCoverCache = make(map[string]embyArtworkCacheEntry)
}
if len(e.libraryCoverCache) > 4000 {
e.libraryCoverCache = make(map[string]embyArtworkCacheEntry)
}
e.libraryCoverCache[id] = embyArtworkCacheEntry{
primary: cover,
expiresAt: time.Now().Add(embyVirtualCacheTTL),
}
}
// libraryCoverArtwork 解析媒体库封面,与网页媒体库封面逻辑一致:
// 1. 用户手动设置的 cover_url 优先;
// 2. 否则取库内最近的一批成员,按 seriesArtworkScore(同网页 artworkScore)
// 挑出评分最高的那张海报作为封面。
//
// Emby 主图只能显示单张,因此返回的正是网页拼图里最靠前的那张。
func (e *EmbyService) libraryCoverArtwork(ctx context.Context, id string) string {
if e == nil {
return ""
}
if cached, ok := e.cachedLibraryCover(id); ok {
return cached
}
lib, err := e.repo.Library.FindByID(ctx, id)
if err != nil || lib == nil {
return ""
}
if strings.TrimSpace(lib.CoverURL) != "" {
cover := lib.CoverURL
e.rememberLibraryCover(id, cover)
return cover
}
var cover string
if rows, _, err := e.repo.Media.ListByLibraryFiltered(ctx, id, 0, 12, repository.MediaQueryFilter{IncludeNSFW: true}); err == nil {
var bestScore = -1
for i := range rows {
score := seriesArtworkScore(rows[i])
if score <= bestScore {
continue
}
bestScore = score
cover = rows[i].PosterURL
if cover == "" {
cover = rows[i].BackdropURL
}
}
}
if cover != "" {
e.rememberLibraryCover(id, cover)
}
return cover
}
// LibraryHasCover reports whether a library has an Emby-servable cover, so the
// Views / virtual-folders payloads can advertise ImageTags.Primary to clients.
func (e *EmbyService) LibraryHasCover(ctx context.Context, id string) bool {
return e.libraryCoverArtwork(ctx, id) != ""
}
func (e *EmbyService) mediaPrimaryArtwork(ctx context.Context, m *model.Media) string {
if m == nil {
return ""
}
if e.mediaShouldBeEpisode(ctx, m) && strings.TrimSpace(m.BackdropURL) != "" {
return m.BackdropURL
}
return m.PosterURL
}
func (e *EmbyService) mediaBackdropArtwork(ctx context.Context, m *model.Media) string {
if m == nil {
return ""
}
if e.mediaShouldBeEpisode(ctx, m) {
return ""
}
return m.BackdropURL
}