mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
2506 lines
79 KiB
Go
2506 lines
79 KiB
Go
// 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)
|
||
}
|