mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
38b85bb487
* feat(strm): 同名多版本择优/keep_ext,并支持合并展示与播放切换 默认对同名不同扩展(如 .mkv/.mp4)按体积→mtime→扩展名优先级择优生成一条 strm,冲突打日志;开启 keep_ext 则分别生成 name.ext.strm。媒体详情与播放器可切换版本,列表继续合并同片多版本,Emby MediaSources 名称区分容器/分辨率。 Co-authored-by: truewhile <truewhile@users.noreply.github.com> * fix(meta): 边车匹配忽略 .mkv.strm 中间扩展,同片共享 NFO/海报/字幕 NFO、海报、字幕与刮削写出统一使用 mediaSidecarBase 词干,使竞女01.mkv.strm 与竞女01.mp4.strm 配对同一套 竞女01.nfo / 竞女01-poster / 竞女01.srt; 仍兼容历史上的 name.mkv.nfo 命名。 Co-authored-by: truewhile <truewhile@users.noreply.github.com> * fix(emby): People NFO 按共享词干匹配 keep_ext strm resolveMediaPeople 改为查找竞女01.nfo(并兼容旧的竞女01.mkv.nfo), 同时修正把 decodeNFOFile 的 partial 误当 ok 导致完整 NFO 被跳过的问题。 Co-authored-by: truewhile <truewhile@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
283 lines
8.0 KiB
Go
283 lines
8.0 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"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/truewhile/MeBox/internal/config"
|
|
"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
|
|
|
|
// 目录发现是 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"`
|
|
}
|
|
|
|
// 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) {
|
|
if tracks, ok := s.cachedDiscovery(mediaID); ok {
|
|
return tracks, nil
|
|
}
|
|
tracks, err := s.discoverUncached(ctx, mediaID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.rememberDiscovery(mediaID, 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")
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
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
|
|
}
|