// 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/sha1" "encoding/hex" "errors" "fmt" "path/filepath" "regexp" "sort" "strconv" "strings" "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" ) // 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到 // 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。 const embyServerID = "mediastation-go-001" // EmbyService produces Emby-shaped JSON. type EmbyService struct { cfg *config.Config log *zap.Logger repo *repository.Container } // NewEmbyService is the constructor. func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService { return &EmbyService{cfg: cfg, log: log, repo: repo} } // ─── 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": "10.8.13", "ProductName": "Jellyfin 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": "10.8.13", "ProductName": "Jellyfin 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 { 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": true, "EnableSyncTranscoding": true, "EnableMediaConversion": true, "EnableAllChannels": true, "EnableAllFolders": true, "EnableAllDevices": true, "AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider", "PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider", }, } } // ─── Views / MediaFolders ──────────────────────────────────────────────────── // Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。 func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) { libs, err := e.repo.Library.List(ctx) if err != nil { return nil, err } items := make([]map[string]any, 0, len(libs)) for _, l := range libs { items = append(items, e.libraryAsView(&l)) } return map[string]any{"Items": items, "TotalRecordCount": len(items)}, 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, "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 Recursive bool SortBy string SortOrder string Limit int StartIndex int } const ( embyVirtualSeriesPrefix = "msgo-series-" embyVirtualSeasonPrefix = "msgo-season-" ) var ( embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*\d+\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 } // 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.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 p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 { return e.Views(ctx) } if season, ok, err := e.findSeasonGroup(ctx, p.ParentID); err != nil { return nil, err } else if ok { return e.episodeItems(ctx, season.Episodes, p) } if series, ok, err := e.findSeriesGroup(ctx, p.ParentID); 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) } return e.mediaItems(ctx, p) } func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) { q := e.repo.DB.WithContext(ctx).Model(&model.Media{}) if p.ParentID != "" { q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID) } if p.SearchTerm != "" { q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") } if containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { q = q.Where("season_num = 0 AND episode_num = 0") } if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") { q = q.Where("season_num > 0 OR episode_num > 0") } var total int64 if err := q.Count(&total).Error; err != nil { return nil, err } order := "created_at desc" switch strings.ToLower(p.SortBy) { case "sortname", "name": order = "title" case "premieredate", "productionyear": order = "year" case "datecreated": order = "created_at" case "communityrating": order = "rating" } if strings.EqualFold(p.SortOrder, "Descending") { if !strings.HasSuffix(order, " desc") { order = order + " desc" } } var rows []model.Media if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil { return nil, err } items, err := e.payloadsForMedia(ctx, rows, p.UserID) if err != nil { return nil, err } return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil } func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) { 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) { userFavs := map[string]bool{} userPos := map[string]int64{} if userID != "" { var favs []model.Favorite _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&favs).Error for _, f := range favs { userFavs[f.MediaID] = true } var hist []model.PlaybackHistory _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).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(&m, userFavs[m.ID], userPos[m.ID])) } return items, nil } // Item 单条目详情。 func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) { if season, ok, err := e.findSeasonGroup(ctx, mediaID); 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); 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); err != nil { return nil, err } else if ok { return e.seriesPayload(series), nil } 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(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 } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") if parentID != "" { if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic { resp, err := e.seriesItemsForLibrary(ctx, parentID, ItemsParams{ UserID: userID, ParentID: parentID, Limit: limit, StartIndex: 0, SortBy: "datecreated", SortOrder: "Descending", }) if err != nil { return nil, err } items, _ := resp["Items"].([]map[string]any) return items, nil } q = q.Where("library_id = ?", parentID) } var rows []model.Media if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil { return nil, err } favs := map[string]bool{} if userID != "" { var fr []model.Favorite _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).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(&m, favs[m.ID], 0)) } return out, 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 if err := e.repo.DB.WithContext(ctx).Where("id IN ?", ids).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(m, false, posByID[h.MediaID])) } } return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil } func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any { itemType := "Movie" name := m.Title parentID := m.LibraryID seriesID := m.SeriesID seriesName := "" seasonID := "" if m.SeasonNum > 0 || m.EpisodeNum > 0 { 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{} if m.PosterURL != "" { imageTags["Primary"] = m.ID } if m.BackdropURL != "" { 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": []map[string]any{e.mediaSource(m, true)}, } } 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") if libraryID != "" { q = q.Where("library_id = ?", libraryID) } if p.SearchTerm != "" { q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%") } var rows []model.Media if err := q.Order("created_at desc").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 { switch lib.Type { case "tv", "anime", "variety": return true, nil } } var count int64 err := e.repo.DB.WithContext(ctx).Model(&model.Media{}). Where("library_id = ? AND (season_num > 0 OR episode_num > 0)", libraryID). Count(&count).Error return count > 0, err } func (e *EmbyService) findSeriesGroup(ctx context.Context, id string) (embySeriesGroup, bool, error) { if strings.TrimSpace(id) == "" { return embySeriesGroup{}, false, nil } var rows []model.Media q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0") if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { q = q.Where("series_id = ?", id) } if err := q.Order("season_num asc, episode_num asc, created_at asc").Find(&rows).Error; err != nil { return embySeriesGroup{}, false, err } for _, group := range e.seriesGroupsFromMedia(rows) { if group.ID == id { 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 string) (embySeasonGroup, bool, error) { if strings.TrimSpace(id) == "" || !strings.HasPrefix(id, embyVirtualSeasonPrefix) { return embySeasonGroup{}, false, nil } var rows []model.Media if err := e.repo.DB.WithContext(ctx).Model(&model.Media{}). Where("season_num > 0 OR episode_num > 0"). Order("season_num asc, episode_num asc, created_at asc"). 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 { 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 { 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 { 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 season, ok, err := e.findSeasonGroup(ctx, id); err != nil { return "", err } else if ok { return pick(season.Series.PosterURL, season.Series.BackdropURL), nil } } if strings.HasPrefix(id, embyVirtualSeriesPrefix) { if series, ok, err := e.findSeriesGroup(ctx, id); err != nil { return "", err } else if ok { return pick(series.PosterURL, series.BackdropURL), nil } } m, err := e.repo.Media.FindByID(ctx, id) if err == nil && m != nil { return pick(m.PosterURL, m.BackdropURL), 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) 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), maxInt(m.SeasonNum, 1)) } 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 := sha1.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 { return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(maxInt(seasonNum, 1))) } func seasonName(seasonNum int) string { 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 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 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 string) (map[string]any, error) { m, err := e.playableMedia(ctx, mediaID) if err != nil || m == nil { return nil, err } return map[string]any{ "MediaSources": []map[string]any{e.mediaSource(m, false)}, "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()), }, nil } func (e *EmbyService) playableMedia(ctx context.Context, id string) (*model.Media, error) { if season, ok, err := e.findSeasonGroup(ctx, id); 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); err != nil { return nil, err } else if ok && len(series.Episodes) > 0 { return &series.Episodes[0], nil } return e.repo.Media.FindByID(ctx, id) } // 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(m *model.Media, asEmbedded bool) map[string]any { src := map[string]any{ "Id": m.ID, "Name": m.Title, "Path": m.Path, "Container": m.Container, "Size": m.SizeBytes, "Protocol": "Http", "Type": "Default", "IsRemote": false, "SupportsTranscoding": true, "SupportsDirectStream": true, "SupportsDirectPlay": true, "SupportsProbing": true, "RunTimeTicks": int64(m.DurationSec) * 10_000_000, "MediaStreams": e.mediaStreams(m), } if !asEmbedded { src["DirectStreamUrl"] = "/Videos/" + m.ID + "/stream" src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream" } if strings.TrimSpace(m.STRMURL) != "" { // STRM 重定向:客户端直接拉远端,跳过我们这一层。 src["IsRemote"] = true src["DirectStreamUrl"] = m.STRMURL src["Path"] = m.STRMURL } return src } 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, }) } 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) }