mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 00:06:37 +08:00
split subtitle service helpers
This commit is contained in:
@@ -19,7 +19,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -27,9 +26,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SubtitleService is the discovery + conversion entry point.
|
// SubtitleService is the discovery + conversion entry point.
|
||||||
@@ -83,7 +80,7 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
|||||||
return nil, errors.New("media not found")
|
return nil, errors.New("media not found")
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") {
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") {
|
||||||
return s.discoverCloud(ctx, *m), nil
|
return discoverCloudSubtitles(ctx, s, *m), nil
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(m.Path)
|
dir := filepath.Dir(m.Path)
|
||||||
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||||
@@ -128,126 +125,6 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
|||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubtitleService) discoverCloud(ctx context.Context, m model.Media) []SubtitleTrack {
|
|
||||||
if s == nil || s.storage == nil {
|
|
||||||
return []SubtitleTrack{}
|
|
||||||
}
|
|
||||||
typ, mediaRef, ok := cloudSubtitleMediaRef(m)
|
|
||||||
if !ok {
|
|
||||||
return []SubtitleTrack{}
|
|
||||||
}
|
|
||||||
dirRef, mediaName := splitCloudRef(mediaRef)
|
|
||||||
if mediaName == "" {
|
|
||||||
return []SubtitleTrack{}
|
|
||||||
}
|
|
||||||
base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName))
|
|
||||||
entries, err := s.storage.CloudList(ctx, typ, dirRef)
|
|
||||||
if err != nil {
|
|
||||||
if s.log != nil {
|
|
||||||
s.log.Debug("list cloud subtitles failed", zap.String("provider", typ), zap.String("dir", dirRef), zap.Error(err))
|
|
||||||
}
|
|
||||||
return []SubtitleTrack{}
|
|
||||||
}
|
|
||||||
tracks := cloudSubtitleTracks(typ, entries, base, false)
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir || !isSubtitleDirectory(entry.Name) || strings.TrimSpace(entry.ID) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subEntries, err := s.storage.CloudList(ctx, typ, entry.ID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tracks = append(tracks, cloudSubtitleTracks(typ, subEntries, base, true)...)
|
|
||||||
}
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloudSubtitleTracks(typ string, entries []cloud.FileEntry, base string, subdir bool) []SubtitleTrack {
|
|
||||||
tracks := make([]SubtitleTrack, 0)
|
|
||||||
baseLower := strings.ToLower(base)
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ext := strings.ToLower(filepath.Ext(entry.Name))
|
|
||||||
codec, ok := extToCodec[ext]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fullName := strings.TrimSuffix(entry.Name, ext)
|
|
||||||
if !subdir && !strings.HasPrefix(strings.ToLower(fullName), baseLower) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ref := cloudEntryRef(typ, entry.ID, entry.PickCode)
|
|
||||||
if ref == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lang := detectLang(fullName, base)
|
|
||||||
tracks = append(tracks, SubtitleTrack{
|
|
||||||
Lang: lang,
|
|
||||||
Label: lang,
|
|
||||||
Path: buildCloudSubtitlePath(typ, ref, entry.Name),
|
|
||||||
Codec: codec,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloudSubtitleMediaRef(m model.Media) (typ, ref string, ok bool) {
|
|
||||||
if info, parsed := ParseCloudLibraryMount(m.Path); parsed && strings.TrimSpace(info.DisplayDir) != "" {
|
|
||||||
return info.Provider, info.DisplayDir, true
|
|
||||||
}
|
|
||||||
if typ, ref, parsed := parseCloudMediaPlaybackURL(m.STRMURL); parsed {
|
|
||||||
return typ, ref, true
|
|
||||||
}
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitCloudRef(ref string) (dir, name string) {
|
|
||||||
ref = strings.Trim(strings.ReplaceAll(strings.TrimSpace(ref), "\\", "/"), "/")
|
|
||||||
if ref == "" {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
idx := strings.LastIndex(ref, "/")
|
|
||||||
if idx < 0 {
|
|
||||||
return "", ref
|
|
||||||
}
|
|
||||||
return ref[:idx], ref[idx+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSubtitleDirectory(name string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
||||||
case "subs", "sub", ".sub", "subtitles", "subtitle":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCloudSubtitlePath(typ, ref, name string) string {
|
|
||||||
u := url.URL{
|
|
||||||
Scheme: "cloud",
|
|
||||||
Host: strings.TrimSpace(typ),
|
|
||||||
Path: "/" + strings.TrimLeft(strings.TrimSpace(ref), "/"),
|
|
||||||
}
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("name", strings.TrimSpace(name))
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCloudSubtitlePath(raw string) (typ, ref, name string, ok bool) {
|
|
||||||
u, err := url.Parse(strings.TrimSpace(raw))
|
|
||||||
if err != nil || strings.ToLower(u.Scheme) != "cloud" || strings.TrimSpace(u.Host) == "" {
|
|
||||||
return "", "", "", false
|
|
||||||
}
|
|
||||||
ref = strings.TrimLeft(u.EscapedPath(), "/")
|
|
||||||
if decoded, err := url.PathUnescape(ref); err == nil {
|
|
||||||
ref = decoded
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(u.Host), strings.TrimSpace(ref), strings.TrimSpace(u.Query().Get("name")), ref != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
|
||||||
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
|
||||||
|
|
||||||
@@ -272,7 +149,7 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
|||||||
return errors.New("media not found")
|
return errors.New("media not found")
|
||||||
}
|
}
|
||||||
if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok {
|
if typ, ref, name, ok := parseCloudSubtitlePath(sub); ok {
|
||||||
return s.serveCloud(ctx, *m, typ, ref, name, w)
|
return serveCloudSubtitle(ctx, s, *m, typ, ref, name, w)
|
||||||
}
|
}
|
||||||
abs, err := filepath.Abs(sub)
|
abs, err := filepath.Abs(sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -305,99 +182,3 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubtitleService) serveCloud(ctx context.Context, 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 s.discoverCloud(ctx, 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":
|
|
||||||
_, err = io.WriteString(w, body)
|
|
||||||
case ".srt":
|
|
||||||
_, err = io.WriteString(w, srtToVTT(body))
|
|
||||||
case ".ass", ".ssa":
|
|
||||||
_, err = io.WriteString(w, assToVTT(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 <track> 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, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
func discoverCloudSubtitles(ctx context.Context, s *SubtitleService, m model.Media) []SubtitleTrack {
|
||||||
|
if s == nil || s.storage == nil {
|
||||||
|
return []SubtitleTrack{}
|
||||||
|
}
|
||||||
|
typ, mediaRef, ok := cloudSubtitleMediaRef(m)
|
||||||
|
if !ok {
|
||||||
|
return []SubtitleTrack{}
|
||||||
|
}
|
||||||
|
dirRef, mediaName := splitCloudRef(mediaRef)
|
||||||
|
if mediaName == "" {
|
||||||
|
return []SubtitleTrack{}
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName))
|
||||||
|
entries, err := s.storage.CloudList(ctx, typ, dirRef)
|
||||||
|
if err != nil {
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Debug("list cloud subtitles failed", zap.String("provider", typ), zap.String("dir", dirRef), zap.Error(err))
|
||||||
|
}
|
||||||
|
return []SubtitleTrack{}
|
||||||
|
}
|
||||||
|
tracks := cloudSubtitleTracks(typ, entries, base, false)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir || !isSubtitleDirectory(entry.Name) || strings.TrimSpace(entry.ID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subEntries, err := s.storage.CloudList(ctx, typ, entry.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks = append(tracks, cloudSubtitleTracks(typ, subEntries, base, true)...)
|
||||||
|
}
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudSubtitleTracks(typ string, entries []cloud.FileEntry, base string, subdir bool) []SubtitleTrack {
|
||||||
|
tracks := make([]SubtitleTrack, 0)
|
||||||
|
baseLower := strings.ToLower(base)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
||||||
|
codec, ok := extToCodec[ext]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullName := strings.TrimSuffix(entry.Name, ext)
|
||||||
|
if !subdir && !strings.HasPrefix(strings.ToLower(fullName), baseLower) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref := cloudEntryRef(typ, entry.ID, entry.PickCode)
|
||||||
|
if ref == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lang := detectLang(fullName, base)
|
||||||
|
tracks = append(tracks, SubtitleTrack{
|
||||||
|
Lang: lang,
|
||||||
|
Label: lang,
|
||||||
|
Path: buildCloudSubtitlePath(typ, ref, entry.Name),
|
||||||
|
Codec: codec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudSubtitleMediaRef(m model.Media) (typ, ref string, ok bool) {
|
||||||
|
if info, parsed := ParseCloudLibraryMount(m.Path); parsed && strings.TrimSpace(info.DisplayDir) != "" {
|
||||||
|
return info.Provider, info.DisplayDir, true
|
||||||
|
}
|
||||||
|
if typ, ref, parsed := parseCloudMediaPlaybackURL(m.STRMURL); parsed {
|
||||||
|
return typ, ref, true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCloudRef(ref string) (dir, name string) {
|
||||||
|
ref = strings.Trim(strings.ReplaceAll(strings.TrimSpace(ref), "\\", "/"), "/")
|
||||||
|
if ref == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(ref, "/")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", ref
|
||||||
|
}
|
||||||
|
return ref[:idx], ref[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSubtitleDirectory(name string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
case "subs", "sub", ".sub", "subtitles", "subtitle":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCloudSubtitlePath(typ, ref, name string) string {
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "cloud",
|
||||||
|
Host: strings.TrimSpace(typ),
|
||||||
|
Path: "/" + strings.TrimLeft(strings.TrimSpace(ref), "/"),
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("name", strings.TrimSpace(name))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCloudSubtitlePath(raw string) (typ, ref, name string, ok bool) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || strings.ToLower(u.Scheme) != "cloud" || strings.TrimSpace(u.Host) == "" {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
ref = strings.TrimLeft(u.EscapedPath(), "/")
|
||||||
|
if decoded, err := url.PathUnescape(ref); err == nil {
|
||||||
|
ref = decoded
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(u.Host), strings.TrimSpace(ref), strings.TrimSpace(u.Query().Get("name")), ref != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCloudSubtitle(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":
|
||||||
|
_, err = io.WriteString(w, body)
|
||||||
|
case ".srt":
|
||||||
|
_, err = io.WriteString(w, srtToVTT(body))
|
||||||
|
case ".ass", ".ssa":
|
||||||
|
_, err = io.WriteString(w, assToVTT(body))
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported subtitle format")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 so the browser <track> element can still display text.
|
||||||
|
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, "")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user