Files
2026-09-20 15:16:40 +08:00

284 lines
8.7 KiB
Go

package service
import (
"context"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/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) || strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
return raw, nil
}
// Latest JSON can outlive (or be served after) the in-memory artwork
// map. Rebuild from the library instead of handing the client a 1x1
// placeholder that it then caches as a successful image.
return e.resolveVirtualArtwork(ctx, id, imageType, pick)
}
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
}
func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType string, pick func(primary, backdrop string) string) (string, error) {
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
season, ok, err := e.findSeasonGroup(ctx, id, "")
if err != nil || !ok {
return "", err
}
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
}
series, ok, err := e.findSeriesGroup(ctx, id, "")
if err != nil || !ok {
return "", err
}
return pick(series.PosterURL, series.BackdropURL), nil
}
// PersonImageURL resolves a person avatar from either a disguised remote ID
// or a person display name captured from a remote item's People field.
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType, tag string) (string, error) {
idOrName = strings.TrimSpace(idOrName)
if idOrName == "" || e == nil {
return "", nil
}
if IsEmbyRemoteID(idOrName) {
return e.ImageURL(ctx, idOrName, imageType)
}
if personID, ok := parseTMDbPersonID(idOrName); ok && e.tmdb != nil {
profilePath := tmdbProfilePathFromTag(tag)
if profilePath == "" {
profilePath = e.cachedPersonImage(idOrName)
}
if profilePath != "" {
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
e.rememberPersonImage(idOrName, profilePath)
return raw, nil
}
}
profilePath, err := e.tmdb.PersonProfilePathByID(ctx, personID)
if err != nil {
return "", err
}
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
e.rememberPersonImage(idOrName, profilePath)
return raw, nil
}
}
if e.remote != nil {
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
return raw, nil
}
}
return e.ImageURL(ctx, idOrName, imageType)
}
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
var imageInfoTypes = []string{"Primary", "Backdrop"}
// ImageInfos 返回条目的图片清单,对应 Emby 的 GET /Items/{Id}/Images。
// 客户端用它在详情页决定要加载哪些图;缺失该接口会落到 404,部分客户端
// 因此把条目当成"无图"而放弃渲染海报。
func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]any {
id = strings.TrimSpace(id)
if id == "" {
return nil
}
out := make([]map[string]any, 0, 2)
seen := map[string]bool{}
for _, imageType := range imageInfoTypes {
raw, err := e.ImageURL(ctx, id, imageType)
if err != nil {
continue
}
raw = strings.TrimSpace(raw)
// 非 Backdrop 类型在缺图时会回退到主图,去重避免同一张图重复出现。
if raw == "" || seen[raw] {
continue
}
seen[raw] = true
// ImageTag 让客户端判断自己缓存的图片是否还有效:远程条目的真实
// ImageTags 会随远端换图变化,拿不到时才退化为条目 ID(恒定值)。
tag := id
if e.remote != nil {
if remoteTag := e.remote.RemoteImageTagOfEncodedID(ctx, id, imageType); remoteTag != "" {
tag = remoteTag
}
}
out = append(out, map[string]any{
"ImageType": imageType,
"ImageIndex": 0,
"ImageTag": tag,
})
}
return out
}
// UserAvatarURL 返回用户头像的来源地址;用户未设置头像时返回空串。
func (e *EmbyService) UserAvatarURL(ctx context.Context, userID string) string {
userID = strings.TrimSpace(userID)
if userID == "" || e == nil || e.repo == nil || e.repo.User == nil {
return ""
}
user, err := e.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return ""
}
return strings.TrimSpace(user.AvatarURL)
}
// 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
}