mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
4b747c74ca
Audit-driven port from the original Python MediaStation. Eight major
subsystems that were absent from the Go rewrite are now in place,
each with its own service, handler, frontend page and smoke-test
assertions.
Backend services
- service/crypto.go: AES-256-GCM encrypt/decrypt for at-rest secrets
keyed off the JWT secret. Legacy plaintext rows pass through
unchanged for smooth upgrades. Unit-tested.
- service/api_config.go: third-party provider config (TMDb, Bangumi,
TheTVDB, Fanart, Douban, OpenAI). Seeds defaults on first run.
Encrypts api_key on write, returns masked 'abc1****wxyz' projection.
- service/duplicate.go: sparse-sample MD5 (head + middle + tail, 1MiB
each, plus file-size suffix) duplicate finder. Picks 'best' primary
(matched > size > id) and marks others is_duplicate=true.
- service/filemanager.go: server-side allow-listed file browser used
by the library-path picker. Strict path-traversal protection.
- service/dlna.go: real SSDP M-SEARCH discovery + AVTransport
SetAVTransportURI/Play SOAP cast. 30 s discovery cache.
- service/scheduler.go: 3 recurring background jobs (library_scan
60min, transcode_cleanup 24h, recycle_purge 24h with 30-day
cutoff). Status + run-now endpoints.
- service/cache_cleanup.go: walkAndPrune helper used by scheduler.
- service/storage.go: DB-only disk-usage breakdown by library and by
container format.
- service/emby_compat.go: read-only Emby/Jellyfin shim
(System/Info, Users, Users/x/Views, Items, PlaybackInfo) so Infuse
/ VidHub / Kodi can browse MediaStationGo libraries.
Model updates
- Media: new strm_url (302 redirect target), file_hash, is_duplicate,
duplicate_of fields.
- APIConfig: new table for encrypted provider secrets.
- AutoMigrate registers APIConfig.
Stream layer
- StreamService.ServeFile now redirects 302 to strm_url when set so
WebDAV / Alist / S3 / HTTP direct links work transparently.
Handlers + routes
- Authed: GET /files, GET /storage, GET /dlna/devices, POST /dlna/cast,
PUT/DELETE /media/:id/strm, POST /strm/import,
POST /duplicates/{scan,unmark}.
- Admin: GET/PUT/DELETE /admin/api-configs/:provider,
GET /admin/scheduler, POST /admin/scheduler/:name/run.
- New /emby/* group: System/Info, Users, Users/:userId/Views,
Users/:userId/Items, Items/:id/PlaybackInfo (auth-required).
Frontend pages (lazy-loaded, 7 new chunks)
- DlnaPage: device list + media picker + cast button.
- FileManagerPage: root selector + breadcrumb + sortable listing.
- APIConfigsPage: per-provider card with masked-key editor.
- StoragePage: usage tiles + per-library bars + per-container grid.
- DuplicatesPage: scan form + grouped report with primary highlight.
- SchedulerPage: live job table with run-now button (5s refresh).
- Sidebar reorganised: 自动化 group adds DLNA, 管理 group adds
存储 / 文件浏览 / 重复文件 / 定时任务 / API 配置.
Smoke test additions (all admin-only)
- api-configs seeded with 6 providers
- api-config encrypted in db (sqlite3 enc:v1: prefix check)
- storage breakdown
- file browser lists library root + rejects /etc (path traversal)
- dlna devices endpoint
- scheduler exposes 3 jobs + run library_scan
- emby /System/Info + /Users/{x}/Views
- strm set + stream 302 + strm clear
- duplicate scan
Verified: go build, go vet, go test (incl. new TestCrypto* suite + the
existing TestParseEpisode/TestCleanQuery/TestSrtToVTT/TestStripASSTags/
TestBuildFFmpegArgs); tsc -b && vite build emits 28 route chunks plus
the deferred hls chunk; main bundle 253 KB / 85 KB gzipped; smoke test
PASS=42 / FAIL=0.
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
// Package service — direct-play / HLS streaming.
|
|
//
|
|
// StreamService exposes two flavours of playback:
|
|
//
|
|
// - Direct play: the original file is served with HTTP Range support.
|
|
// Works for browser-friendly containers (mp4 / webm / m4v), no ffmpeg
|
|
// involved, zero CPU overhead.
|
|
// - HLS: when the client opts in (or the source codec / container is
|
|
// not browser-friendly), the TranscoderService runs ffmpeg in the
|
|
// background and we serve the resulting .m3u8 + .ts files directly.
|
|
//
|
|
// The HTTP layer decides which mode to use based on the request path:
|
|
//
|
|
// GET /api/stream/:id → direct play
|
|
// GET /api/hls/:id/index.m3u8 → HLS playlist
|
|
// GET /api/hls/:id/seg_NNNNN.ts → HLS segment
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
)
|
|
|
|
// StreamService serves media files with proper Range support so browsers can
|
|
// seek into the stream.
|
|
type StreamService struct {
|
|
cfg *config.Config
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
transcoder *TranscoderService
|
|
}
|
|
|
|
// NewStreamService is the constructor.
|
|
func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Container, transcoder *TranscoderService) *StreamService {
|
|
return &StreamService{
|
|
cfg: cfg,
|
|
log: log,
|
|
repo: repo,
|
|
transcoder: transcoder,
|
|
}
|
|
}
|
|
|
|
// ErrMediaNotFound is returned when the media row or its file is missing.
|
|
var ErrMediaNotFound = errors.New("media not found")
|
|
|
|
// ServeFile streams the file backing the given media ID using
|
|
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
|
|
//
|
|
// When the media row has a STRMURL set we redirect (302) to that URL
|
|
// instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP
|
|
// direct links flow through the rest of the player UI unchanged.
|
|
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
|
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if m == nil {
|
|
return ErrMediaNotFound
|
|
}
|
|
if strings.TrimSpace(m.STRMURL) != "" {
|
|
http.Redirect(w, r, m.STRMURL, http.StatusFound)
|
|
return nil
|
|
}
|
|
f, err := os.Open(m.Path)
|
|
if err != nil {
|
|
return ErrMediaNotFound
|
|
}
|
|
defer f.Close()
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
|
return nil
|
|
}
|
|
|
|
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
|
// We block (with a 30s timeout) until the playlist file shows up.
|
|
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
|
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
|
return err
|
|
}
|
|
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
|
|
return errors.New("hls playlist not ready")
|
|
}
|
|
playlist := s.transcoder.PlaylistPath(mediaID)
|
|
f, err := os.Open(playlist)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
stat, _ := f.Stat()
|
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
|
return nil
|
|
}
|
|
|
|
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
|
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
|
// Only allow segments that look like seg_NNNNN.ts so we cannot be tricked
|
|
// into reading arbitrary files via path traversal.
|
|
if !strings.HasPrefix(segment, "seg_") || !strings.HasSuffix(segment, ".ts") {
|
|
return errors.New("bad segment")
|
|
}
|
|
full := filepath.Join(s.transcoder.HLSDir(mediaID), segment)
|
|
abs, err := filepath.Abs(full)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir, _ := filepath.Abs(s.transcoder.HLSDir(mediaID))
|
|
if !strings.HasPrefix(abs, dir) {
|
|
return errors.New("path escape")
|
|
}
|
|
f, err := os.Open(abs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
stat, _ := f.Stat()
|
|
w.Header().Set("Content-Type", "video/mp2t")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
|
return nil
|
|
}
|
|
|
|
// Probe re-runs ffprobe against an existing media row and refreshes the
|
|
// extracted metadata. Used by the admin UI's "rescan" button.
|
|
func (s *StreamService) Probe(ctx context.Context, mediaID string, probe *FFprobeService) error {
|
|
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
|
if err != nil || m == nil {
|
|
return ErrMediaNotFound
|
|
}
|
|
res, err := probe.Probe(ctx, m.Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updates := map[string]any{
|
|
"duration_sec": res.DurationSec,
|
|
"width": res.Width,
|
|
"height": res.Height,
|
|
"video_codec": res.VideoCodec,
|
|
"audio_codec": res.AudioCodec,
|
|
}
|
|
if res.Container != "" {
|
|
updates["container"] = res.Container
|
|
}
|
|
return s.repo.DB.Model(m).Updates(updates).Error
|
|
}
|