mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
8824638efb
Backend
- service/transcoder.go: encoder selector (software / nvenc / qsv /
vaapi) with proper hwaccel + scale_* filters per encoder; configurable
bitrate/preset/height/segment-seconds via transcoder.* config; new
Active() snapshot + ActiveJob struct for the Tasks panel; unit-tested
via buildFFmpegArgs(...).
- service/thetvdb.go: TheTVDB v4 provider — login() caches the JWT for
24h, SearchSeries() returns Match struct.
- service/fanart.go: Fanart.tv provider — high-res movie artwork keyed
by TMDb id; used to upgrade poster/backdrop after a successful match.
- service/scraper.go: provider chain reworked — anime → Bangumi, tv →
TheTVDB, default → TMDb, with optional Fanart upgrade post-match.
- service/discover.go: TMDb /trending/movie/day + /movie/popular for
the Discover rail.
- service/ai.go: OpenAI-compatible client. SmartSearch() turns a free-
form query into a structured SearchIntent JSON; Recommend() emits a
short list of titles given the user's history. Disabled when
ai.api_key is empty.
- service/nfo.go: per-movie .nfo writer (Kodi/Jellyfin schema) +
library-scope batch exporter.
- service/media.go: SoftDelete / Restore / Purge / ListRecycleBin
helpers backed by gorm Unscoped().
- service/service.go: container wires Fanart, TheTVDB, Discover, AI,
NFO; everything still tears down cleanly via Close().
- config: new transcoder.* section (encoder, preset, video_bitrate,
max_rate, buf_size, max_height, segment_seconds) with sensible
defaults.
Handlers / routes
- new files: discover.go, recycle.go, nfo.go, ai.go, tasks.go.
- registers GET /tasks, /discover/{trending,popular},
/ai/{status,search,recommend}; admin-only GET /recycle and
DELETE/POST /media/:id (soft delete / restore / purge); admin-only
POST /media/:id/nfo and /libraries/:id/nfo.
Frontend
- new pages: DiscoverPage, TasksPage, RecycleBinPage; SearchPage gains
an optional AI smart-search toggle that calls /api/ai/search.
- MediaDetailPage gains admin actions for 导出 NFO and 移至回收站.
- new api/ helpers: ai.ts, discover.ts, recycle.ts, tasks.ts.
- App.tsx routes /discover, /tasks, /recycle (last two admin-only).
- Layout sidebar groups now include Discover + Tasks + Recycle Bin.
Docker / config
- Dockerfile: adds intel-media-driver / libva-utils / mesa-va-gallium
so QSV + VAAPI work out of the box on Intel iGPUs.
- docker-compose.yml: documents devices + group_add + nvidia runtime
overrides for hardware transcoding.
Verified: go build, go vet, go test (incl. new TestBuildFFmpegArgs across
software / nvenc / qsv / vaapi encoder profiles); tsc -b && vite build
emits 22 route chunks plus the deferred hls chunk; main bundle 248 KB /
83 KB gzipped.
359 lines
9.4 KiB
Go
359 lines
9.4 KiB
Go
// Package service — HLS on-demand transcoder.
|
|
//
|
|
// TranscoderService spawns ffmpeg processes that segment a source media file
|
|
// into HLS (.m3u8 + .ts). The output lives under cache.cache_dir/hls/<id>.
|
|
// The HTTP layer serves these files directly with a normal http.FileServer.
|
|
//
|
|
// Encoder selection (read once at startup from the config):
|
|
//
|
|
// transcoder.encoder = "" | "nvenc" | "qsv" | "vaapi"
|
|
//
|
|
// "" software libx264 (default; runs anywhere)
|
|
// nvenc h264_nvenc (NVIDIA GPU, requires --gpus all on Docker)
|
|
// qsv h264_qsv (Intel iGPU, requires /dev/dri:/dev/dri)
|
|
// vaapi h264_vaapi (Mesa/Intel VAAPI, requires /dev/dri:/dev/dri
|
|
// plus the kernel module loaded)
|
|
//
|
|
// Concurrency model:
|
|
// - Each Media has at most one active ffmpeg job.
|
|
// - jobs[mediaID] tracks the running goroutine + cancel func.
|
|
// - Calling Start while a job already exists is a no-op.
|
|
// - When the playlist file appears on disk we consider the job "ready"
|
|
// and unblock the HTTP handler that was waiting on it.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
)
|
|
|
|
// TranscoderService orchestrates background ffmpeg transcodes.
|
|
type TranscoderService struct {
|
|
cfg *config.Config
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
hub *Hub
|
|
|
|
mu sync.Mutex
|
|
jobs map[string]*hlsJob
|
|
}
|
|
|
|
// hlsJob holds the live state of one ffmpeg run.
|
|
type hlsJob struct {
|
|
mediaID string
|
|
outputDir string
|
|
cancel context.CancelFunc
|
|
startedAt time.Time
|
|
playlistOK bool
|
|
encoder string
|
|
}
|
|
|
|
// NewTranscoderService is the constructor.
|
|
func NewTranscoderService(cfg *config.Config, log *zap.Logger, repo *repository.Container, hub *Hub) *TranscoderService {
|
|
return &TranscoderService{
|
|
cfg: cfg,
|
|
log: log,
|
|
repo: repo,
|
|
hub: hub,
|
|
jobs: make(map[string]*hlsJob),
|
|
}
|
|
}
|
|
|
|
// HLSDir is the per-media directory that holds index.m3u8 + segment files.
|
|
func (t *TranscoderService) HLSDir(mediaID string) string {
|
|
return filepath.Join(t.cfg.Cache.CacheDir, "hls", mediaID)
|
|
}
|
|
|
|
// PlaylistPath returns the absolute path of the m3u8 playlist for a media.
|
|
func (t *TranscoderService) PlaylistPath(mediaID string) string {
|
|
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
|
|
}
|
|
|
|
// EnsureJob makes sure a transcode is running for mediaID. The function is
|
|
// non-blocking: it returns the playlist path immediately. The caller is
|
|
// expected to poll until WaitReady reports true.
|
|
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
|
|
m, err := t.repo.Media.FindByID(ctx, mediaID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if m == nil {
|
|
return "", ErrMediaNotFound
|
|
}
|
|
if _, err := os.Stat(m.Path); err != nil {
|
|
return "", ErrMediaNotFound
|
|
}
|
|
|
|
t.mu.Lock()
|
|
if _, ok := t.jobs[mediaID]; ok {
|
|
t.mu.Unlock()
|
|
return t.PlaylistPath(mediaID), nil
|
|
}
|
|
|
|
outDir := t.HLSDir(mediaID)
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
t.mu.Unlock()
|
|
return "", err
|
|
}
|
|
|
|
jobCtx, cancel := context.WithCancel(context.Background())
|
|
job := &hlsJob{
|
|
mediaID: mediaID,
|
|
outputDir: outDir,
|
|
cancel: cancel,
|
|
startedAt: time.Now(),
|
|
encoder: t.cfg.Transcoder.Encoder,
|
|
}
|
|
t.jobs[mediaID] = job
|
|
t.mu.Unlock()
|
|
|
|
go t.runFFmpeg(jobCtx, job, m.Path)
|
|
return t.PlaylistPath(mediaID), nil
|
|
}
|
|
|
|
// WaitReady blocks (with a deadline) until the playlist file shows up on
|
|
// disk. Returns true on success.
|
|
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
if _, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
|
|
t.mu.Lock()
|
|
if j, ok := t.jobs[mediaID]; ok {
|
|
j.playlistOK = true
|
|
}
|
|
t.mu.Unlock()
|
|
return true
|
|
}
|
|
if time.Now().After(deadline) || ctx.Err() != nil {
|
|
return false
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-time.After(250 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// StopJob cancels a running ffmpeg process for mediaID, if any.
|
|
func (t *TranscoderService) StopJob(mediaID string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if j, ok := t.jobs[mediaID]; ok {
|
|
j.cancel()
|
|
delete(t.jobs, mediaID)
|
|
}
|
|
}
|
|
|
|
// StopAll terminates every running transcode (called on graceful shutdown).
|
|
func (t *TranscoderService) StopAll() {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
for id, j := range t.jobs {
|
|
j.cancel()
|
|
delete(t.jobs, id)
|
|
}
|
|
}
|
|
|
|
// ActiveJob is the JSON shape exposed to the React Tasks panel.
|
|
type ActiveJob struct {
|
|
MediaID string `json:"media_id"`
|
|
Encoder string `json:"encoder"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
PlaylistOK bool `json:"playlist_ok"`
|
|
}
|
|
|
|
// Active returns a snapshot of the currently running transcode jobs.
|
|
func (t *TranscoderService) Active() []ActiveJob {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
out := make([]ActiveJob, 0, len(t.jobs))
|
|
for _, j := range t.jobs {
|
|
out = append(out, ActiveJob{
|
|
MediaID: j.mediaID,
|
|
Encoder: j.encoder,
|
|
StartedAt: j.startedAt,
|
|
PlaylistOK: j.playlistOK,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
|
|
bin := t.cfg.App.FFmpegPath
|
|
if bin == "" {
|
|
bin = "ffmpeg"
|
|
}
|
|
|
|
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
|
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
|
|
|
|
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
|
|
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
t.log.Info("transcode started",
|
|
zap.String("media_id", job.mediaID),
|
|
zap.String("encoder", job.encoder),
|
|
zap.String("source", source),
|
|
)
|
|
t.hub.Publish("transcode", map[string]any{
|
|
"media_id": job.mediaID,
|
|
"encoder": job.encoder,
|
|
"status": "started",
|
|
})
|
|
|
|
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
|
|
t.log.Warn("ffmpeg exited",
|
|
zap.String("media_id", job.mediaID),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
|
|
t.mu.Lock()
|
|
delete(t.jobs, job.mediaID)
|
|
t.mu.Unlock()
|
|
|
|
t.hub.Publish("transcode", map[string]any{
|
|
"media_id": job.mediaID,
|
|
"status": "stopped",
|
|
"duration": time.Since(job.startedAt).Seconds(),
|
|
})
|
|
}
|
|
|
|
// buildFFmpegArgs assembles the ffmpeg command line for the configured
|
|
// encoder. The function is package-level so the unit test can pin its
|
|
// behaviour without spawning a real ffmpeg process.
|
|
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
|
|
enc := cfg.Transcoder.Encoder
|
|
bitrate := cfg.Transcoder.VideoBitrate
|
|
if bitrate == "" {
|
|
bitrate = "1500k"
|
|
}
|
|
maxrate := cfg.Transcoder.MaxRate
|
|
if maxrate == "" {
|
|
maxrate = "1800k"
|
|
}
|
|
bufsize := cfg.Transcoder.BufSize
|
|
if bufsize == "" {
|
|
bufsize = "3000k"
|
|
}
|
|
preset := cfg.Transcoder.Preset
|
|
if preset == "" {
|
|
preset = "veryfast"
|
|
}
|
|
height := cfg.Transcoder.MaxHeight
|
|
if height <= 0 {
|
|
height = 720
|
|
}
|
|
segDur := cfg.Transcoder.SegmentSeconds
|
|
if segDur <= 0 {
|
|
segDur = 4
|
|
}
|
|
|
|
// Hardware-accel arguments differ in three places:
|
|
// - Optional input flags (-hwaccel + device init)
|
|
// - Optional input pixel-format upload filter
|
|
// - The actual -c:v encoder name + preset/quality flag
|
|
var pre, vf, vcodec, vpreset string
|
|
switch enc {
|
|
case "nvenc":
|
|
pre = "-hwaccel cuda -hwaccel_output_format cuda"
|
|
vf = fmt.Sprintf("scale_cuda=-2:min(%d\\,ih)", height)
|
|
vcodec = "h264_nvenc"
|
|
vpreset = "p4"
|
|
case "qsv":
|
|
pre = "-hwaccel qsv -hwaccel_output_format qsv"
|
|
vf = fmt.Sprintf("scale_qsv=-1:min(%d\\,ih)", height)
|
|
vcodec = "h264_qsv"
|
|
vpreset = preset
|
|
case "vaapi":
|
|
device := cfg.App.VAAPIDevice
|
|
if device == "" {
|
|
device = "/dev/dri/renderD128"
|
|
}
|
|
pre = fmt.Sprintf("-hwaccel vaapi -vaapi_device %s -hwaccel_output_format vaapi", device)
|
|
vf = fmt.Sprintf("scale_vaapi=-2:min(%d\\,ih),format=nv12|vaapi,hwupload", height)
|
|
vcodec = "h264_vaapi"
|
|
vpreset = ""
|
|
default:
|
|
// software
|
|
pre = ""
|
|
vf = fmt.Sprintf("scale=-2:min(%d\\,ih)", height)
|
|
vcodec = "libx264"
|
|
vpreset = preset
|
|
}
|
|
|
|
args := []string{"-y", "-fflags", "+genpts"}
|
|
for _, p := range splitNonEmptyArgs(pre) {
|
|
args = append(args, p)
|
|
}
|
|
args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", vf, "-c:v", vcodec)
|
|
if vpreset != "" {
|
|
args = append(args, "-preset", vpreset)
|
|
}
|
|
args = append(args,
|
|
"-pix_fmt", "yuv420p",
|
|
"-b:v", bitrate,
|
|
"-maxrate", maxrate,
|
|
"-bufsize", bufsize,
|
|
"-c:a", "aac",
|
|
"-ar", "48000",
|
|
"-b:a", "128k",
|
|
"-ac", "2",
|
|
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segDur),
|
|
"-f", "hls",
|
|
"-hls_time", fmt.Sprintf("%d", segDur),
|
|
"-hls_list_size", "0",
|
|
"-hls_segment_type", "mpegts",
|
|
"-hls_flags", "independent_segments",
|
|
"-hls_segment_filename", segments,
|
|
playlist,
|
|
)
|
|
return args
|
|
}
|
|
|
|
// splitNonEmptyArgs is a tiny helper that mirrors strings.Fields for the
|
|
// pre-input flag string without dragging the strings import into the hot
|
|
// path of every call to buildFFmpegArgs.
|
|
func splitNonEmptyArgs(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, 4)
|
|
field := make([]rune, 0, 16)
|
|
flush := func() {
|
|
if len(field) > 0 {
|
|
out = append(out, string(field))
|
|
field = field[:0]
|
|
}
|
|
}
|
|
for _, r := range s {
|
|
if r == ' ' || r == '\t' {
|
|
flush()
|
|
continue
|
|
}
|
|
field = append(field, r)
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
// HumanFFmpegProfile is exposed for the admin UI / settings view.
|
|
func (t *TranscoderService) HumanFFmpegProfile() string {
|
|
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
|
|
t.cfg.App.FFmpegPath, t.cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
|
|
}
|