mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 15:26:38 +08:00
feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit
Backend
- service/bangumi.go: Bangumi (bgm.tv) scraper for anime libraries.
- service/episode_parser.go: SxxExx / NxE / EPxx / 第NN集 parser with
unit tests; consumed by the scanner for tv/anime libraries.
- service/scraper.go: provider chain orchestrator picks Bangumi for
anime libraries (TMDb fallback), TMDb for everything else; emits
'no_match' rows so we don't retry forever; AnyEnabled() for the
scanner kick.
- service/scanner.go: writes season/episode numbers for tv/anime libs
and reports a 'probed' counter alongside 'added'.
- service/transcoder.go (existing): unchanged, stays per-media.
- service/subtitle.go: discovers external subtitles next to the source
file (.srt/.vtt/.ass/.ssa) and converts SRT/ASS to WebVTT on the fly.
- service/qbittorrent.go: thread-safe qBittorrent v2 Web UI client
(login / add / list / delete) with cookie-jar reuse.
- service/downloads.go: persists download tasks, reads runtime
qbittorrent.* settings, polls /torrents/info every 5 s and pushes
the result to WS subscribers ('download' topic).
- service/subscription.go: 10-minute RSS poller with regex filter,
GUID dedup persisted in the settings table, and 'subscription' WS
events on enqueue.
- service/watcher.go: fsnotify watcher with 5 s coalescing debouncer
that triggers per-library rescans on create/rename/remove.
- service/stats.go: dashboard snapshot — totals, recently added,
gopsutil-driven CPU/mem/disk readings.
- service/profile.go: non-credential profile patch + admin role mutator.
- service/audit.go: best-effort writer for the access_logs table.
- service/service.go: container wires every new service; Boot() spins
up watcher / downloads poller / subscription scheduler; Close()
tears them down on graceful shutdown.
Handlers
- new files: downloads.go, subscriptions.go, subtitles.go, series.go,
stats.go, profile.go, util.go.
- handler.go: registers PATCH /me, /libraries/:id/seasons,
/media/:id/subtitles, /subtitles/:id, /downloads*, /subscriptions*,
/stats, /admin/users/:id/role.
- auth.go / media.go: write audit rows for login + library CRUD and
refresh the watcher when libraries change.
Frontend
- api: new helpers for downloads, subscriptions, profile, series, stats,
subtitles; library helper gained scrape().
- hooks/useWebSocket.ts: shared connection with 3 s reconnect.
- components/GlobalEvents.tsx: surfaces scan / scrape / subscription
completion as toasts (mounted at app root).
- pages: Library now switches to a season-grouped layout for tv/anime
libraries; Player attaches WebVTT <track> elements; new pages for
Downloads (live torrent table), Subscriptions, Profile, Stats.
- components/Layout.tsx + App.tsx: sidebar groups (媒体库 / 自动化 /
账号 / 管理) and routes for the new pages; /stats and /admin remain
admin-only.
- types/index.ts: new types — Subscription, DownloadTask, QBitTorrent,
Hardware, StatsSnapshot.
Verified: go build, go vet, go test (incl. ParseEpisode + srtToVTT +
stripASSTags) all pass; tsc -b && vite build emits 17 route chunks plus
the deferred hls chunk; main bundle 247 KB / 83 KB gzipped.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// 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 <track> 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/<media_id>?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))
|
||||
}
|
||||
|
||||
var tracks []SubtitleTrack
|
||||
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 <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, "")
|
||||
}
|
||||
Reference in New Issue
Block a user