Files
MeBox/internal/service/emby_media_sources.go
T
2026-09-09 22:03:52 +08:00

332 lines
11 KiB
Go

package service
import (
"context"
"fmt"
"net/url"
"sort"
"strings"
"github.com/truewhile/MeBox/internal/model"
)
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}
}
targetKey := e.mediaVersionKey(ctx, m)
filtered := rows[:0]
for i := range rows {
if e.mediaVersionKey(ctx, &rows[i]) == targetKey {
filtered = append(filtered, rows[i])
}
}
rows = filtered
if 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)
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
}
}
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
}
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if stemKey := mediaVersionStemGroupKey(*m, libraryGroup); stemKey != "" {
return stemKey + kindKey
}
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
}
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%s", libraryGroup, title, m.Year, season, episode, kindKey)
}
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(ctx context.Context, 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",
})
}
streams = e.appendSubtitleStreams(ctx, streams, m)
return streams
}
// appendSubtitleStreams discovers sideloaded external subtitle tracks next to
// the video and advertises them as Emby Subtitle MediaStreams so third-party
// clients can request them via /Videos/:id/Subtitles/:index/Stream. When no
// service is wired, discovery fails, or there are no tracks, the existing
// stream list is returned unchanged.
//
// The streams follow the official Emby external-subtitle contract:
// - Codec reports the source codec (ass/ssa/subrip/vtt), matching the RAW
// bytes served at the DeliveryUrl (see ServeSubtitleStream / ServeRaw).
// - Index is stable and global across the MediaSource (Video 0, Audio 1,
// subtitles 2, 3, ...). It never shifts to 1 when audio is absent.
// - IsDefault is false (external subtitles are never auto-selected).
// - Path and DeliveryMethod identify the sidecar file.
func (e *EmbyService) appendSubtitleStreams(ctx context.Context, streams []map[string]any, m *model.Media) []map[string]any {
if e == nil || e.subtitle == nil || m == nil {
return streams
}
// Emby 字幕只列表外挂字幕文件:云盘/strm 媒体的容器内嵌字幕不做服务端
// 提取,客户端直连播放直链时自行解析。
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
if err != nil || len(tracks) == 0 {
return streams
}
// Subtitle indexes continue after Video (0) and Audio (1). When Audio is
// absent (e.g. STRM media), the fallback "unknown" Video stream fills slot 0
// and subtitles still start at 1; when Audio is present they start at 2.
next := 1
if m.AudioCodec != "" {
next = 2
}
mediaID := strings.TrimSpace(m.ID)
for _, t := range tracks {
index := next
next++
codec := subtitleCodecFromExt(t.Codec)
streams = append(streams, map[string]any{
"Codec": codec,
"DeliveryFormat": codec,
"Container": codec,
"Type": "Subtitle",
"Index": index,
"IsExternal": true,
"IsForced": false,
"IsDefault": false,
"Language": t.Lang,
"DisplayTitle": subtitleDisplayTitle(t),
"Path": strings.TrimSpace(t.Path),
"DeliveryMethod": "External",
// Official Emby / Swagger shape:
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
// MediaSourceId is a bare path parameter whose value is the
// MediaSource.Id (== m.ID here). No "mediasource_" prefix: that
// prefix only appears in real servers because their MediaSource.Id
// value itself starts with it, not because the route template says so.
"IsTextSubtitleStream": true, // external sidecar files are always text subtitles
"SupportsExternalStream": true,
"DeliveryUrl": "/Videos/" + mediaID + "/" + mediaID + "/Subtitles/" + fmt.Sprint(index) + "/Stream." + codec,
})
}
return streams
}
// subtitleCodecFromExt maps a subtitle source codec identifier (which is the
// track's extension-derived codec, e.g. "srt"/"ass"/"ssa"/"vtt") to the codec
// name Emby clients expect in MediaStreams. SRT is advertised as "subrip" (the
// official Emby/Jellyfin naming) while ASS/SSA/VTT keep their names.
func subtitleCodecFromExt(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "srt":
return "subrip"
default:
return strings.ToLower(strings.TrimSpace(codec))
}
}
// SubtitleContentType returns a Content-Type for a subtitle codec name.
func SubtitleContentType(codec string) string {
switch strings.ToLower(strings.TrimSpace(codec)) {
case "srt", "subrip":
return "application/x-subrip; charset=utf-8"
case "vtt":
return "text/vtt; charset=utf-8"
default: // ass, ssa
return "text/plain; charset=utf-8"
}
}
// SubtitleCodecFromFormat maps a subtitle format suffix from a delivery URL
// (e.g. "ass", "srt", "subrip", "vtt", "ssa") to the codec name used for the
// Content-Type header. Unknown formats are returned lowercase unchanged.
func SubtitleCodecFromFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "srt", "subrip":
return "subrip"
case "vtt":
return "vtt"
case "ass":
return "ass"
case "ssa":
return "ssa"
default:
return strings.ToLower(strings.TrimSpace(format))
}
}
func subtitleDisplayTitle(t SubtitleTrack) string {
// Prefer an explicit language label (e.g. "中文", "en"), otherwise fall back
// to a friendly "字幕 (ASS)"-style label matching official Emby instead of a
// bare "und" or a raw filename.
if label := strings.TrimSpace(t.Label); label != "" && !strings.EqualFold(label, "und") {
return label
}
codec := subtitleCodecFromExt(t.Codec)
codecDisplay := strings.ToUpper(strings.TrimSpace(codec))
if codecDisplay == "" {
codecDisplay = "Subtitle"
}
return "字幕 (" + codecDisplay + ")"
}