diff --git a/internal/handler/emby_playback.go b/internal/handler/emby_playback.go index 52e6387..d3520d6 100644 --- a/internal/handler/emby_playback.go +++ b/internal/handler/emby_playback.go @@ -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) + } + } } } diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 0f533c6..5e2d3fd 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -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)) diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index baabe4a..96189ae 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -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)) diff --git a/internal/handler/emby_subtitle_routes_test.go b/internal/handler/emby_subtitle_routes_test.go new file mode 100644 index 0000000..e22c8b7 --- /dev/null +++ b/internal/handler/emby_subtitle_routes_test.go @@ -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) + } +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index dd26123..5d7d6fa 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -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 共用的查询参数。 diff --git a/internal/service/emby_media_sources.go b/internal/service/emby_media_sources.go index 14f9906..e35c2a2 100644 --- a/internal/service/emby_media_sources.go +++ b/internal/service/emby_media_sources.go @@ -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 + ")" +} diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index ab1160a..e33a679 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -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), } } diff --git a/internal/service/emby_subtitle_test.go b/internal/service/emby_subtitle_test.go new file mode 100644 index 0000000..5e0414a --- /dev/null +++ b/internal/service/emby_subtitle_test.go @@ -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) + } +} diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 00bd233..fc9fafd 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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( diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 008862f..f7ac854 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -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 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 +} diff --git a/internal/service/subtitle_cloud.go b/internal/service/subtitle_cloud.go index 70b79d9..242ba0d 100644 --- a/internal/service/subtitle_cloud.go +++ b/internal/service/subtitle_cloud.go @@ -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 +} diff --git a/internal/service/subtitle_convert.go b/internal/service/subtitle_convert.go index 7c1a299..ce6661c 100644 --- a/internal/service/subtitle_convert.go +++ b/internal/service/subtitle_convert.go @@ -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(`\{[^}]*\}`) diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index bfe40db..90a5565 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -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) + } +}