添加对外挂字幕的支持

添加对字幕的支持
This commit is contained in:
truewhile
2026-08-19 21:36:35 +08:00
parent 3edeb94c93
commit 2f1f427b9e
13 changed files with 809 additions and 5 deletions
+46
View File
@@ -31,6 +31,38 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
}
}
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
// sideloaded subtitle file next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams.
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
// fall back to the discovered source codec. :mediaSourceId is ignored —
// the item is located by :id and subtitles by :index (1:1 in this shim).
format := strings.TrimSpace(c.Param("format"))
codec := svc.Emby.SubtitleStreamCodec(ctx, c.Param("id"), c.Param("index"), uid)
if codec != "" {
if format != "" {
codec = service.SubtitleCodecFromFormat(format)
}
c.Header("Content-Type", service.SubtitleContentType(codec))
}
c.Header("Cache-Control", "public, max-age=3600")
if err := svc.Emby.ServeSubtitleStream(ctx, c.Writer, c.Param("id"), c.Param("index"), uid); err != nil {
embyError(c, http.StatusNotFound, "subtitle not found")
return
}
}
}
func embyAttachRequestTokenToMediaSources(c *gin.Context, out any) {
token := embyRequestToken(c)
if token == "" || out == nil {
@@ -83,6 +115,20 @@ func embyAttachTokenToMediaSources(sources []map[string]any, token string) {
}
source[key] = embyAppendAPIKey(raw, token)
}
// Subtitle streams advertise a DeliveryUrl; the official Emby client
// fetches it directly, so it must carry the auth token too.
if streams, ok := source["MediaStreams"].([]map[string]any); ok {
for _, stream := range streams {
if stream["Type"] != "Subtitle" {
continue
}
raw, ok := stream["DeliveryUrl"].(string)
if !ok {
continue
}
stream["DeliveryUrl"] = embyAppendAPIKey(raw, token)
}
}
}
}
+15
View File
@@ -192,6 +192,21 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.HEAD("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
// Official Emby/Swagger shape:
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
// The mediaSourceId segment is a bare path param. We name it :seg (matching
// the HLS /Videos/:id/:seg catch-all) so gin allows the two routes to
// coexist — gin permits the same :param name to be both terminal and parent
// of children, but rejects two different param names at the same position.
// The subtitle handler ignores this segment (lookup is by :id + :index).
auth.GET("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
if prefix == "/emby" {
auth.GET("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
auth.HEAD("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
+12
View File
@@ -62,6 +62,18 @@ func registerLowercaseEmbyPlaybackRoutes(auth *gin.RouterGroup, svc *service.Con
auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
registerEmbyVideoStreamRoutes(auth, svc, "/videos")
auth.GET("/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
auth.HEAD("/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
auth.GET("/users/:userId/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
auth.HEAD("/users/:userId/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
// Official Emby/Swagger shape:
// /videos/{Id}/{MediaSourceId}/subtitles/{Index}/stream.{Format}
// The mediaSourceId segment is a bare path param named :seg (shared with the
// HLS /videos/:id/:seg catch-all) so gin allows both routes to coexist.
auth.GET("/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/users/:userId/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/users/:userId/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.GET("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
@@ -0,0 +1,190 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"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"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
// subtitle route shape
// (/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}) is
// registered, resolves the media, and streams the RAW ASS bytes — matching the
// advertised Codec — so a real Emby client can load the subtitle.
func TestEmbySubtitleOfficialRouteServesRawASS(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
videoPath := filepath.Join(dir, "Movie.mkv")
subPath := filepath.Join(dir, "Movie.ass")
rawASS := "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,test line\n"
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(subPath, []byte(rawASS), 0o644); err != nil {
t.Fatalf("write subtitle: %v", err)
}
mediaID := "media-1"
if err := db.Create(&model.Media{
Base: model.Base{ID: mediaID},
LibraryID: "lib-1",
Title: "Movie",
Path: videoPath,
Container: "mkv",
VideoCodec: "h264",
AudioCodec: "aac",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
service.NewSubtitleService(zap.NewNop(), repos),
),
})
// Official-format route:
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
// Index 2 = first subtitle when audio present (Video 0, Audio 1, Sub 2).
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/"+mediaID+"/"+mediaID+"/Subtitles/2/Stream.ass", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want text/plain", ct)
}
if got := w.Body.String(); got != rawASS {
t.Fatalf("served body = %q, want raw ASS %q", got, rawASS)
}
}
// TestEmbySubtitleDeliveryUrlGetsToken ensures the subtitle DeliveryUrl carries
// the auth token in PlaybackInfo (delivered via embyAttachTokenToMediaSources).
func TestEmbySubtitleDeliveryUrlGetsToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
videoPath := filepath.Join(dir, "Movie.mkv")
subPath := filepath.Join(dir, "Movie.ass")
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(subPath, []byte("Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,x\n"), 0o644); err != nil {
t.Fatalf("write subtitle: %v", err)
}
mediaID := "media-1"
if err := db.Create(&model.Media{
Base: model.Base{ID: mediaID},
LibraryID: "lib-1",
Title: "Movie",
Path: videoPath,
Container: "mkv",
VideoCodec: "h264",
AudioCodec: "aac",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
token := signedTestToken(t, secret)
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
service.NewSubtitleService(zap.NewNop(), repos),
),
})
req := httptest.NewRequest(http.MethodGet, "/emby/Items/"+mediaID+"/PlaybackInfo", nil)
req.Header.Set("X-Emby-Token", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status %d body=%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
source := body["MediaSources"].([]any)[0].(map[string]any)
streams := source["MediaStreams"].([]any)
var deliveryURL string
for _, s := range streams {
stream := s.(map[string]any)
if stream["Type"] == "Subtitle" {
deliveryURL, _ = stream["DeliveryUrl"].(string)
break
}
}
if deliveryURL == "" {
t.Fatalf("no subtitle DeliveryUrl found: %#v", source)
}
// Official shape with bare MediaSourceId + format suffix and the token appended.
wantPrefix := "/Videos/" + mediaID + "/" + mediaID + "/Subtitles/2/Stream.ass"
if deliveryURL[:len(wantPrefix)] != wantPrefix {
t.Fatalf("DeliveryUrl %q does not start with %q", deliveryURL, wantPrefix)
}
if !strings.Contains(deliveryURL, "api_key="+token) {
t.Fatalf("DeliveryUrl %q missing auth token", deliveryURL)
}
}
+12
View File
@@ -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 共用的查询参数。
+120 -1
View File
@@ -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 + ")"
}
+78 -3
View File
@@ -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),
}
}
+189
View File
@@ -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)
}
}
+1
View File
@@ -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(
+32
View File
@@ -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
}
+36
View File
@@ -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
}
+25 -1
View File
@@ -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(`\{[^}]*\}`)
+53
View File
@@ -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)
}
}