Files
MeBox/internal/service/emby_compat.go
T
2026-06-21 12:53:52 +08:00

2506 lines
79 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service — Emby/Jellyfin compatibility shim.
//
// EmbyService produces JSON envelopes shaped like the most-consumed
// Emby-API endpoints so existing players (Infuse / Yamby / Hills /
// Senplayer / Kodi NextPVR extension / iOS native clients) can talk to
// MediaStationGo without a custom plugin.
//
// The shim is read-mostly: items, images, playback are fully covered;
// 播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory /
// Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到
// 我们自己的 React UI。
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
const embyServerID = "mediastation-go-001"
// embyCompatVersion deliberately reports an Emby 4.x server. Official Emby
// clients reject Jellyfin-style 10.x identities as unsupported/too old during
// the login handshake, even when the API shape is compatible enough for us.
const embyCompatVersion = "4.8.10.0"
const (
embyLocalAuthenticationProviderID = "Emby.Server.Implementations.LocalAuthenticationProvider" // #nosec G101 -- Emby provider identifier, not a credential.
embyLocalPasswordResetProviderID = "Emby.Server.Implementations.LocalPasswordResetProvider" // #nosec G101 -- Emby provider identifier, not a credential.
)
// PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
// 以释放宿主机 CPU 资源。
const PlaybackDirectOnlySettingKey = "playback.direct_only"
// EmbyService produces Emby-shaped JSON.
type EmbyService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
storage cloudPlaybackResolver
probe cloudPlaybackProber
cache *RuntimeCacheService
virtualMu sync.RWMutex
virtualSeries map[string]embySeriesCacheEntry
virtualSeasons map[string]embySeasonCacheEntry
virtualArtwork map[string]embyArtworkCacheEntry
visibilityMu sync.RWMutex
visibilityCache map[string]embyVisibilityCacheEntry
cloudProbeMu sync.Mutex
cloudProbeInFlight map[string]struct{}
}
type cloudPlaybackResolver interface {
CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error)
}
type cloudPlaybackProber interface {
ProbeHTTP(ctx context.Context, rawURL string, headers map[string]string) (*ProbeResult, error)
}
// NewEmbyService is the constructor.
func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService {
return &EmbyService{cfg: cfg, log: log, repo: repo}
}
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
if e != nil {
e.cache = cache
}
return e
}
func (e *EmbyService) SetCloudProbe(storage cloudPlaybackResolver, probe cloudPlaybackProber) {
if e == nil {
return
}
e.storage = storage
e.probe = probe
}
// ─── System ──────────────────────────────────────────────────────────────────
// SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any {
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
"ServerName": "MediaStationGo",
"Version": embyCompatVersion,
"ServerVersion": embyCompatVersion,
"ProductName": "Emby Server",
"OperatingSystem": "Windows",
"Architecture": "X64",
"LocalAddress": "",
"WanAddress": "",
"HasPendingRestart": false,
"IsShuttingDown": false,
"SupportsLibraryMonitor": true,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"HttpServerPortNumber": e.cfg.App.Port,
"HttpsPortNumber": 0,
"PublishedServerUrl": "",
"WebSocketPortNumber": e.cfg.App.Port,
"CompletedInstallations": []any{},
"CanSelfRestart": false,
"CanLaunchWebBrowser": false,
"CanRestart": false,
}
}
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
func (e *EmbyService) SystemInfoPublic() map[string]any {
return map[string]any{
"Id": embyServerID,
"ServerId": embyServerID,
"ServerName": "MediaStationGo",
"Version": embyCompatVersion,
"ServerVersion": embyCompatVersion,
"ProductName": "Emby Server",
"OperatingSystem": "Windows",
"LocalAddress": "",
"WanAddress": "",
"HttpServerPortNumber": e.cfg.App.Port,
"HttpsPortNumber": 0,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"StartupWizardCompleted": true,
}
}
// ─── Users ───────────────────────────────────────────────────────────────────
// ListUsers returns Emby-shaped users.
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
users, err := e.repo.User.List(ctx)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, e.userPayload(&u))
}
return out, nil
}
// FindUser 用 ID 查用户,用于 /Users/Me 与 /Users/{id}。
func (e *EmbyService) FindUser(ctx context.Context, id string) (map[string]any, error) {
u, err := e.repo.User.FindByID(ctx, id)
if err != nil || u == nil {
return nil, err
}
return e.userPayload(u), nil
}
func (e *EmbyService) userPayload(u *model.User) map[string]any {
canDownload := u.Role == "admin"
return map[string]any{
"Id": u.ID,
"Name": u.Username,
"ServerId": embyServerID,
"ServerName": "MediaStationGo",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"LastLoginDate": u.LastLoginAt,
"LastActivityDate": u.UpdatedAt,
"Configuration": map[string]any{
"PlayDefaultAudioTrack": true,
"DisplayCollectionsView": true,
"DisplayMissingEpisodes": false,
"SubtitleMode": "Default",
"EnableNextEpisodeAutoPlay": true,
"AudioLanguagePreference": "",
"SubtitleLanguagePreference": "",
},
"Policy": map[string]any{
"IsAdministrator": u.Role == "admin",
"IsHidden": false,
"IsDisabled": !u.IsActive,
"EnableUserPreferenceAccess": true,
"EnableRemoteAccess": true,
"EnableMediaPlayback": true,
"EnableAudioPlaybackTranscoding": true,
"EnableVideoPlaybackTranscoding": true,
"EnablePlaybackRemuxing": true,
"EnableLiveTvAccess": false,
"EnableContentDownloading": canDownload,
"EnableSyncTranscoding": canDownload,
"EnableMediaConversion": canDownload,
"EnableAllChannels": true,
"EnableAllFolders": true,
"EnableAllDevices": true,
"AuthenticationProviderId": embyLocalAuthenticationProviderID,
"PasswordResetProviderId": embyLocalPasswordResetProviderID,
},
}
}
// ─── Views / MediaFolders ────────────────────────────────────────────────────
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
libs, err := e.repo.Library.List(ctx)
if err != nil {
return nil, err
}
libs = FilterDisplayCloudLibraries(ctx, e.repo, libs)
visibility := e.mediaVisibility(ctx, userID)
items := make([]map[string]any, 0, len(libs))
for _, l := range libs {
if !e.libraryVisibleFromCachedVisibility(l, visibility) {
continue
}
items = append(items, e.libraryAsView(&l))
}
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
collectionType := "movies"
switch l.Type {
case "tv":
collectionType = "tvshows"
case "anime":
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
case "variety":
collectionType = "tvshows"
case "music":
collectionType = "music"
}
return map[string]any{
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
"IsFolder": true,
"Path": l.Path,
"SortName": strings.ToLower(l.Name),
"DateCreated": l.CreatedAt.UTC().Format(time.RFC3339),
"CanDelete": false,
"CanDownload": false,
"DisplayPreferencesId": l.ID,
"PrimaryImageItemId": l.ID,
"PrimaryImageAspectRatio": 1.7777777777777777,
"RecursiveItemCount": 0,
"ChildCount": 0,
"SpecialFeatureCount": 0,
"EnableMediaSourceDisplay": true,
"PlayAccess": "Full",
"ExternalUrls": []any{},
"ProviderIds": map[string]string{},
"Genres": []string{},
"Tags": []string{},
"ImageTags": map[string]string{},
"BackdropImageTags": []string{},
"UserData": map[string]any{
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"UnplayedItemCount": 0,
},
}
}
// ─── Items ───────────────────────────────────────────────────────────────────
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
type ItemsParams struct {
UserID string
ParentID string
IDs []string
SearchTerm string
IncludeItemTypes []string
Filters []string
Recursive bool
SortBy string
SortOrder string
Limit int
StartIndex int
}
const (
embyVirtualSeriesPrefix = "msgo-series-"
embyVirtualSeasonPrefix = "msgo-season-"
embyVirtualCacheTTL = 10 * time.Minute
embyVisibilityCacheTTL = 30 * time.Second
embySeriesGroupingLimit = 5000
)
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
type embySeriesGroup struct {
ID string
LibraryID string
Name string
PosterURL string
BackdropURL string
Overview string
Rating float32
Year int
TMDbID int
BangumiID int
CreatedAt time.Time
Episodes []model.Media
}
type embySeasonGroup struct {
ID string
SeriesID string
LibraryID string
Name string
SeasonNum int
Series embySeriesGroup
Episodes []model.Media
}
type embySeriesCacheEntry struct {
group embySeriesGroup
expiresAt time.Time
}
type embySeasonCacheEntry struct {
season embySeasonGroup
expiresAt time.Time
}
type embyArtworkCacheEntry struct {
primary string
backdrop string
expiresAt time.Time
}
type embyVisibilityCacheEntry struct {
visibility MediaVisibility
expiresAt time.Time
}
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
// episode as a separate movie card.
func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 500 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
}
if len(p.IncludeItemTypes) > 0 && !containsSupportedEmbyItemType(p.IncludeItemTypes) {
return emptyItemsEnvelope(p.StartIndex), nil
}
if len(p.IDs) > 0 {
items := make([]map[string]any, 0, len(p.IDs))
for _, id := range p.IDs {
item, err := e.Item(ctx, id, p.UserID)
if err != nil {
return nil, err
}
if item != nil {
items = append(items, item)
}
}
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
if containsOnlyFolderItemTypes(p.IncludeItemTypes) {
if p.ParentID == "" {
return e.Views(ctx, p.UserID)
}
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil {
return nil, err
} else if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 && len(p.Filters) == 0 {
return e.Views(ctx, p.UserID)
}
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID, p.UserID); err != nil {
return nil, err
} else if ok {
return e.episodeItems(ctx, season.Episodes, p)
}
if series, ok, err := e.findSeriesGroup(ctx, p.ParentID, p.UserID); err != nil {
return nil, err
} else if ok {
if p.Recursive || containsItemType(p.IncludeItemTypes, "Episode") {
return e.episodeItems(ctx, series.Episodes, p)
}
seasons := e.seasonsForSeries(series)
items := make([]map[string]any, 0, len(seasons))
for _, season := range pageSlice(seasons, p.StartIndex, p.Limit) {
items = append(items, e.seasonPayload(season))
}
return map[string]any{"Items": items, "TotalRecordCount": len(seasons), "StartIndex": p.StartIndex}, nil
}
if p.ParentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil {
return nil, err
} else if episodic && !p.Recursive && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
}
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
// Season/SxxE 结构整理的内容(如整合成剧集的剧场版 / 合集动画)。这些行若按
// 散装单集(Episode)漏出,在 Infuse/yamby 等客户端表现为「整部剧被拆成一堆
// 单集卡片」。方案 B: 把它们按整剧聚成 Series 卡片,与真正的电影(Movie)并列
// 展示在同一电影库视图。仅在该电影库确实含此类内容时才走此分支,普通电影库
// 仍走 mediaItems(保留其缓存 / 版本合并逻辑)。
if p.ParentID != "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil && !episodic {
if has, err := e.movieLibraryHasEpisodicContent(ctx, p.ParentID); err == nil && has {
return e.movieLibraryItems(ctx, p)
}
}
}
return e.mediaItems(ctx, p)
}
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
// (有季集号且路径形如剧集,例如 .../国产剧/某剧/Season 01/某剧 - S01E01.mkv)。
// 用于决定是否需要走 movieLibraryItems 把这些内容聚成 Series 卡片。普通电影库
// 没有这类行时返回 false,继续走常规 mediaItems。
func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, libraryID string) (bool, error) {
clause, args := embyLikelyEpisodicPathSQL()
if clause == "" {
return false, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID)).
Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...)
var count int64
if err := q.Limit(1).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// movieLibraryItems 处理电影类型库的常规浏览,返回「真正的电影(Movie)」与
// 「库内剧集结构内容聚成的 Series 卡片」的合并列表(按 DateCreated 倒序分页)。
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
apply := func(q *gorm.DB) *gorm.DB {
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
q = q.Where("library_id IN ?", libIDs)
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
return q
}
// 剧集结构内容 → Series 卡片。
clause, args := embyLikelyEpisodicPathSQL()
var episodicRows []model.Media
if clause != "" {
epQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
if epQ == nil {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
epQ = epQ.Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...).
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
if err := epQ.Find(&episodicRows).Error; err != nil {
return nil, err
}
}
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
// 真正的电影 → Movie 项(剔除剧集结构行)。
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
if movieQ == nil {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ).
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
var movieRows []model.Media
if err := movieQ.Find(&movieRows).Error; err != nil {
return nil, err
}
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
if err != nil {
return nil, err
}
// 合并: Series 卡片 + Movie 项, 统一按 DateCreated 倒序。
type entry struct {
createdAt time.Time
payload map[string]any
}
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
for _, g := range seriesGroups {
entries = append(entries, entry{createdAt: g.CreatedAt, payload: e.seriesPayload(g)})
}
for _, item := range movieItems {
entries = append(entries, entry{createdAt: embyPayloadCreatedAt(item), payload: item})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].createdAt.After(entries[j].createdAt)
})
total := len(entries)
paged := pageSlice(entries, p.StartIndex, p.Limit)
items := make([]map[string]any, 0, len(paged))
for _, en := range paged {
items = append(items, en.payload)
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
func embyPayloadCreatedAt(item map[string]any) time.Time {
if item == nil {
return time.Time{}
}
if v, ok := item["DateCreated"].(time.Time); ok {
return v
}
return time.Time{}
}
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("items", p)
var cached embyItemsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
return map[string]any{"Items": cached.Items, "TotalRecordCount": cached.TotalRecordCount, "StartIndex": cached.StartIndex}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{})
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if p.ParentID != "" {
q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID)
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
resumeFilter := containsEmbyFilter(p.Filters, "IsResumable")
if resumeFilter {
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
q = q.Joins(`JOIN (
SELECT media_id, MAX(watched_at) AS watched_at
FROM playback_histories
WHERE user_id = ? AND completed = ? AND position_ms > 0
GROUP BY media_id
) AS resume ON resume.media_id = media.id`, p.UserID, false)
}
filterBySeasonNumbers := true
parentKnownNonEpisodic := false
if p.ParentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil && !episodic {
filterBySeasonNumbers = false
parentKnownNonEpisodic = true
}
}
if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
return emptyItemsEnvelope(p.StartIndex), nil
}
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = e.filterMovieItems(ctx, q)
}
if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = filterLikelyEpisodicPathsFromMovieQuery(q)
}
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
q = e.filterEpisodeItems(ctx, q)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
order := "media.created_at desc"
switch primarySupportedEmbySort(p.SortBy, resumeFilter) {
case "sortname", "name":
order = "media.title"
case "premieredate", "productionyear":
order = "media.year"
case "datecreated":
order = "media.created_at"
case "dateplayed":
order = "resume.watched_at"
case "communityrating":
order = "media.rating"
}
if strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") {
if !strings.HasSuffix(order, " desc") {
order = order + " desc"
}
}
fetchLimit := p.Limit
fetchOffset := p.StartIndex
if fetchLimit > 0 && e.shouldCollapseMediaVersions(ctx, p) {
// Duplicates across merged local/cloud libraries collapse into one Emby
// item with multiple MediaSources. Fetch a wider window so duplicates do
// not consume the whole requested page.
fetchOffset = 0
fetchLimit = p.StartIndex + maxInt(p.Limit*4, p.Limit)
}
var rows []model.Media
if err := q.Order(order).Offset(fetchOffset).Limit(fetchLimit).Find(&rows).Error; err != nil {
return nil, err
}
if e.shouldCollapseMediaVersions(ctx, p) {
rows = e.collapseMediaVersionRows(ctx, rows)
rows = pageSlice(rows, p.StartIndex, p.Limit)
}
items, err := e.payloadsForMedia(ctx, rows, p.UserID)
if err != nil {
return nil, err
}
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{Items: items, TotalRecordCount: total, StartIndex: p.StartIndex}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
type embyItemsCacheValue struct {
Items []map[string]any `json:"items"`
TotalRecordCount int64 `json:"total_record_count"`
StartIndex int `json:"start_index"`
}
type embyLatestCacheValue struct {
Items []map[string]any `json:"items"`
}
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
includeTypes := append([]string(nil), p.IncludeItemTypes...)
filters := append([]string(nil), p.Filters...)
ids := append([]string(nil), p.IDs...)
sort.Strings(includeTypes)
sort.Strings(filters)
sort.Strings(ids)
sum := sha256.Sum256([]byte(strings.Join([]string{
kind,
p.UserID,
p.ParentID,
strings.Join(ids, ","),
p.SearchTerm,
strings.Join(includeTypes, ","),
strings.Join(filters, ","),
strconv.FormatBool(p.Recursive),
p.SortBy,
p.SortOrder,
strconv.Itoa(p.StartIndex),
strconv.Itoa(p.Limit),
}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
sum := sha256.Sum256([]byte(strings.Join([]string{"latest", userID, parentID, strconv.Itoa(limit)}, "|")))
return "media:emby:" + hex.EncodeToString(sum[:])
}
func (e *EmbyService) mediaCacheTTLSeconds() int {
if e == nil || e.cfg == nil || e.cfg.Cache.MediaTTLSeconds < 1 {
return 15
}
return e.cfg.Cache.MediaTTLSeconds
}
func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) {
rows = e.filterMediaRowsForUser(ctx, rows, p.UserID)
if p.SearchTerm != "" {
filtered := rows[:0]
needle := strings.ToLower(p.SearchTerm)
for _, row := range rows {
if strings.Contains(strings.ToLower(row.Title), needle) || strings.Contains(strings.ToLower(row.OriginalName), needle) {
filtered = append(filtered, row)
}
}
rows = filtered
}
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].CreatedAt.Before(rows[j].CreatedAt)
})
total := len(rows)
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
if err != nil {
return nil, err
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
rows = e.collapseMediaVersionRows(ctx, rows)
userFavs := map[string]bool{}
userPos := map[string]int64{}
if userID != "" && len(rows) > 0 {
mediaIDs := make([]string, 0, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.ID) != "" {
mediaIDs = append(mediaIDs, row.ID)
}
}
if len(mediaIDs) == 0 {
mediaIDs = []string{"__none__"}
}
var favs []model.Favorite
favQuery := e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Where("media_id IN ?", mediaIDs)
_ = favQuery.Find(&favs).Error
for _, f := range favs {
userFavs[f.MediaID] = true
}
var hist []model.PlaybackHistory
histQuery := e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Where("media_id IN ?", mediaIDs)
_ = histQuery.Find(&hist).Error
for _, h := range hist {
userPos[h.MediaID] = h.PositionMs
}
}
items := make([]map[string]any, 0, len(rows))
for _, m := range rows {
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID]))
}
return items, nil
}
func (e *EmbyService) shouldCollapseMediaVersions(ctx context.Context, p ItemsParams) bool {
if containsItemType(p.IncludeItemTypes, "Series") || containsItemType(p.IncludeItemTypes, "Season") {
return false
}
if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
return true
}
if p.ParentID == "" {
return true
}
episodic, err := e.libraryIsEpisodic(ctx, p.ParentID)
return err == nil && !episodic
}
func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model.Media) []model.Media {
if len(rows) < 2 {
return rows
}
out := make([]model.Media, 0, len(rows))
indexByKey := make(map[string]int, len(rows))
for _, row := range rows {
key := e.mediaVersionKey(ctx, &row)
if key == "" {
out = append(out, row)
continue
}
if idx, ok := indexByKey[key]; ok {
if preferMediaVersion(row, out[idx]) {
out[idx] = row
}
continue
}
indexByKey[key] = len(out)
out = append(out, row)
}
return out
}
// Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
return nil, err
} else if lib != nil {
libs := FilterDisplayCloudLibraries(ctx, e.repo, []model.Library{*lib})
if len(libs) == 0 {
return nil, nil
}
visibility := e.mediaVisibility(ctx, userID)
if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) {
return nil, nil
}
return e.libraryAsView(&libs[0]), nil
}
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seasonPayload(season), nil
}
}
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seriesPayload(series), nil
}
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
return e.seriesPayload(series), nil
}
return nil, nil
}
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
return nil, nil
}
fav := false
pos := int64(0)
if userID != "" {
var f model.Favorite
ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if ferr == nil {
fav = true
}
var h model.PlaybackHistory
herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).
Order("watched_at desc").First(&h).Error
if herr == nil {
pos = h.PositionMs
}
}
return e.itemPayload(ctx, m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
q = e.applyUserMediaVisibility(ctx, q, userID)
if parentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err == nil && e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, err
}
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
}
var rows []model.Media
if err := q.Order("media.created_at desc").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
favs := map[string]bool{}
if userID != "" && len(rows) > 0 {
mediaIDs := make([]string, 0, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.ID) != "" {
mediaIDs = append(mediaIDs, row.ID)
}
}
if len(mediaIDs) == 0 {
mediaIDs = []string{"__none__"}
}
var fr []model.Favorite
_ = e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, mediaIDs).Find(&fr).Error
for _, f := range fr {
favs[f.MediaID] = true
}
}
out := make([]map[string]any, 0, len(rows))
for _, m := range rows {
out = append(out, e.itemPayload(ctx, &m, favs[m.ID], 0))
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
rowLimit := limit * 40
if rowLimit < 200 {
rowLimit = 200
}
if rowLimit > embySeriesGroupingLimit {
rowLimit = embySeriesGroupingLimit
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "datecreated", SortOrder: "Descending"})
if len(groups) > limit {
groups = groups[:limit]
}
items := make([]map[string]any, 0, len(groups))
for _, group := range groups {
items = append(items, e.seriesPayload(group))
}
return items, nil
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
type row struct {
MediaID string
PositionMs int64
DurationMs int64
}
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
}
ids := make([]string, 0, len(hist))
posByID := map[string]int64{}
for _, h := range hist {
ids = append(ids, h.MediaID)
posByID[h.MediaID] = h.PositionMs
}
var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", ids)
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.Find(&medias).Error; err != nil {
return nil, err
}
// 维持时间倒序
byID := map[string]*model.Media{}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
items := make([]map[string]any, 0, len(hist))
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
items = append(items, e.itemPayload(ctx, m, false, posByID[h.MediaID]))
}
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
itemType := "Movie"
name := m.Title
parentID := m.LibraryID
seriesID := m.SeriesID
seriesName := ""
seasonID := ""
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(m)
seriesName = e.seriesNameForMedia(m)
seasonID = e.seasonIDForMedia(m)
parentID = seasonID
originalName := strings.TrimSpace(m.OriginalName)
if originalName != "" && !strings.EqualFold(originalName, seriesName) && !strings.EqualFold(originalName, m.Title) {
name = m.OriginalName
} else if m.EpisodeNum > 0 {
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
}
}
imageTags := map[string]string{}
backdropTags := []string{}
primaryArtwork := e.mediaPrimaryArtwork(ctx, m)
backdropArtwork := e.mediaBackdropArtwork(ctx, m)
if primaryArtwork != "" {
imageTags["Primary"] = m.ID
}
if backdropArtwork != "" {
backdropTags = append(backdropTags, m.ID+"-bd")
}
runTimeTicks := int64(m.DurationSec) * 10_000_000
durationMs := int64(m.DurationSec) * 1000
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
pct := 0.0
if durationMs > 0 {
pct = float64(posMs) / float64(durationMs) * 100
}
return map[string]any{
"Id": m.ID,
"Name": name,
"OriginalTitle": m.OriginalName,
"ServerId": embyServerID,
"Type": itemType,
"MediaType": "Video",
"IsFolder": false,
"ProductionYear": m.Year,
"ParentIndexNumber": m.SeasonNum,
"IndexNumber": m.EpisodeNum,
"Overview": m.Overview,
"RunTimeTicks": runTimeTicks,
"CommunityRating": m.Rating,
"Container": m.Container,
"Width": m.Width,
"Height": m.Height,
"DateCreated": m.CreatedAt,
"Path": m.Path,
"ParentId": parentID,
"SeasonId": seasonID,
"SeasonName": seasonName(m.SeasonNum),
"SeriesId": seriesID,
"SeriesName": seriesName,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
},
"UserData": map[string]any{
"PlaybackPositionTicks": posMs * 10_000,
"PlayCount": 0,
"IsFavorite": fav,
"Played": played,
"PlayedPercentage": pct,
},
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
}
}
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if libraryID != "" {
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID))
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
rowLimit := p.StartIndex + maxInt(p.Limit*40, 1000)
if rowLimit < p.Limit {
rowLimit = p.Limit
}
if rowLimit > embySeriesGroupingLimit {
rowLimit = embySeriesGroupingLimit
}
var rows []model.Media
if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
}
groups := e.seriesGroupsFromMedia(rows)
sortSeriesGroups(groups, p)
total := len(groups)
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
items = append(items, e.seriesPayload(group))
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (bool, error) {
if strings.TrimSpace(libraryID) == "" {
return false, nil
}
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
return false, err
} else if lib != nil {
return embyLibraryTypeIsEpisodic(lib.Type), nil
}
var count int64
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID)).
Count(&count).Error
return count > 0, err
}
func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *model.Media) bool {
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
return false
}
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil || lib == nil {
return false
}
return embyLibraryTypeIsEpisodic(lib.Type)
}
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
if m == nil || (m.SeasonNum <= 0 && m.EpisodeNum <= 0) {
return false
}
if e.mediaBelongsToEpisodicLibrary(ctx, m) {
return true
}
return embyMediaPathLooksEpisodic(m.Path)
}
func embyLibraryTypeIsEpisodic(typ string) bool {
switch strings.ToLower(strings.TrimSpace(typ)) {
case "tv", "anime", "variety":
return true
default:
return false
}
}
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return filterLikelyEpisodicPathsFromMovieQuery(q)
}
q = q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
return filterLikelyEpisodicPathsFromMovieQuery(q)
}
func (e *EmbyService) filterEpisodeItems(ctx context.Context, q *gorm.DB) *gorm.DB {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return q.Where("1 = 0")
}
return q.Where("media.library_id IN ? AND (media.season_num > 0 OR media.episode_num > 0)", episodicIDs)
}
func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string {
if e == nil || e.repo == nil || e.repo.DB == nil {
return nil
}
var ids []string
if err := e.repo.DB.WithContext(ctx).Model(&model.Library{}).
Where("LOWER(type) IN ?", []string{"tv", "anime", "variety"}).
Pluck("id", &ids).Error; err != nil {
return nil
}
return ids
}
func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB {
clause, args := embyLikelyEpisodicPathSQL()
if clause == "" {
return q
}
return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...)
}
func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
"%/电视剧/%", "%/剧集/%", "%/国产剧/%", "%/欧美剧/%", "%/日韩剧/%", "%/日剧/%", "%/韩剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns)*2)
args := make([]any, 0, len(patterns)*2)
for _, pattern := range patterns {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, pattern)
if strings.Contains(pattern, "/") {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, strings.ReplaceAll(pattern, "/", `\`))
}
}
return strings.Join(clauses, " OR "), args
}
func embyMediaPathLooksEpisodic(path string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
if normalized == "" {
return false
}
for _, marker := range []string{
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
"/电视剧/", "/剧集/", "/国产剧/", "/欧美剧/", "/日韩剧/", "/日剧/", "/韩剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
if strings.Contains(normalized, marker) {
return true
}
}
for _, marker := range []string{"/s0", "/s1", "/s2", "/s3", "/s4", "/s5", "/s6", "/s7", "/s8", "/s9"} {
if idx := strings.Index(normalized, marker); idx >= 0 {
after := idx + len(marker)
if after < len(normalized) && normalized[after] >= '0' && normalized[after] <= '9' {
slash := after + 1
if slash < len(normalized) && normalized[slash] == '/' {
return true
}
}
}
}
return false
}
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e == nil || strings.TrimSpace(group.ID) == "" {
return
}
expiresAt := time.Now().Add(embyVirtualCacheTTL)
e.virtualMu.Lock()
defer e.virtualMu.Unlock()
if e.virtualSeries == nil {
e.virtualSeries = make(map[string]embySeriesCacheEntry)
}
if e.virtualSeasons == nil {
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
}
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
if len(e.virtualSeries) > 2000 || len(e.virtualSeasons) > 5000 || len(e.virtualArtwork) > 7000 {
e.virtualSeries = make(map[string]embySeriesCacheEntry)
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
for _, season := range e.seasonsForSeries(group) {
e.virtualSeasons[season.ID] = embySeasonCacheEntry{season: season, expiresAt: expiresAt}
e.virtualArtwork[season.ID] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[season.ID+"-bd"] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
}
}
func (e *EmbyService) rememberSeasonGroup(season embySeasonGroup) {
if e == nil || strings.TrimSpace(season.ID) == "" {
return
}
expiresAt := time.Now().Add(embyVirtualCacheTTL)
e.virtualMu.Lock()
defer e.virtualMu.Unlock()
if e.virtualSeasons == nil {
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
}
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
}
e.virtualSeasons[season.ID] = embySeasonCacheEntry{season: season, expiresAt: expiresAt}
e.virtualArtwork[season.ID] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[season.ID+"-bd"] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
}
func (e *EmbyService) cachedSeriesGroup(id string) (embySeriesGroup, bool) {
if e == nil || strings.TrimSpace(id) == "" {
return embySeriesGroup{}, false
}
now := time.Now()
e.virtualMu.RLock()
entry, ok := e.virtualSeries[id]
e.virtualMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.virtualMu.Lock()
delete(e.virtualSeries, id)
e.virtualMu.Unlock()
}
return embySeriesGroup{}, false
}
return entry.group, true
}
func (e *EmbyService) cachedSeasonGroup(id string) (embySeasonGroup, bool) {
if e == nil || strings.TrimSpace(id) == "" {
return embySeasonGroup{}, false
}
now := time.Now()
e.virtualMu.RLock()
entry, ok := e.virtualSeasons[id]
e.virtualMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.virtualMu.Lock()
delete(e.virtualSeasons, id)
e.virtualMu.Unlock()
}
return embySeasonGroup{}, false
}
return entry.season, true
}
func (e *EmbyService) cachedArtworkURL(id, imageType string) (string, bool) {
if e == nil || strings.TrimSpace(id) == "" {
return "", false
}
now := time.Now()
e.virtualMu.RLock()
entry, ok := e.virtualArtwork[id]
e.virtualMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.virtualMu.Lock()
delete(e.virtualArtwork, id)
e.virtualMu.Unlock()
}
return "", false
}
switch strings.ToLower(imageType) {
case "backdrop", "art":
if entry.backdrop != "" {
return entry.backdrop, true
}
}
if entry.primary != "" {
return entry.primary, true
}
return entry.backdrop, entry.backdrop != ""
}
func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (embySeriesGroup, bool, error) {
if strings.TrimSpace(id) == "" {
return embySeriesGroup{}, false, nil
}
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if group, ok := e.cachedSeriesGroup(id); ok {
return group, true, nil
}
}
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, userID)
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
}
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(rows) {
if group.ID == id {
e.rememberSeriesGroup(group)
return group, true, nil
}
}
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
}, true, nil
}
}
return embySeriesGroup{}, false, nil
}
func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
if strings.TrimSpace(id) == "" || !strings.HasPrefix(id, embyVirtualSeasonPrefix) {
return embySeasonGroup{}, false, nil
}
if season, ok := e.cachedSeasonGroup(id); ok {
return season, true, nil
}
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(rows) {
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
return season, true, nil
}
}
}
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup {
byID := map[string]*embySeriesGroup{}
order := []string{}
for _, row := range rows {
row := row
seriesID := e.seriesIDForMedia(&row)
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(&row),
Year: row.Year,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.After(group.CreatedAt) {
group.CreatedAt = row.CreatedAt
}
if group.PosterURL == "" && row.PosterURL != "" {
group.PosterURL = row.PosterURL
}
if group.BackdropURL == "" && row.BackdropURL != "" {
group.BackdropURL = row.BackdropURL
}
if group.Overview == "" && row.Overview != "" {
group.Overview = row.Overview
}
if group.Rating == 0 && row.Rating > 0 {
group.Rating = row.Rating
}
if group.Year == 0 && row.Year > 0 {
group.Year = row.Year
}
group.Episodes = append(group.Episodes, row)
}
groups := make([]embySeriesGroup, 0, len(order))
for _, id := range order {
group := *byID[id]
sort.SliceStable(group.Episodes, func(i, j int) bool {
if group.Episodes[i].SeasonNum != group.Episodes[j].SeasonNum {
return group.Episodes[i].SeasonNum < group.Episodes[j].SeasonNum
}
if group.Episodes[i].EpisodeNum != group.Episodes[j].EpisodeNum {
return group.Episodes[i].EpisodeNum < group.Episodes[j].EpisodeNum
}
return group.Episodes[i].CreatedAt.Before(group.Episodes[j].CreatedAt)
})
groups = append(groups, group)
}
return groups
}
func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup {
bySeason := map[int]*embySeasonGroup{}
order := []int{}
for _, episode := range series.Episodes {
seasonNum := episode.SeasonNum
if seasonNum < 0 {
seasonNum = 1
}
season, ok := bySeason[seasonNum]
if !ok {
season = &embySeasonGroup{
ID: seasonID(series.ID, seasonNum),
SeriesID: series.ID,
LibraryID: series.LibraryID,
Name: seasonName(seasonNum),
SeasonNum: seasonNum,
Series: series,
}
bySeason[seasonNum] = season
order = append(order, seasonNum)
}
season.Episodes = append(season.Episodes, episode)
}
sort.Ints(order)
out := make([]embySeasonGroup, 0, len(order))
for _, seasonNum := range order {
out = append(out, *bySeason[seasonNum])
}
return out
}
func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
e.rememberSeriesGroup(group)
imageTags := map[string]string{}
backdropTags := []string{}
if group.PosterURL != "" {
imageTags["Primary"] = group.ID
}
if group.BackdropURL != "" {
backdropTags = append(backdropTags, group.ID+"-bd")
}
return map[string]any{
"Id": group.ID,
"Name": group.Name,
"ServerId": embyServerID,
"Type": "Series",
"MediaType": "Video",
"IsFolder": true,
"ParentId": group.LibraryID,
"ProductionYear": group.Year,
"Overview": group.Overview,
"CommunityRating": group.Rating,
"RecursiveItemCount": len(group.Episodes),
"ChildCount": len(e.seasonsForSeries(group)),
"DateCreated": group.CreatedAt,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"ProviderIds": map[string]string{
"Tmdb": intToStr(group.TMDbID),
"Bangumi": intToStr(group.BangumiID),
},
"UserData": emptyUserData(),
}
}
func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
e.rememberSeasonGroup(season)
imageTags := map[string]string{}
backdropTags := []string{}
if season.Series.PosterURL != "" {
imageTags["Primary"] = season.ID
}
if season.Series.BackdropURL != "" {
backdropTags = append(backdropTags, season.ID+"-bd")
}
return map[string]any{
"Id": season.ID,
"Name": season.Name,
"ServerId": embyServerID,
"Type": "Season",
"MediaType": "Video",
"IsFolder": true,
"ParentId": season.SeriesID,
"SeriesId": season.SeriesID,
"SeriesName": season.Series.Name,
"IndexNumber": season.SeasonNum,
"ChildCount": len(season.Episodes),
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"UserData": emptyUserData(),
}
}
// ImageURL returns artwork for a media/series/season item id.
func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (string, error) {
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
}
return "", nil
}
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
}
func (e *EmbyService) seriesIDForMedia(m *model.Media) string {
if strings.TrimSpace(m.SeriesID) != "" {
return m.SeriesID
}
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m))
}
func (e *EmbyService) seasonIDForMedia(m *model.Media) string {
return seasonID(e.seriesIDForMedia(m), m.SeasonNum)
}
func (e *EmbyService) seriesNameForMedia(m *model.Media) string {
if strings.TrimSpace(m.SeriesID) != "" {
if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" {
return series.Title
}
}
if name := inferSeriesNameFromPath(m.Path); name != "" {
return name
}
name := strings.TrimSpace(m.Title)
name = embyEpisodeTitleRE.ReplaceAllString(name, "")
name = embyYearSuffixRE.ReplaceAllString(name, "")
if name == "" {
name = strings.TrimSpace(m.OriginalName)
}
return name
}
func inferSeriesNameFromPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
dir := filepath.Dir(path)
base := filepath.Base(dir)
if embySeasonDirRE.MatchString(base) {
dir = filepath.Dir(dir)
base = filepath.Base(dir)
}
base = strings.TrimSpace(embyYearSuffixRE.ReplaceAllString(base, ""))
if base == "." || base == string(filepath.Separator) {
return ""
}
return base
}
func stableEmbyID(prefix string, parts ...string) string {
h := sha256.New()
for _, part := range parts {
_, _ = h.Write([]byte(strings.ToLower(strings.TrimSpace(part))))
_, _ = h.Write([]byte{0})
}
return prefix + hex.EncodeToString(h.Sum(nil))[:32]
}
func seasonID(seriesID string, seasonNum int) string {
if seasonNum < 0 {
seasonNum = 1
}
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum))
}
func seasonName(seasonNum int) string {
if seasonNum == 0 {
return "特别篇"
}
if seasonNum < 0 {
seasonNum = 1
}
return fmt.Sprintf("第 %d 季", seasonNum)
}
func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
switch strings.ToLower(p.SortBy) {
case "sortname", "name":
sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Descending") {
return groups[i].Name > groups[j].Name
}
return groups[i].Name < groups[j].Name
})
default:
sort.SliceStable(groups, func(i, j int) bool {
if strings.EqualFold(p.SortOrder, "Ascending") {
return groups[i].CreatedAt.Before(groups[j].CreatedAt)
}
return groups[i].CreatedAt.After(groups[j].CreatedAt)
})
}
}
func containsItemType(types []string, want string) bool {
for _, t := range types {
if strings.EqualFold(strings.TrimSpace(t), want) {
return true
}
}
return false
}
func containsSupportedEmbyItemType(types []string) bool {
for _, itemType := range types {
switch strings.ToLower(strings.TrimSpace(itemType)) {
case "movie", "series", "season", "episode", "video", "folder", "collectionfolder":
return true
}
}
return false
}
func containsOnlyFolderItemTypes(types []string) bool {
if len(types) == 0 {
return false
}
for _, itemType := range types {
switch strings.ToLower(strings.TrimSpace(itemType)) {
case "folder", "collectionfolder":
default:
return false
}
}
return true
}
func emptyItemsEnvelope(startIndex int) map[string]any {
return map[string]any{
"Items": []map[string]any{},
"TotalRecordCount": int64(0),
"StartIndex": startIndex,
}
}
func containsEmbyFilter(filters []string, want string) bool {
for _, filter := range filters {
if strings.EqualFold(strings.TrimSpace(filter), want) {
return true
}
}
return false
}
func firstCSVValue(value string) string {
if i := strings.Index(value, ","); i >= 0 {
value = value[:i]
}
return strings.TrimSpace(value)
}
func primarySupportedEmbySort(sortBy string, resumeFilter bool) string {
for _, part := range strings.Split(sortBy, ",") {
key := strings.ToLower(strings.TrimSpace(part))
switch key {
case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating":
return key
case "dateplayed":
if resumeFilter {
return key
}
}
}
return strings.ToLower(strings.TrimSpace(firstCSVValue(sortBy)))
}
func pageSlice[T any](items []T, start, limit int) []T {
if start < 0 {
start = 0
}
if limit <= 0 {
limit = len(items)
}
if start >= len(items) {
return []T{}
}
end := start + limit
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func emptyUserData() map[string]any {
return map[string]any{
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"PlayedPercentage": 0,
}
}
func (e *EmbyService) applyUserMediaVisibility(ctx context.Context, q *gorm.DB, userID string) *gorm.DB {
visibility := e.mediaVisibility(ctx, userID)
if !visibility.IncludeNSFW {
q = q.Where("nsfw = ?", false)
if hidden := visibility.HiddenLibraryIDs; len(hidden) > 0 {
q = q.Where("library_id NOT IN ?", hidden)
}
}
if len(visibility.AllowedLibraryIDs) > 0 {
q = q.Where("library_id IN ?", visibility.AllowedLibraryIDs)
}
return q
}
func (e *EmbyService) filterMediaRowsForUser(ctx context.Context, rows []model.Media, userID string) []model.Media {
visibility := e.mediaVisibility(ctx, userID)
if visibility.IncludeNSFW && len(visibility.AllowedLibraryIDs) == 0 {
return rows
}
allowed := map[string]bool{}
for _, id := range visibility.AllowedLibraryIDs {
allowed[id] = true
}
hiddenLibraries := map[string]bool{}
for _, id := range visibility.HiddenLibraryIDs {
hiddenLibraries[id] = true
}
out := rows[:0]
for _, row := range rows {
if row.NSFW && !visibility.IncludeNSFW {
continue
}
if hiddenLibraries[row.LibraryID] {
continue
}
if len(allowed) > 0 && !allowed[row.LibraryID] {
continue
}
out = append(out, row)
}
return out
}
func (e *EmbyService) mediaVisibility(ctx context.Context, userID string) MediaVisibility {
if e == nil {
return MediaVisibility{IncludeNSFW: true}
}
key := strings.TrimSpace(userID)
now := time.Now()
e.visibilityMu.RLock()
entry, ok := e.visibilityCache[key]
e.visibilityMu.RUnlock()
if ok && now.Before(entry.expiresAt) {
return cloneMediaVisibility(entry.visibility)
}
visibility := UserDefaultMediaVisibility(ctx, e.repo, userID)
if !visibility.IncludeNSFW {
visibility.HiddenLibraryIDs = e.hiddenLibraryIDs(ctx, visibility)
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, e.repo, visibility)
visibility = cloneMediaVisibility(visibility)
e.visibilityMu.Lock()
if e.visibilityCache == nil {
e.visibilityCache = make(map[string]embyVisibilityCacheEntry)
}
if len(e.visibilityCache) > 1000 {
e.visibilityCache = make(map[string]embyVisibilityCacheEntry)
}
e.visibilityCache[key] = embyVisibilityCacheEntry{
visibility: cloneMediaVisibility(visibility),
expiresAt: now.Add(embyVisibilityCacheTTL),
}
e.visibilityMu.Unlock()
return visibility
}
func (e *EmbyService) mergedLibraryIDs(ctx context.Context, libraryID string) []string {
ids, err := MergedLibraryIDsForLibrary(ctx, e.repo, libraryID)
if err != nil || len(ids) == 0 {
return []string{libraryID}
}
return ids
}
func cloneMediaVisibility(visibility MediaVisibility) MediaVisibility {
if visibility.AllowedLibraryIDs != nil {
visibility.AllowedLibraryIDs = append([]string(nil), visibility.AllowedLibraryIDs...)
}
if visibility.HiddenLibraryIDs != nil {
visibility.HiddenLibraryIDs = append([]string(nil), visibility.HiddenLibraryIDs...)
}
return visibility
}
func (e *EmbyService) libraryVisibleFromCachedVisibility(lib model.Library, visibility MediaVisibility) bool {
if len(visibility.AllowedLibraryIDs) > 0 {
allowed := false
for _, id := range visibility.AllowedLibraryIDs {
if id == lib.ID {
allowed = true
break
}
}
if !allowed {
return false
}
}
if visibility.IncludeNSFW {
return true
}
for _, id := range visibility.HiddenLibraryIDs {
if id == lib.ID {
return false
}
}
return true
}
func (e *EmbyService) hiddenLibraryIDs(ctx context.Context, visibility MediaVisibility) []string {
if visibility.IncludeNSFW {
return nil
}
libs, err := e.repo.Library.List(ctx)
if err != nil {
return nil
}
shadowed := ShadowedCloudLibraryIDSet(libs)
ids := make([]string, 0)
for _, lib := range libs {
if shadowed[lib.ID] || !LibraryVisibleForUser(ctx, e.repo, lib, visibility) {
ids = append(ids, lib.ID)
}
}
return ids
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// ─── Playback ────────────────────────────────────────────────────────────────
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) {
m, err := e.playableMedia(ctx, mediaID, userID)
if err != nil || m == nil {
return nil, err
}
e.ensureCloudTrackMetadata(ctx, m)
return map[string]any{
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
}, nil
}
// ensureCloudTrackMetadata 在后台补齐云盘媒体的轨道元数据。
//
// 注意必须是异步的:此前这里在 PlaybackInfo 请求路径上同步执行
// CloudResolve + ffprobe(HTTP)(最长 8 秒),既把第三方播放器的起播时间
// 拖长到秒级,又让每一次点开详情/起播都可能触发一次云盘数据下载,是
// Docker 部署下 CPU/带宽长期居高的来源之一。探测结果落库后,下一次
// 请求自然能读到完整元数据。
func (e *EmbyService) ensureCloudTrackMetadata(ctx context.Context, m *model.Media) {
if e == nil || m == nil || e.storage == nil || e.probe == nil || !mediaTrackMetadataMissing(m) {
return
}
typ, ref, ok := parseCloudMediaPlaybackURL(m.STRMURL)
if !ok {
return
}
mediaID := m.ID
e.cloudProbeMu.Lock()
if e.cloudProbeInFlight == nil {
e.cloudProbeInFlight = make(map[string]struct{})
}
if _, busy := e.cloudProbeInFlight[mediaID]; busy {
e.cloudProbeMu.Unlock()
return
}
e.cloudProbeInFlight[mediaID] = struct{}{}
e.cloudProbeMu.Unlock()
go func() {
defer func() {
e.cloudProbeMu.Lock()
delete(e.cloudProbeInFlight, mediaID)
e.cloudProbeMu.Unlock()
}()
probeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
link, err := e.storage.CloudResolve(probeCtx, typ, ref, "")
if err != nil {
if e.log != nil {
e.log.Debug("resolve cloud media for playback probe failed", zap.String("media_id", mediaID), zap.Error(err))
}
return
}
probe, err := e.probe.ProbeHTTP(probeCtx, link.URL, link.Headers)
if err != nil {
if e.log != nil {
e.log.Debug("playback cloud ffprobe failed", zap.String("media_id", mediaID), zap.Error(err))
}
return
}
updates := probeResultUpdates(probe)
if len(updates) == 0 {
return
}
if err := e.repo.DB.WithContext(probeCtx).Model(&model.Media{}).Where("id = ?", mediaID).Updates(updates).Error; err != nil && e.log != nil {
e.log.Debug("persist playback cloud probe failed", zap.String("media_id", mediaID), zap.Error(err))
}
}()
}
func mediaTrackMetadataMissing(m *model.Media) bool {
return m.DurationSec <= 0 ||
m.Width <= 0 ||
m.Height <= 0 ||
strings.TrimSpace(m.VideoCodec) == "" ||
strings.TrimSpace(m.AudioCodec) == ""
}
func parseCloudMediaPlaybackURL(raw string) (string, string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
u, err := url.Parse(raw)
if err != nil {
return "", "", false
}
path := strings.Trim(u.Path, "/")
const prefix = "api/cloud/play/"
idx := strings.Index(strings.ToLower(path), prefix)
if idx < 0 {
return "", "", false
}
typ := strings.TrimSpace(path[idx+len(prefix):])
ref := strings.TrimSpace(u.Query().Get("ref"))
return typ, ref, typ != "" && ref != ""
}
func applyProbeResultToMediaValue(m *model.Media, probe *ProbeResult) {
if m == nil || probe == nil {
return
}
if probe.DurationSec > 0 {
m.DurationSec = probe.DurationSec
}
if probe.Width > 0 {
m.Width = probe.Width
}
if probe.Height > 0 {
m.Height = probe.Height
}
if strings.TrimSpace(probe.VideoCodec) != "" {
m.VideoCodec = probe.VideoCodec
}
if strings.TrimSpace(probe.AudioCodec) != "" {
m.AudioCodec = probe.AudioCodec
}
if strings.TrimSpace(probe.Container) != "" {
m.Container = probe.Container
}
}
// directPlayOnly reports whether the admin enabled「客户端直连解码」mode.
// In that mode the host never transcodes; clients must direct-play.
func (e *EmbyService) directPlayOnly(ctx context.Context) bool {
if e.repo == nil || e.repo.Setting == nil {
return false
}
v, err := e.repo.Setting.Get(ctx, PlaybackDirectOnlySettingKey)
if err != nil {
return false
}
return parseBoolSetting(v, false)
}
func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*model.Media, error) {
if season, ok, err := e.findSeasonGroup(ctx, id, userID); err != nil {
return nil, err
} else if ok && len(season.Episodes) > 0 {
return &season.Episodes[0], nil
}
if series, ok, err := e.findSeriesGroup(ctx, id, userID); err != nil {
return nil, err
} else if ok && len(series.Episodes) > 0 {
return &series.Episodes[0], nil
}
m, err := e.repo.Media.FindByID(ctx, id)
if err != nil || m == nil {
return m, err
}
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
return nil, nil
}
return m, nil
}
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
//
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 指向 Emby 兼容
// /Videos/{id}/stream(客户端会继续携带 X-Emby-Token 或 append api_key)。
func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) map[string]any {
container := strings.Trim(strings.ToLower(m.Container), ". ")
if container == "" {
container = strings.TrimPrefix(strings.ToLower(filepath.Ext(m.Path)), ".")
}
if container == "" && strings.TrimSpace(m.STRMURL) != "" {
container = "strm"
}
isCloud := strings.TrimSpace(m.STRMURL) != ""
playURL := embyDirectStreamURL(m.ID, container)
if isCloud {
switch CloudPlaybackMode(ctx, e.repo) {
case CloudPlaybackModeSTRM:
playURL = embySTRMStreamURL(m.ID)
case CloudPlaybackModeRedirectProxy:
playURL = embyDirectStreamURL(m.ID, container)
default:
playURL = ""
}
}
if isCloud {
// Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS
// transcoding makes some Emby clients pick /master.m3u8, forcing this
// lightweight server to pull remote bytes through ffmpeg and often
// surfacing as "network/playback failed". Keep cloud media direct-only.
directOnly = true
}
src := map[string]any{
"Id": m.ID,
"Name": m.Title,
"Path": m.Path,
"Container": container,
"Size": m.SizeBytes,
"Protocol": "Http",
"Type": "Default",
"IsRemote": isCloud,
"RequiresOpening": false,
"RequiresClosing": false,
"ReadAtNativeFramerate": false,
"SupportsTranscoding": !directOnly,
// 云盘媒体的 Path 在 PlaybackInfo 阶段会被补上 api_key,且最终
// 302 到云盘直链。Infuse/Emby 官方客户端会优先挑选 DirectPlay
// 源;如果这里标 false,即使 DirectStreamUrl 可用,也可能被判定
// 为“没有可播放媒体源”。
"SupportsDirectStream": !isCloud || playURL != "",
"SupportsDirectPlay": !isCloud || playURL != "",
"SupportsProbing": true,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
"MediaStreams": e.mediaStreams(m),
}
if !asEmbedded && playURL != "" {
src["DirectStreamUrl"] = playURL
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
// 宿主机不参与转码。
if !directOnly {
src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8"
}
}
if strings.TrimSpace(m.STRMURL) != "" && playURL != "" {
// STRM / cloud:// media must stay behind a token-aware endpoint. When
// STRM playback is enabled we expose /api/stream so third-party clients
// follow the same STRM entry as generated .strm files; when disabled we
// expose /Videos/{id}/stream so playback uses the Emby 302/proxy path.
src["IsRemote"] = true
src["Path"] = playURL
}
return src
}
func (e *EmbyService) mediaSourcesForItem(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) []map[string]any {
siblings := e.mediaVersionSiblings(ctx, m)
if len(siblings) == 0 {
return []map[string]any{e.mediaSource(ctx, m, asEmbedded, directOnly)}
}
sources := make([]map[string]any, 0, len(siblings))
for i := range siblings {
media := siblings[i]
sources = append(sources, e.mediaSource(ctx, &media, asEmbedded, directOnly))
}
return sources
}
func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media) []model.Media {
if e == nil || e.repo == nil || e.repo.DB == nil || m == nil || strings.TrimSpace(m.ID) == "" {
return nil
}
libraryIDs := e.mergedLibraryIDs(ctx, m.LibraryID)
if len(libraryIDs) == 0 {
libraryIDs = []string{m.LibraryID}
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", libraryIDs).
Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
if m.TMDbID > 0 {
q = q.Where("tm_db_id = ?", m.TMDbID)
} else if m.BangumiID > 0 {
q = q.Where("bangumi_id = ?", m.BangumiID)
} else {
title := strings.TrimSpace(m.Title)
if title == "" {
title = strings.TrimSpace(m.OriginalName)
}
if title == "" {
return []model.Media{*m}
}
q = q.Where("LOWER(title) = ?", strings.ToLower(title))
if m.Year > 0 {
q = q.Where("year = ?", m.Year)
}
}
var rows []model.Media
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
return []model.Media{*m}
}
rows = e.collapseExactPathRows(rows)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].ID == m.ID {
return true
}
if rows[j].ID == m.ID {
return false
}
return preferMediaVersion(rows[i], rows[j])
})
return rows
}
func (e *EmbyService) collapseExactPathRows(rows []model.Media) []model.Media {
if len(rows) < 2 {
return rows
}
out := rows[:0]
seen := map[string]struct{}{}
for _, row := range rows {
path := strings.TrimSpace(row.Path)
if path != "" {
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
}
out = append(out, row)
}
return out
}
func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) string {
if e == nil || m == nil {
return ""
}
ids := e.mergedLibraryIDs(ctx, m.LibraryID)
sort.Strings(ids)
libraryGroup := strings.Join(ids, ",")
if libraryGroup == "" {
libraryGroup = strings.TrimSpace(m.LibraryID)
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
title = strings.ToLower(strings.TrimSpace(m.OriginalName))
}
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
}
func preferMediaVersion(candidate, current model.Media) bool {
candidateCloud := strings.TrimSpace(candidate.STRMURL) != "" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(candidate.Path)), "cloud://")
currentCloud := strings.TrimSpace(current.STRMURL) != "" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(current.Path)), "cloud://")
if candidateCloud != currentCloud {
return !candidateCloud
}
if candidate.Width != current.Width {
return candidate.Width > current.Width
}
if candidate.SizeBytes != current.SizeBytes {
return candidate.SizeBytes > current.SizeBytes
}
return candidate.CreatedAt.After(current.CreatedAt)
}
func embySTRMStreamURL(mediaID string) string {
return "/api/stream/" + url.PathEscape(strings.TrimSpace(mediaID))
}
func embyDirectStreamURL(mediaID, container string) string {
mediaID = strings.TrimSpace(mediaID)
container = strings.Trim(strings.ToLower(container), ". ")
if container == "" || container == "strm" {
return "/Videos/" + mediaID + "/stream"
}
return "/Videos/" + mediaID + "/stream." + container
}
func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
streams := []map[string]any{}
if m.VideoCodec != "" || m.Width > 0 {
streams = append(streams, map[string]any{
"Codec": m.VideoCodec,
"Type": "Video",
"Index": 0,
"Width": m.Width,
"Height": m.Height,
"AspectRatio": "",
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
"DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec),
})
}
if m.AudioCodec != "" {
streams = append(streams, map[string]any{
"Codec": m.AudioCodec,
"Type": "Audio",
"Index": 1,
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
})
}
if len(streams) == 0 {
streams = append(streams, map[string]any{
"Codec": "unknown",
"Type": "Video",
"Index": 0,
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
"DisplayTitle": "Video",
})
}
return streams
}
// ─── 收藏 / 已看(Emby 客户端写路径) ──────────────────────────────────────
// SetFavorite 把 mediaID 标为 userID 的收藏。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if favorite {
var f model.Favorite
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return e.repo.DB.WithContext(ctx).Create(&model.Favorite{
UserID: userID, MediaID: mediaID,
}).Error
}
return err
}
return e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
if !played {
return e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.PlaybackHistory{}).Error
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: true,
})
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
pos := positionTicks / 10_000
dur := runtimeTicks / 10_000
if dur <= 0 {
// runtimeTicks 缺失时回退到 media.DurationSec
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
dur = int64(m.DurationSec) * 1000
}
}
completed := dur > 0 && pos >= dur*9/10
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
})
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return []string{}
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func intToStr(v int) string {
if v == 0 {
return ""
}
return strconv.Itoa(v)
}