mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
73b95a8e38
- 新增 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 正确)
178 lines
4.9 KiB
Go
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
|
|
}
|