Files
MeBox/internal/service/subtitle.go
T
2026-09-01 23:16:11 +08:00

209 lines
6.1 KiB
Go

// Package service — subtitle handling.
//
// SubtitleService finds external subtitle files next to a media file and
// exposes them as WebVTT so the browser <track> 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"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/repository"
)
// SubtitleService is the discovery + conversion entry point.
type SubtitleService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
}
// 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"`
}
// 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/<media_id>?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) {
return s.discover(ctx, mediaID)
}
func (s *SubtitleService) discover(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")
}
dir := filepath.Dir(m.Path)
base := 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)
if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) &&
c == dir {
// In the same directory we require a basename match;
// inside subs/ subdirs we accept anything.
continue
}
lang := detectLang(fullName, base)
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: lang,
Path: filepath.Join(c, e.Name()),
Codec: codec,
})
}
}
return tracks, nil
}
// 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")
}
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()
body, err := io.ReadAll(f)
if err != nil {
return err
}
switch strings.ToLower(filepath.Ext(abs)) {
case ".vtt":
_, err = w.Write(body)
case ".srt":
_, err = w.Write([]byte(srtToVTT(string(body))))
case ".ass", ".ssa":
_, err = w.Write([]byte(assToVTT(string(body))))
default:
return errors.New("unsupported subtitle format")
}
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")
}
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
}