diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 5974f3d..008862f 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "net/url" "os" "path/filepath" "regexp" @@ -27,9 +26,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" - "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) // 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") } 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) 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 } -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. 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") } 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) if err != nil { @@ -305,99 +182,3 @@ func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.W } 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 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, "") -} diff --git a/internal/service/subtitle_cloud.go b/internal/service/subtitle_cloud.go new file mode 100644 index 0000000..70b79d9 --- /dev/null +++ b/internal/service/subtitle_cloud.go @@ -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 +} diff --git a/internal/service/subtitle_convert.go b/internal/service/subtitle_convert.go new file mode 100644 index 0000000..7c1a299 --- /dev/null +++ b/internal/service/subtitle_convert.go @@ -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 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, "") +}