mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
18e6cb40fd
Co-authored-by: Cursor <cursoragent@cursor.com>
332 lines
11 KiB
Go
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 + ")"
|
|
}
|