mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
258 lines
8.2 KiB
Go
258 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
)
|
|
|
|
// 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 e.probeCloudTrackMetadata(mediaID, typ, ref)
|
|
}
|
|
|
|
func (e *EmbyService) probeCloudTrackMetadata(mediaID, typ, ref string) {
|
|
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 := embyMediaContainer(m)
|
|
isCloud := strings.TrimSpace(m.STRMURL) != ""
|
|
playURL := e.embyMediaPlayURL(ctx, m, container, isCloud)
|
|
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 := e.baseMediaSource(m, container, isCloud, playURL, directOnly)
|
|
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) baseMediaSource(m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
|
|
return 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,
|
|
"SupportsDirectStream": !isCloud || playURL != "",
|
|
"SupportsDirectPlay": !isCloud || playURL != "",
|
|
"SupportsProbing": true,
|
|
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
|
"MediaStreams": e.mediaStreams(m),
|
|
}
|
|
}
|
|
|
|
func embyMediaContainer(m *model.Media) string {
|
|
container := strings.Trim(strings.ToLower(m.Container), ". ")
|
|
if container == "" {
|
|
container = strings.TrimPrefix(strings.ToLower(filepath.Ext(m.Path)), ".")
|
|
}
|
|
if container == "" && strings.TrimSpace(m.STRMURL) != "" {
|
|
return "strm"
|
|
}
|
|
return container
|
|
}
|
|
|
|
func (e *EmbyService) embyMediaPlayURL(ctx context.Context, m *model.Media, container string, isCloud bool) string {
|
|
if !isCloud {
|
|
return embyDirectStreamURL(m.ID, container)
|
|
}
|
|
switch CloudPlaybackMode(ctx, e.repo) {
|
|
case CloudPlaybackModeSTRM:
|
|
return embySTRMStreamURL(m.ID)
|
|
case CloudPlaybackModeRedirectProxy:
|
|
return embyDirectStreamURL(m.ID, container)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|