mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
添加对外挂字幕的支持
添加对字幕的支持
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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