// Package service — subtitle handling. // // SubtitleService finds external subtitle files next to a media file and // converts SRT to WebVTT on the fly so the browser element can // load them directly. // // External-subtitle discovery rules (matching MediaStation 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/MediaStationGo/internal/repository" ) // SubtitleService is the discovery + conversion entry point. type SubtitleService struct { log *zap.Logger repo *repository.Container } // NewSubtitleService is the constructor. func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService { return &SubtitleService{log: log, repo: repo} } // 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/?path=... // when serializing for the frontend. 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 !strings.HasPrefix(abs, mediaDir) { return fmt.Errorf("path escape") } f, err := os.Open(abs) 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 } // srtToVTT performs the minimal SRT → WebVTT transformation: prepend // "WEBVTT\n\n" and replace ',' with '.' in the timecode separators. func srtToVTT(body string) string { body = strings.ReplaceAll(body, "\r\n", "\n") out := strings.Builder{} out.WriteString("WEBVTT\n\n") for _, line := range strings.Split(body, "\n") { if strings.Contains(line, "-->") { line = strings.ReplaceAll(line, ",", ".") } out.WriteString(line) out.WriteByte('\n') } return out.String() } // assToVTT extracts the dialogue lines from an ASS/SSA subtitle. Styling // is dropped — the goal is to produce something usable in rather // than a pixel-perfect render. func assToVTT(body string) string { out := strings.Builder{} out.WriteString("WEBVTT\n\n") for i, line := range strings.Split(body, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "Dialogue:") { continue } parts := strings.SplitN(line, ",", 10) if len(parts) < 10 { continue } fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n", i, normaliseTimecode(parts[1]), normaliseTimecode(parts[2]), stripASSTags(parts[9]), ) } return out.String() } func normaliseTimecode(t string) string { t = strings.TrimSpace(t) parts := strings.Split(t, ":") if len(parts) != 3 { return t } hh := parts[0] if len(hh) == 1 { hh = "0" + hh } return hh + ":" + parts[1] + ":" + strings.ReplaceAll(parts[2], ".", ".") } var assTag = regexp.MustCompile(`\{[^}]*\}`) func stripASSTags(s string) string { return assTag.ReplaceAllString(s, "") }