添加对外挂字幕的支持

添加对字幕的支持
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
+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)
}
}