mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
添加对外挂字幕的支持
添加对字幕的支持
This commit is contained in:
@@ -50,6 +50,7 @@ type EmbyService struct {
|
||||
storage cloudPlaybackResolver
|
||||
probe cloudPlaybackProber
|
||||
cache *RuntimeCacheService
|
||||
subtitle *SubtitleService
|
||||
|
||||
virtualMu sync.RWMutex
|
||||
virtualSeries map[string]embySeriesCacheEntry
|
||||
@@ -91,6 +92,17 @@ func (e *EmbyService) SetCloudProbe(storage cloudPlaybackResolver, probe cloudPl
|
||||
e.probe = probe
|
||||
}
|
||||
|
||||
// SetSubtitleService wires the external-subtitle discovery service into the
|
||||
// Emby shim so MediaStreams can advertise sideloaded subtitle tracks. It is
|
||||
// nil-safe: when subtitle is nil, mediaStreams simply emits no subtitle
|
||||
// streams and keeps the pre-existing Video/Audio behaviour.
|
||||
func (e *EmbyService) SetSubtitleService(subtitle *SubtitleService) *EmbyService {
|
||||
if e != nil {
|
||||
e.subtitle = subtitle
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ─── Items ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
|
||||
|
||||
@@ -141,7 +141,7 @@ func embyDirectStreamURL(mediaID, container string) string {
|
||||
return "/Videos/" + mediaID + "/stream." + container
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
|
||||
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{
|
||||
@@ -178,5 +178,124 @@ func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
|
||||
"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
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(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 + ")"
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,6 +28,79 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ServeSubtitleStream resolves the Emby /Videos/:id/Subtitles/:index/Stream
|
||||
// request to one of the media's sideloaded external subtitle tracks and writes
|
||||
// the original (unconverted) subtitle bytes to w — matching the source Codec
|
||||
// advertised in MediaStreams. The index mapping mirrors appendSubtitleStreams
|
||||
// so the DeliveryUrl advertised in MediaStreams lines up exactly with the
|
||||
// served track: subtitles start at 1 when no audio stream is present, otherwise
|
||||
// at 2 (after Video 0 + Audio 1).
|
||||
func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, mediaID, indexStr string, userID string) error {
|
||||
if e == nil || e.subtitle == nil {
|
||||
return ErrSubtitleUnavailable
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
if err != nil || m == nil {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
index, err := strconv.Atoi(strings.TrimSpace(indexStr))
|
||||
if err != nil || index < 1 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
first := 1
|
||||
if strings.TrimSpace(m.AudioCodec) != "" {
|
||||
first = 2
|
||||
}
|
||||
offset := index - first
|
||||
if offset < 0 || offset >= len(tracks) {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
return e.subtitle.ServeRaw(ctx, m.ID, tracks[offset].Path, w)
|
||||
}
|
||||
|
||||
// SubtitleStreamCodec resolves the source codec (ass/subrip/vtt/ssa) for a
|
||||
// given Emby subtitle index so the handler can set an accurate Content-Type
|
||||
// header before streaming the raw bytes.
|
||||
func (e *EmbyService) SubtitleStreamCodec(ctx context.Context, mediaID, indexStr, userID string) string {
|
||||
if e == nil || e.subtitle == nil {
|
||||
return ""
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
if err != nil || m == nil {
|
||||
return ""
|
||||
}
|
||||
index, err := strconv.Atoi(strings.TrimSpace(indexStr))
|
||||
if err != nil || index < 1 {
|
||||
return ""
|
||||
}
|
||||
tracks, err := e.subtitle.Discover(ctx, m.ID)
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
first := 1
|
||||
if strings.TrimSpace(m.AudioCodec) != "" {
|
||||
first = 2
|
||||
}
|
||||
offset := index - first
|
||||
if offset < 0 || offset >= len(tracks) {
|
||||
return ""
|
||||
}
|
||||
return subtitleCodecFromExt(tracks[offset].Codec)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrSubtitleUnavailable is returned when the Emby shim has no subtitle
|
||||
// service wired and therefore cannot serve external subtitle streams.
|
||||
ErrSubtitleUnavailable = fmt.Errorf("subtitle service unavailable")
|
||||
// ErrSubtitleNotFound is returned when a requested subtitle index does not
|
||||
// map to a discovered external subtitle track.
|
||||
ErrSubtitleNotFound = fmt.Errorf("subtitle not found")
|
||||
)
|
||||
|
||||
// ensureCloudTrackMetadata 在后台补齐云盘媒体的轨道元数据。
|
||||
//
|
||||
// 注意必须是异步的:此前这里在 PlaybackInfo 请求路径上同步执行
|
||||
@@ -189,7 +264,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
||||
// surfacing as "network/playback failed". Keep cloud media direct-only.
|
||||
directOnly = true
|
||||
}
|
||||
src := e.baseMediaSource(m, container, isCloud, playURL, directOnly)
|
||||
src := e.baseMediaSource(ctx, m, container, isCloud, playURL, directOnly)
|
||||
if !asEmbedded && playURL != "" {
|
||||
src["DirectStreamUrl"] = playURL
|
||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||
@@ -208,7 +283,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
||||
return src
|
||||
}
|
||||
|
||||
func (e *EmbyService) baseMediaSource(m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
|
||||
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": m.ID,
|
||||
"Name": m.Title,
|
||||
@@ -226,7 +301,7 @@ func (e *EmbyService) baseMediaSource(m *model.Media, container string, isCloud
|
||||
"SupportsDirectPlay": !isCloud || playURL != "",
|
||||
"SupportsProbing": true,
|
||||
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
||||
"MediaStreams": e.mediaStreams(m),
|
||||
"MediaStreams": e.mediaStreams(ctx, m),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// writeTempVideoWithSubtitle creates a temp directory with a fake video and a
|
||||
// same-name external subtitle, and inserts the media row into the test DB. It
|
||||
// returns the media id. The subtitle content is valid ASS for .ass/.ssa and
|
||||
// valid SRT for .srt.
|
||||
func writeTempVideoWithSubtitle(t *testing.T, svc *EmbyService, lib *model.Library, container, subExt string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
video := "MovieName" + container
|
||||
sub := "MovieName" + subExt
|
||||
if err := os.WriteFile(filepath.Join(dir, video), []byte("video"), 0o644); err != nil {
|
||||
t.Fatalf("write video: %v", err)
|
||||
}
|
||||
var subBody string
|
||||
switch strings.ToLower(subExt) {
|
||||
case ".srt":
|
||||
subBody = "1\n00:00:01,000 --> 00:00:02,000\nhello\n"
|
||||
default:
|
||||
subBody = "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,hello\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, sub), []byte(subBody), 0o644); err != nil {
|
||||
t.Fatalf("write subtitle: %v", err)
|
||||
}
|
||||
m := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "MovieName",
|
||||
Path: filepath.Join(dir, video),
|
||||
Container: container,
|
||||
VideoCodec: "h264",
|
||||
AudioCodec: "aac",
|
||||
}
|
||||
if err := svc.repo.DB.Create(&m).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
|
||||
func newTestSubtitleService(t *testing.T, svc *EmbyService) *SubtitleService {
|
||||
t.Helper()
|
||||
return NewSubtitleService(zap.NewNop(), svc.repo)
|
||||
}
|
||||
|
||||
func TestEmbyMediaStreamsAttachSameNameSubtitle(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
mediaID := writeTempVideoWithSubtitle(t, svc, &lib, ".mp4", ".ass")
|
||||
svc.SetSubtitleService(newTestSubtitleService(t, svc))
|
||||
|
||||
m, err := svc.repo.Media.FindByID(t.Context(), mediaID)
|
||||
if err != nil || m == nil {
|
||||
t.Fatalf("find media: %v", err)
|
||||
}
|
||||
streams := svc.mediaStreams(t.Context(), m)
|
||||
|
||||
// Video (0) + Audio (1) + one Subtitle (2)
|
||||
if len(streams) != 3 {
|
||||
t.Fatalf("expected 3 streams (video/audio/subtitle), got %d: %#v", len(streams), streams)
|
||||
}
|
||||
sub := streams[2]
|
||||
if sub["Type"] != "Subtitle" {
|
||||
t.Fatalf("stream[2] type = %v, want Subtitle", sub["Type"])
|
||||
}
|
||||
if sub["Index"] != 2 {
|
||||
t.Fatalf("subtitle index = %v, want 2", sub["Index"])
|
||||
}
|
||||
// Source codec (ASS) must be reported, and IsDefault must be false to match
|
||||
// the official Emby external-subtitle contract.
|
||||
if sub["Codec"] != "ass" {
|
||||
t.Fatalf("subtitle codec = %v, want ass", sub["Codec"])
|
||||
}
|
||||
if sub["DeliveryFormat"] != "ass" {
|
||||
t.Fatalf("subtitle delivery format = %v, want ass", sub["DeliveryFormat"])
|
||||
}
|
||||
if sub["IsDefault"] != false {
|
||||
t.Fatalf("subtitle IsDefault = %v, want false", sub["IsDefault"])
|
||||
}
|
||||
if sub["DeliveryMethod"] != "External" {
|
||||
t.Fatalf("subtitle DeliveryMethod = %v, want External", sub["DeliveryMethod"])
|
||||
}
|
||||
if got, _ := sub["Path"].(string); got == "" {
|
||||
t.Fatalf("subtitle Path should not be empty: %#v", sub)
|
||||
}
|
||||
// Official Emby shape: /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
|
||||
// (no "mediasource_" prefix; that prefix belongs to the Id value, not the route)
|
||||
wantURL := "/Videos/" + mediaID + "/" + mediaID + "/Subtitles/2/Stream.ass"
|
||||
if sub["DeliveryUrl"] != wantURL {
|
||||
t.Fatalf("DeliveryUrl = %v, want %v", sub["DeliveryUrl"], wantURL)
|
||||
}
|
||||
if sub["IsTextSubtitleStream"] != true {
|
||||
t.Fatalf("IsTextSubtitleStream = %v, want true", sub["IsTextSubtitleStream"])
|
||||
}
|
||||
if sub["SupportsExternalStream"] != true {
|
||||
t.Fatalf("SupportsExternalStream = %v, want true", sub["SupportsExternalStream"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMediaStreamsSubtitleDisplayTitleFriendly(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
mediaID := writeTempVideoWithSubtitle(t, svc, &lib, ".mp4", ".ass")
|
||||
svc.SetSubtitleService(newTestSubtitleService(t, svc))
|
||||
|
||||
m, err := svc.repo.Media.FindByID(t.Context(), mediaID)
|
||||
if err != nil || m == nil {
|
||||
t.Fatalf("find media: %v", err)
|
||||
}
|
||||
streams := svc.mediaStreams(t.Context(), m)
|
||||
sub := streams[2]
|
||||
// No language tag on the filename -> should fall back to "字幕 (ASS)",
|
||||
// matching official Emby's friendly label rather than a bare "und".
|
||||
if sub["DisplayTitle"] != "字幕 (ASS)" {
|
||||
t.Fatalf("DisplayTitle = %v, want 字幕 (ASS)", sub["DisplayTitle"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyServeSubtitleStreamServesRawSource(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
mediaID := writeTempVideoWithSubtitle(t, svc, &lib, ".mp4", ".ass")
|
||||
svc.SetSubtitleService(newTestSubtitleService(t, svc))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ServeSubtitleStream(t.Context(), &buf, mediaID, "2", ""); err != nil {
|
||||
t.Fatalf("serve subtitle: %v", err)
|
||||
}
|
||||
// Emby must get the RAW ASS source, NOT a WebVTT conversion.
|
||||
if bytes.Contains(buf.Bytes(), []byte("WEBVTT")) {
|
||||
t.Fatalf("served subtitle should be raw ASS, got WebVTT: %q", buf.String())
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("Dialogue:")) {
|
||||
t.Fatalf("served subtitle should contain raw ASS Dialogue lines: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyServeSubtitleStreamBadIndexNotFound(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
mediaID := writeTempVideoWithSubtitle(t, svc, &lib, ".mp4", ".ass")
|
||||
svc.SetSubtitleService(newTestSubtitleService(t, svc))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ServeSubtitleStream(t.Context(), &buf, mediaID, "99", ""); err != ErrSubtitleNotFound {
|
||||
t.Fatalf("expected ErrSubtitleNotFound for index 99, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMediaStreamsNoSubtitleServiceKeepsVideoAudio(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
// Intentionally do NOT SetSubtitleService.
|
||||
mediaID := writeTempVideoWithSubtitle(t, svc, &lib, ".mp4", ".srt")
|
||||
|
||||
m, err := svc.repo.Media.FindByID(t.Context(), mediaID)
|
||||
if err != nil || m == nil {
|
||||
t.Fatalf("find media: %v", err)
|
||||
}
|
||||
streams := svc.mediaStreams(t.Context(), m)
|
||||
if len(streams) != 2 {
|
||||
t.Fatalf("expected 2 streams (video/audio) without subtitle service, got %d: %#v", len(streams), streams)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.Subtitle.SetStorageConfig(b.c.StorageCfg)
|
||||
b.c.Emby.SetRuntimeCache(b.c.Cache)
|
||||
b.c.Emby.SetCloudProbe(b.c.StorageCfg, b.c.FFprobe)
|
||||
b.c.Emby.SetSubtitleService(b.c.Subtitle)
|
||||
b.c.DownloadClients = NewDownloadClientService(b.log, b.repos)
|
||||
b.c.Assistant = NewAssistantService(b.log, b.repos, b.c.AI)
|
||||
b.c.Scheduler = NewSchedulerService(
|
||||
|
||||
@@ -182,3 +182,35 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ServeRaw writes the subtitle file in its original format without any
|
||||
// WebVTT conversion. Emby/Jellyfin clients advertise the source codec (ASS,
|
||||
// subrip, etc.) in MediaStreams, then fetch the subtitle bytes via the
|
||||
// DeliveryUrl and parse them with a decoder matching that codec — so the bytes
|
||||
// must be the unmodified source, not a conversion. Unlike Serve (used by the
|
||||
// browser <track> path, which requires WebVTT), ServeRaw preserves the file
|
||||
// exactly as-is. Same path-safety constraints as Serve.
|
||||
func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w io.Writer) error {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok {
|
||||
return serveCloudSubtitleRaw(ctx, s, *m, typ, ref, name, w)
|
||||
}
|
||||
abs, err := filepath.Abs(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
|
||||
if !pathWithin(abs, mediaDir) {
|
||||
return fmt.Errorf("path escape")
|
||||
}
|
||||
f, err := os.Open(abs) // #nosec G304 -- abs is constrained to the media file directory with pathWithin.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -170,3 +170,39 @@ func serveCloudSubtitle(ctx context.Context, s *SubtitleService, m model.Media,
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// serveCloudSubtitleRaw streams a cloud subtitle in its original format
|
||||
// without WebVTT conversion, for Emby clients that parse by the advertised
|
||||
// Codec. Shares the same access-control (discovery whitelist) checks as
|
||||
// serveCloudSubtitle.
|
||||
func serveCloudSubtitleRaw(ctx context.Context, s *SubtitleService, m model.Media, typ, ref, name string, w io.Writer) error {
|
||||
if s == nil || s.storage == nil {
|
||||
return errors.New("cloud storage service unavailable")
|
||||
}
|
||||
mediaTyp, _, ok := cloudSubtitleMediaRef(m)
|
||||
if !ok || mediaTyp != typ {
|
||||
return ErrCloudPlaybackUnavailable
|
||||
}
|
||||
allowed := false
|
||||
for _, track := range discoverCloudSubtitles(ctx, s, m) {
|
||||
if track.Path == buildCloudSubtitlePath(typ, ref, name) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("path escape")
|
||||
}
|
||||
body, err := s.storage.CloudReadText(ctx, typ, ref, 8<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(firstNonEmpty(name, ref)))
|
||||
switch ext {
|
||||
case ".vtt", ".srt", ".ass", ".ssa":
|
||||
_, err = io.WriteString(w, body)
|
||||
default:
|
||||
return errors.New("unsupported subtitle format")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -56,7 +56,31 @@ func normaliseTimecode(t string) string {
|
||||
if len(hh) == 1 {
|
||||
hh = "0" + hh
|
||||
}
|
||||
return hh + ":" + parts[1] + ":" + strings.ReplaceAll(parts[2], ".", ".")
|
||||
// The final segment may be "ss" or "ss.mm"/"ss.mmm"/"ss.frames". WebVTT
|
||||
// requires hh:mm:ss.mmm, so pad the millisecond part to 3 digits and cap
|
||||
// at 3. Leaving 2-digit fractions (common in ASS: "ss.cc") breaks strict
|
||||
// parsers.
|
||||
last := strings.SplitN(parts[2], ".", 2)
|
||||
sec := last[0]
|
||||
if len(sec) == 1 {
|
||||
sec = "0" + sec
|
||||
}
|
||||
frac := ""
|
||||
if len(last) == 2 {
|
||||
frac = last[1]
|
||||
// ASS centiseconds -> WebVTT milliseconds: append a trailing zero so
|
||||
// "51" becomes "510", "123" stays "123".
|
||||
if len(frac) < 3 {
|
||||
frac += strings.Repeat("0", 3-len(frac))
|
||||
} else if len(frac) > 3 {
|
||||
frac = frac[:3]
|
||||
}
|
||||
}
|
||||
out := hh + ":" + parts[1] + ":" + sec
|
||||
if frac != "" {
|
||||
out += "." + frac
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var assTag = regexp.MustCompile(`\{[^}]*\}`)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -42,3 +44,54 @@ func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseTimecode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"0:00:01": "00:00:01",
|
||||
"0:00:01.51": "00:00:01.510", // ASS centiseconds -> 3-digit ms
|
||||
"0:00:04.123": "00:00:04.123", // already 3 digits
|
||||
"00:00:12.07": "00:00:12.070", // 2-digit fraction padded
|
||||
"00:01:30.1234": "00:01:30.123", // capped at 3 digits
|
||||
"00:00:1.5": "00:00:01.500", // single-digit seconds and fraction
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normaliseTimecode(in); got != want {
|
||||
t.Errorf("normaliseTimecode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
videoPath := filepath.Join(dir, "MovieName.mkv")
|
||||
subPath := filepath.Join(dir, "MovieName.ass")
|
||||
raw := "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,線合なライン\n"
|
||||
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(subPath, []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{Title: "MovieName", Path: videoPath}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewSubtitleService(zap.NewNop(), repository.New(db))
|
||||
var buf bytes.Buffer
|
||||
if err := svc.ServeRaw(t.Context(), media.ID, subPath, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ServeRaw must NOT convert ASS->VTT; it returns the exact source bytes.
|
||||
if got := buf.String(); got != raw {
|
||||
t.Fatalf("ServeRaw returned %q, want raw %q", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user