// Package service — subtitle handling. // // SubtitleService finds external subtitle files next to a media file and // exposes them as WebVTT so the browser element can load them // directly, or as the original bytes for Emby/Jellyfin clients. // // External-subtitle discovery rules (matching the legacy Python defaults): // // 1. Same directory, same basename, different extension. // 2. Same directory, ".sub/" or "subs/" subdirectory. // 3. Sibling languages e.g. movie.zh.srt / movie.en.srt → exposed as // ?lang=zh / ?lang=en. // // Supported extensions: .srt, .ass, .ssa, .vtt. package service import ( "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) // SubtitleService is the discovery + conversion entry point. type SubtitleService struct { log *zap.Logger repo *repository.Container cfg *config.Config strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error) // 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次 // os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。 cacheMu sync.Mutex discovery map[string]subtitleDiscoveryEntry } const ( subtitleDiscoveryTTL = 2 * time.Minute subtitleDiscoveryCacheCap = 4096 ) type subtitleDiscoveryEntry struct { tracks []SubtitleTrack expiresAt time.Time } // NewSubtitleService is the constructor. func NewSubtitleService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *SubtitleService { return &SubtitleService{log: log, repo: repo, cfg: cfg} } // SubtitleTrack describes one external subtitle file. type SubtitleTrack struct { Lang string `json:"lang"` Label string `json:"label"` Path string `json:"path"` URL string `json:"url"` Codec string `json:"codec"` Source string `json:"source"` Delivery string `json:"delivery"` StreamIndex int `json:"stream_index,omitempty"` } // extToCodec maps the file extension to the inner codec name. var extToCodec = map[string]string{ ".srt": "srt", ".vtt": "vtt", ".ass": "ass", ".ssa": "ssa", } // Discover lists every external subtitle file for a media row. The URL is // relative; the caller should prepend /api/subtitles/?path=... // when serializing for the frontend. func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { return s.discover(ctx, mediaID) } // DiscoverExternalOnly 只返回媒体旁边的外挂字幕文件,不含容器内嵌字幕轨。 // Emby 字幕接口(/Videos/:id/Subtitles/...)用。 func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { cacheKey := "external:" + mediaID if tracks, ok := s.cachedDiscovery(cacheKey); ok { return tracks, nil } tracks, err := s.discoverExternalUncached(ctx, mediaID) if err != nil { return nil, err } s.rememberDiscovery(cacheKey, tracks) return tracks, nil } func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { cacheKey := "all:" + mediaID if tracks, ok := s.cachedDiscovery(cacheKey); ok { return tracks, nil } tracks, err := s.discoverUncached(ctx, mediaID) if err != nil { return nil, err } s.rememberDiscovery(cacheKey, tracks) return tracks, nil } func (s *SubtitleService) cachedDiscovery(mediaID string) ([]SubtitleTrack, bool) { now := time.Now() s.cacheMu.Lock() defer s.cacheMu.Unlock() entry, ok := s.discovery[mediaID] if !ok { return nil, false } if now.After(entry.expiresAt) { delete(s.discovery, mediaID) return nil, false } // 返回副本,避免调用方修改缓存内容。 return append([]SubtitleTrack(nil), entry.tracks...), true } func (s *SubtitleService) rememberDiscovery(mediaID string, tracks []SubtitleTrack) { now := time.Now() s.cacheMu.Lock() defer s.cacheMu.Unlock() if s.discovery == nil { s.discovery = make(map[string]subtitleDiscoveryEntry) } if len(s.discovery) >= subtitleDiscoveryCacheCap { s.discovery = make(map[string]subtitleDiscoveryEntry) } s.discovery[mediaID] = subtitleDiscoveryEntry{ tracks: append([]SubtitleTrack(nil), tracks...), expiresAt: now.Add(subtitleDiscoveryTTL), } } func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { m, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil { return nil, err } if m == nil { return nil, errors.New("media not found") } tracks := discoverExternalSubtitleTracks(m) embedded, err := s.discoverEmbedded(ctx, m) if err != nil { if s.log != nil { s.log.Debug("discover embedded subtitles failed", zap.String("media_id", mediaID), zap.Error(err)) } } else { tracks = append(tracks, embedded...) } return tracks, nil } func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { m, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil { return nil, err } if m == nil { return nil, errors.New("media not found") } return discoverExternalSubtitleTracks(m), nil } func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack { dir := filepath.Dir(m.Path) bases := mediaSidecarBaseVariants(m.Path) if len(bases) == 0 { bases = []string{strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))} } candidates := make([]string, 0, 16) candidates = append(candidates, dir) for _, sub := range []string{"subs", "Subs", "sub", ".sub"} { candidates = append(candidates, filepath.Join(dir, sub)) } tracks := make([]SubtitleTrack, 0) for _, c := range candidates { entries, err := os.ReadDir(c) if err != nil { continue } for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) codec, ok := extToCodec[ext] if !ok { continue } fullName := strings.TrimSuffix(e.Name(), ext) matchedBase := "" if c == dir { for _, base := range bases { if strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) { matchedBase = base break } } if matchedBase == "" { // In the same directory we require a basename match; // inside subs/ subdirs we accept anything. continue } } else if len(bases) > 0 { matchedBase = bases[0] } lang := detectLang(fullName, matchedBase) tracks = append(tracks, SubtitleTrack{ Lang: lang, Label: lang, Path: filepath.Join(c, e.Name()), Codec: codec, Source: "external", Delivery: subtitleDeliveryForCodec(codec), }) } } return tracks } type embeddedSubtitleProbe struct { Streams []struct { Index int `json:"index"` CodecName string `json:"codec_name"` Tags struct { Language string `json:"language"` Title string `json:"title"` } `json:"tags"` Disposition struct { Default int `json:"default"` Forced int `json:"forced"` } `json:"disposition"` } `json:"streams"` } var imageSubtitleCodecs = map[string]bool{ "hdmv_pgs_subtitle": true, "dvd_subtitle": true, "dvb_subtitle": true, "xsub": true, } func (s *SubtitleService) discoverEmbedded(ctx context.Context, media *model.Media) ([]SubtitleTrack, error) { if s == nil || s.cfg == nil { return nil, errors.New("subtitle probe unavailable") } input, err := s.resolveInput(ctx, media) if err != nil { return nil, err } bin, err := resolveLocalExecutable(s.cfg.App.FFprobePath, "ffprobe") if err != nil { return nil, err } probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() args := []string{"-v", "error"} if headers := ffmpegHeaderText(input.Headers); headers != "" { args = append(args, "-headers", headers) } args = append(args, "-select_streams", "s", "-show_entries", "stream=index,codec_name:stream_tags=language,title:stream_disposition=default,forced", "-of", "json", input.Source, ) out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- executable is resolved locally and arguments do not use a shell. if err != nil { return nil, err } var probe embeddedSubtitleProbe if err := json.Unmarshal(out, &probe); err != nil { return nil, err } return subtitleTracksFromProbe(probe), nil } // subtitleDeliveryForCodec selects the browser rendering path for a text // subtitle codec. ASS/SSA keep their original bytes and are rendered by // libass in the web player; other text formats are converted to WebVTT. func subtitleDeliveryForCodec(codec string) string { switch strings.ToLower(strings.TrimSpace(codec)) { case "ass", "ssa": return "ass" default: return "webvtt" } } func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack { tracks := make([]SubtitleTrack, 0, len(probe.Streams)) for _, stream := range probe.Streams { codec := strings.ToLower(strings.TrimSpace(stream.CodecName)) lang := strings.ToLower(strings.TrimSpace(stream.Tags.Language)) if lang == "" { lang = "und" } label := strings.TrimSpace(stream.Tags.Title) if label == "" { label = lang } if stream.Disposition.Forced != 0 { label += "(强制)" } else if stream.Disposition.Default != 0 { label += "(默认)" } delivery := subtitleDeliveryForCodec(codec) if imageSubtitleCodecs[codec] { delivery = "burn" } sourceLabel := "(内嵌)" if delivery == "burn" { sourceLabel = "(内嵌·图片)" } tracks = append(tracks, SubtitleTrack{ Lang: lang, Label: label + sourceLabel, Path: "embedded:" + strconv.Itoa(stream.Index), Codec: codec, Source: "embedded", Delivery: delivery, StreamIndex: stream.Index, }) } return tracks } func (s *SubtitleService) SetStrmPlayTargetResolver(resolve func(context.Context, string) (*StrmPlayResult, error)) { if s != nil { s.strmResolve = resolve } } func (s *SubtitleService) resolveInput(ctx context.Context, media *model.Media) (transcodeInput, error) { if media == nil { return transcodeInput{}, ErrMediaNotFound } if !isStrmMediaRow(media) { if _, err := os.Stat(media.Path); err != nil { return transcodeInput{}, ErrMediaNotFound } return transcodeInput{Source: media.Path}, nil } raw := strings.TrimSpace(media.STRMURL) if raw == "" && strings.HasSuffix(strings.ToLower(media.Path), ".strm") { raw, _ = readLocalSTRMTarget(media.Path) } if s.strmResolve != nil { resolved, err := s.strmResolve(ctx, raw) if err != nil { return transcodeInput{}, err } return transcodeInputFromPlayResult(resolved) } if isHTTPPlaybackTarget(raw) { return transcodeInput{Source: raw}, nil } return transcodeInput{}, errors.New("subtitle source unavailable") } // langTag matches the .zh / .zh-cn / .chs language sub-extensions. var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`) func detectLang(name, base string) string { suffix := strings.TrimPrefix(name, base) suffix = strings.TrimPrefix(suffix, ".") if m := langTag.FindStringSubmatch("." + suffix); len(m) >= 2 { return strings.ToLower(m[1]) } if suffix == "" { return "und" // undetermined } return strings.ToLower(suffix) } // Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are // converted minimally on the fly. Returns ErrSubtitleNotFound when the // path is rejected (path traversal / not in the media directory). func (s *SubtitleService) Serve(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 strings.HasPrefix(sub, "embedded:") { index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:")) if err != nil || index < 0 { return errors.New("invalid embedded subtitle") } return s.serveEmbedded(ctx, m, index, w) } abs, err := readExternalSubtitlePath(m, sub) if err != nil { return err } 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() body, err := io.ReadAll(f) if err != nil { return err } // 非 UTF-8 的外挂字幕(UTF-16、GBK/Big5 等)必须先归一化:浏览器只能按 // UTF-8 解析 内容,否则整篇都会变成替换字符。 text := decodeSubtitleText(body) switch strings.ToLower(filepath.Ext(abs)) { case ".vtt": _, err = io.WriteString(w, text) case ".srt": _, err = io.WriteString(w, srtToVTT(text)) case ".ass", ".ssa": _, err = io.WriteString(w, assToVTT(text)) default: return errors.New("unsupported subtitle format") } return err } func (s *SubtitleService) serveEmbedded(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error { input, err := s.resolveInput(ctx, media) if err != nil { return err } bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg") if err != nil { return err } args := []string{"-hide_banner", "-loglevel", "error"} args = append(args, ffmpegHTTPInputArgs(input)...) args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-f", "webvtt", "-") cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell. cmd.Stdout = w var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("extract embedded subtitle: %w: %s", err, strings.TrimSpace(stderr.String())) } return nil } // 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 strings.HasPrefix(sub, "embedded:") { return errors.New("embedded subtitle is not available in raw mode") } 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") } if _, ok := extToCodec[strings.ToLower(filepath.Ext(abs))]; !ok { return errors.New("unsupported subtitle format") } 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 } // ServeASS returns an ASS/SSA stream suitable for libass-wasm. External ASS // files are sent unchanged; embedded ASS/SSA tracks are remuxed to ASS by // ffmpeg. This keeps fonts, positioning and typesetting data available to the // browser renderer instead of flattening the track through assToVTT first. func (s *SubtitleService) ServeASS(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 strings.HasPrefix(sub, "embedded:") { index, err := strconv.Atoi(strings.TrimPrefix(sub, "embedded:")) if err != nil || index < 0 { return errors.New("invalid embedded subtitle") } return s.serveEmbeddedASS(ctx, m, index, w) } switch strings.ToLower(filepath.Ext(sub)) { case ".ass", ".ssa": // 外挂 ASS 同样要归一化成 UTF-8:libass 只认 UTF-8,UTF-16/GBK 的 // 字幕交给它会解析不到任何事件,表现为「字幕选中了却不显示」。 abs, err := readExternalSubtitlePath(m, sub) if err != nil { return err } 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() body, err := io.ReadAll(f) if err != nil { return err } _, err = io.WriteString(w, decodeSubtitleText(body)) return err default: return errors.New("subtitle is not ASS/SSA") } } // readExternalSubtitlePath 校验外挂字幕路径(必须落在媒体文件所在目录内)并返回 // 绝对路径。Serve 与 ServeASS 共用,避免两处各自实现出现安全口径不一致。 func readExternalSubtitlePath(m *model.Media, sub string) (string, error) { 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") } return abs, nil } func (s *SubtitleService) serveEmbeddedASS(ctx context.Context, media *model.Media, streamIndex int, w io.Writer) error { input, err := s.resolveInput(ctx, media) if err != nil { return err } bin, err := resolveLocalExecutable(s.cfg.App.FFmpegPath, "ffmpeg") if err != nil { return err } args := []string{"-hide_banner", "-loglevel", "error"} args = append(args, ffmpegHTTPInputArgs(input)...) args = append(args, "-i", input.Source, "-map", "0:"+strconv.Itoa(streamIndex), "-c:s", "ass", "-f", "ass", "-") cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- executable is resolved locally and arguments do not use a shell. cmd.Stdout = w var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("extract embedded ASS subtitle: %w: %s", err, strings.TrimSpace(stderr.String())) } return nil }