feat: hwaccel + Fanart/TheTVDB + AI + Discover + NFO + Tasks + Recycle bin

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.
This commit is contained in:
Kiro
2026-05-14 16:22:08 +00:00
parent 7bd6bcfb1b
commit 8824638efb
32 changed files with 1795 additions and 83 deletions
+6
View File
@@ -33,8 +33,14 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
# ---- Stage 3: runtime ------------------------------------------------------
FROM alpine:3.19
# ffmpeg/intel-media-driver/libva enable QSV / VAAPI on Intel iGPUs.
# NVENC requires the proprietary NVIDIA Container Toolkit on the host —
# no extra packages needed inside the image, only --gpus all on docker run.
RUN apk add --no-cache \
ffmpeg \
intel-media-driver \
libva-utils \
mesa-va-gallium \
tzdata \
ca-certificates \
wget \
+16 -6
View File
@@ -42,7 +42,10 @@ making deployment painless on NAS hardware.
- ✅ Library CRUD + recursive filesystem scan
- ✅ ffprobe metadata extraction (duration / resolution / codecs / container)
- ✅ Scene-noise filename cleaner with year + season/episode parsing
- ✅ TMDb scraper (movies) + Bangumi scraper (anime), provider chain by library type
- ✅ Multi-provider scrape chain by library type:
- movie → TMDb (with optional Fanart.tv high-res poster upgrade)
- tv → TheTVDB (fallback TMDb)
- anime → Bangumi (fallback TMDb)
- ✅ Image proxy with disk cache for TMDb / Bangumi / Douban / Fanart / TheTVDB
- ✅ TV / anime libraries grouped by season with episode listing
- ✅ fsnotify-based filesystem watcher with 5 s coalescing debouncer
@@ -61,8 +64,17 @@ making deployment painless on NAS hardware.
### Operations
- ✅ Real-time scan / scrape / transcode / download / subscription events over WebSocket
- ✅ Operator dashboard at `/stats` (CPU / memory / disk / library counts / Goroutines)
- ✅ Real-time tasks panel at `/tasks` (active ffmpeg jobs + qBittorrent torrents)
- ✅ Recycle bin at `/recycle` (soft delete + restore + purge)
- ✅ NFO export (Kodi / Jellyfin compatibility) — single media or whole library
- ✅ Hardware-accel encoder profiles: software / NVENC / Intel QSV / VAAPI
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI + GHCR publish
### Discovery & AI
- ✅ TMDb Discover — Trending (today) + Popular rails on `/discover`
- ✅ AI smart search (OpenAI-compatible) — natural-language queries → structured intent
- ✅ AI recommendations seeded from your watch history (`GET /api/ai/recommend`)
### Frontend
- ✅ React SPA with code-splitting: Login / Home / Library / Search / Favourites /
Playlists / Media detail / Player (HLS + direct + subtitles) / Profile /
@@ -74,12 +86,10 @@ making deployment painless on NAS hardware.
| Area | Status |
|------|--------|
| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) profiles | ⏳ |
| Douban / Fanart / TheTVDB providers | ⏳ |
| Subtitle search (online providers) | ⏳ |
| Emby/Jellyfin compatibility layer | ⏳ |
| Bidirectional Jellyfin / Emby compatibility layer | ⏳ |
| DLNA / Chromecast | ⏳ |
| AI metadata enhancement & smart search | ⏳ |
| Online subtitle search providers | ⏳ |
| Multi-bitrate ABR transcode profiles | ⏳ |
---
+14
View File
@@ -64,6 +64,20 @@ media:
tv_dir: ""
anime_dir: ""
transcoder:
# Hardware encoder for HLS transcodes.
# "" software libx264 (default; runs anywhere)
# nvenc h264_nvenc (NVIDIA GPU, requires --gpus all)
# qsv h264_qsv (Intel iGPU, requires /dev/dri)
# vaapi h264_vaapi (Intel/AMD VAAPI, requires /dev/dri)
encoder: ""
preset: veryfast
video_bitrate: 1500k
max_rate: 1800k
buf_size: 3000k
max_height: 720
segment_seconds: 4
ai:
enabled: false
provider: openai # openai | deepseek | qwen | ollama
+13 -1
View File
@@ -34,15 +34,27 @@ services:
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
MEDIASTATION_CACHE_CACHE_DIR: /cache
MEDIASTATION_LOGGING_LEVEL: info
# MEDIASTATION_TRANSCODER_ENCODER: vaapi # qsv | vaapi | nvenc | (empty=software)
# MEDIASTATION_TRANSCODER_MAX_HEIGHT: 1080
# MEDIASTATION_SECRETS_JWT_SECRET: change-me-to-a-strong-random-string
# MEDIASTATION_APP_CORS_ORIGINS: '["https://media.example.com"]'
PUID: "1000"
PGID: "1000"
TZ: Asia/Shanghai
# Hardware-accelerated transcoding (uncomment when you have an Intel iGPU).
# Hardware-accelerated transcoding.
# * Intel iGPU (QSV / VAAPI): mount /dev/dri.
# * NVIDIA NVENC: switch the runtime to nvidia and add `gpus: all`.
# devices:
# - /dev/dri:/dev/dri
# group_add:
# - "989" # match render GID on host
# runtime: nvidia
# deploy:
# resources:
# reservations:
# devices:
# - capabilities: [gpu]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
+27 -7
View File
@@ -30,13 +30,25 @@ const EnvPrefix = "MEDIASTATION"
// Config is the root config aggregate.
type Config struct {
App AppConfig `mapstructure:"app"`
Database DatabaseConfig `mapstructure:"database"`
Secrets SecretsConfig `mapstructure:"secrets"`
Logging LoggingConfig `mapstructure:"logging"`
Cache CacheConfig `mapstructure:"cache"`
Media MediaConfig `mapstructure:"media"`
AI AIConfig `mapstructure:"ai"`
App AppConfig `mapstructure:"app"`
Database DatabaseConfig `mapstructure:"database"`
Secrets SecretsConfig `mapstructure:"secrets"`
Logging LoggingConfig `mapstructure:"logging"`
Cache CacheConfig `mapstructure:"cache"`
Media MediaConfig `mapstructure:"media"`
Transcoder TranscoderConfig `mapstructure:"transcoder"`
AI AIConfig `mapstructure:"ai"`
}
// TranscoderConfig controls the HLS / ffmpeg backend.
type TranscoderConfig struct {
Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi
Preset string `mapstructure:"preset"`
VideoBitrate string `mapstructure:"video_bitrate"`
MaxRate string `mapstructure:"max_rate"`
BufSize string `mapstructure:"buf_size"`
MaxHeight int `mapstructure:"max_height"`
SegmentSeconds int `mapstructure:"segment_seconds"`
}
// AppConfig holds runtime app parameters.
@@ -195,6 +207,14 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("ai.model", "gpt-4o-mini")
v.SetDefault("ai.timeout", 30)
v.SetDefault("ai.max_concurrent", 3)
v.SetDefault("transcoder.encoder", "")
v.SetDefault("transcoder.preset", "veryfast")
v.SetDefault("transcoder.video_bitrate", "1500k")
v.SetDefault("transcoder.max_rate", "1800k")
v.SetDefault("transcoder.buf_size", "3000k")
v.SetDefault("transcoder.max_height", 720)
v.SetDefault("transcoder.segment_seconds", 4)
}
// normalize fills derived defaults and self-heals empty critical fields.
+71
View File
@@ -0,0 +1,71 @@
// Package handler — AI integration endpoints.
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type smartSearchReq struct {
Query string `json:"query" binding:"required"`
}
func smartSearchHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req smartSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
intent, err := svc.AI.SmartSearch(c.Request.Context(), req.Query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Run the actual library search using the cleaned query so the
// caller can render results in one round-trip.
items, _ := svc.Media.SearchMedia(c.Request.Context(), intent.Query, 60)
c.JSON(http.StatusOK, gin.H{
"intent": intent,
"items": items,
})
}
}
func aiRecommendHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
hist, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), 10)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
titles := make([]string, 0, len(hist))
for _, h := range hist {
if h.Media != nil && strings.TrimSpace(h.Media.Title) != "" {
titles = append(titles, h.Media.Title)
}
}
out, err := svc.AI.Recommend(c.Request.Context(), titles, 8)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"titles": out})
}
}
func aiStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"enabled": svc.AI.Enabled(),
"provider": svc.Cfg.AI.Provider,
"model": svc.Cfg.AI.Model,
})
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package handler — TMDb discovery endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func trendingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Discover.Trending(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func popularHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Discover.Popular(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
+18
View File
@@ -50,8 +50,13 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.GET("/media", searchMediaHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
authed.DELETE("/media/:id", middleware.AdminRequired(), deleteMediaHandler(svc))
authed.POST("/media/:id/restore", middleware.AdminRequired(), restoreMediaHandler(svc))
authed.DELETE("/media/:id/purge", middleware.AdminRequired(), purgeMediaHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
authed.POST("/media/:id/nfo", middleware.AdminRequired(), exportNFOHandler(svc))
authed.POST("/libraries/:id/nfo", middleware.AdminRequired(), exportLibraryNFOHandler(svc))
// Streaming.
authed.GET("/stream/:id", streamHandler(svc))
@@ -90,6 +95,19 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// Stats / dashboard.
authed.GET("/stats", statsHandler(svc))
authed.GET("/tasks", tasksHandler(svc))
// Discover (TMDb trending / popular).
authed.GET("/discover/trending", trendingHandler(svc))
authed.GET("/discover/popular", popularHandler(svc))
// AI.
authed.GET("/ai/status", aiStatusHandler(svc))
authed.POST("/ai/search", smartSearchHandler(svc))
authed.GET("/ai/recommend", aiRecommendHandler(svc))
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
authed.GET("/ws", wsHandler(svc))
}
+32
View File
@@ -0,0 +1,32 @@
// Package handler — NFO export endpoints (Kodi / Jellyfin compatibility).
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func exportNFOHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path, err := svc.NFO.ExportOne(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"path": path})
}
}
func exportLibraryNFOHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
written, err := svc.NFO.ExportLibrary(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"written": written})
}
}
+51
View File
@@ -0,0 +1,51 @@
// Package handler — recycle bin endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func deleteMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.SoftDelete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func listRecycleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Media.ListRecycleBin(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func restoreMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.RestoreDeleted(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func purgeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Media.PurgeDeleted(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package handler — live tasks board.
//
// /api/tasks aggregates running ffmpeg transcodes, qBittorrent torrents
// and recent scrape progress into a single snapshot suitable for the
// React Tasks panel. The panel can layer this REST snapshot on top of
// live WS events for instant updates.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func tasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
transcodes := svc.Transcoder.Active()
_, torrents, _ := svc.Downloads.List(c.Request.Context())
c.JSON(http.StatusOK, gin.H{
"transcodes": transcodes,
"torrents": torrents,
})
}
}
+166
View File
@@ -0,0 +1,166 @@
// Package service — AI integration (OpenAI-compatible chat completions).
//
// AIService is a thin wrapper around any OpenAI-compatible REST endpoint
// (OpenAI, DeepSeek, Qwen, Ollama, …). Today we expose two operations:
//
// - SmartSearch: interpret a free-form Chinese / English query and
// return a normalised JSON intent the React UI can
// translate into filter params.
// - Recommend: given a list of recently-watched titles, generate
// a short list of "you might like…" recommendations.
//
// The service is disabled (every method returns nil) when ai.enabled is
// false or ai.api_key is empty.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// AIService talks to an OpenAI-compatible chat-completions endpoint.
type AIService struct {
cfg *config.Config
log *zap.Logger
client *http.Client
}
// NewAIService is the constructor.
func NewAIService(cfg *config.Config, log *zap.Logger) *AIService {
timeout := time.Duration(cfg.AI.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
return &AIService{
cfg: cfg,
log: log,
client: &http.Client{Timeout: timeout},
}
}
// Enabled reports whether the AI integration is configured.
func (a *AIService) Enabled() bool {
return a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != ""
}
// SearchIntent is the structured output the smart search endpoint returns.
type SearchIntent struct {
Query string `json:"query"`
Year int `json:"year,omitempty"`
Genre string `json:"genre,omitempty"`
Type string `json:"type,omitempty"` // movie / tv / anime / music
Sort string `json:"sort,omitempty"` // recent / rating / random
Language string `json:"language,omitempty"`
}
// SmartSearch turns a natural-language query into a structured intent.
// Returns a best-effort intent on parse failure (raw query passes through).
func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent, error) {
if !a.Enabled() {
return &SearchIntent{Query: raw}, nil
}
const sys = "You are a media-library search assistant. Read the user's query and " +
"output a JSON object with the keys: query (string), year (int, optional), " +
"genre (string, optional), type (movie|tv|anime|music, optional), sort " +
"(recent|rating|random, optional), language (zh|en, optional). Respond with " +
"JSON only, no commentary."
out, err := a.complete(ctx, sys, raw)
if err != nil {
return &SearchIntent{Query: raw}, err
}
var intent SearchIntent
if err := json.Unmarshal([]byte(out), &intent); err != nil {
// Fallback: tolerate non-JSON output by treating the raw text as
// the cleaned query.
intent.Query = strings.TrimSpace(out)
}
if intent.Query == "" {
intent.Query = raw
}
return &intent, nil
}
// Recommend builds a short comma-separated list of titles given the user's
// history. The first call is intentionally best-effort: a future iteration
// may chain media DB lookups onto each suggestion.
func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([]string, error) {
if !a.Enabled() || len(history) == 0 {
return nil, nil
}
if max <= 0 || max > 20 {
max = 8
}
sys := fmt.Sprintf("You are a film / TV recommendation assistant. Reply with %d "+
"comma-separated titles only, no commentary, in the same language as the input.", max)
usr := "I recently watched: " + strings.Join(history, "; ")
out, err := a.complete(ctx, sys, usr)
if err != nil {
return nil, err
}
parts := strings.Split(out, ",")
titles := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
p = strings.Trim(p, "\"'`")
if p != "" {
titles = append(titles, p)
}
}
return titles, nil
}
// complete is the shared helper — POST /v1/chat/completions.
func (a *AIService) complete(ctx context.Context, system, user string) (string, error) {
payload := map[string]any{
"model": a.cfg.AI.Model,
"temperature": 0.2,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(a.cfg.AI.APIBase, "/") + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.cfg.AI.APIKey)
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
type choice struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
var out struct {
Choices []choice `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if len(out.Choices) == 0 {
return "", errors.New("ai: empty completion")
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
+117
View File
@@ -0,0 +1,117 @@
// Package service — TMDb discovery (trending / popular).
//
// DiscoverService surfaces curated lists from TMDb so the React home
// page can show "Trending" and "Popular" rails alongside the user's own
// library. All methods gracefully no-op when the TMDb provider is
// disabled.
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"go.uber.org/zap"
)
// DiscoverService talks to TMDb's /trending and /movie/popular endpoints.
type DiscoverService struct {
log *zap.Logger
tmdb *TMDbProvider
client *http.Client
}
// NewDiscoverService is the constructor.
func NewDiscoverService(log *zap.Logger, tmdb *TMDbProvider) *DiscoverService {
return &DiscoverService{
log: log,
tmdb: tmdb,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Trending returns the daily trending movies (TMDb /trending/movie/day).
func (d *DiscoverService) Trending(ctx context.Context) ([]Match, error) {
return d.fetch(ctx, "/trending/movie/day")
}
// Popular returns the popular movies list (TMDb /movie/popular).
func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) {
return d.fetch(ctx, "/movie/popular")
}
// fetch is the shared helper that paginates page=1 only — that's all the
// home page needs and it keeps us under TMDb's 50 rps limit.
func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) {
if d.tmdb == nil || !d.tmdb.Enabled() {
return nil, nil
}
q := url.Values{}
q.Set("api_key", d.tmdb.cfg.Secrets.TMDbAPIKey)
q.Set("language", "zh-CN")
q.Set("page", "1")
u := d.tmdb.base + path + "?" + q.Encode()
type result struct {
ID int `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
}
type page struct {
Results []result `json:"results"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("tmdb %s: %d", path, resp.StatusCode)
}
var p page
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, err
}
out := make([]Match, 0, len(p.Results))
for _, r := range p.Results {
title := r.Title
if title == "" {
title = r.Name
}
m := Match{
TMDbID: r.ID,
Title: title,
Overview: r.Overview,
Rating: r.VoteAverage,
}
if r.PosterPath != "" {
m.PosterURL = d.tmdb.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = d.tmdb.imgCDN + "/w1280" + r.BackdropPath
}
date := r.ReleaseDate
if date == "" {
date = r.FirstAirDate
}
if len(date) >= 4 {
fmt.Sscanf(date[:4], "%d", &m.Year)
}
out = append(out, m)
}
return out, nil
}
+109
View File
@@ -0,0 +1,109 @@
// Package service — Fanart.tv image provider.
//
// Fanart.tv (https://fanart.tv) hosts community-curated artwork. We use
// it to upgrade movie / tv posters and backdrops with higher-resolution
// alternatives once a TMDb / Bangumi match is established.
//
// Endpoints used:
//
// GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id)
// GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id)
//
// The provider is enabled iff secrets.fanart_tv_api_key is non-empty.
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// FanartProvider talks to https://webservice.fanart.tv.
type FanartProvider struct {
cfg *config.Config
log *zap.Logger
client *http.Client
}
// NewFanartProvider is the constructor.
func NewFanartProvider(cfg *config.Config, log *zap.Logger) *FanartProvider {
return &FanartProvider{
cfg: cfg,
log: log,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Enabled reports whether an API key is configured.
func (f *FanartProvider) Enabled() bool { return f.cfg.Secrets.FanartAPIKey != "" }
// Artwork is the high-res image set returned by Fanart.tv.
type Artwork struct {
Poster string `json:"poster"`
Backdrop string `json:"backdrop"`
Logo string `json:"logo"`
Thumb string `json:"thumb"`
}
// MovieArtwork looks up the artwork bundle for a TMDb movie id.
func (f *FanartProvider) MovieArtwork(ctx context.Context, tmdbID int) (*Artwork, error) {
if !f.Enabled() || tmdbID <= 0 {
return nil, nil
}
type entry struct {
URL string `json:"url"`
Lang string `json:"lang"`
}
type page struct {
MoviePoster []entry `json:"movieposter"`
MovieBackgr []entry `json:"moviebackground"`
HDLogo []entry `json:"hdmovielogo"`
MovieThumb []entry `json:"moviethumb"`
}
u := fmt.Sprintf("https://webservice.fanart.tv/v3/movies/%d?api_key=%s",
tmdbID, f.cfg.Secrets.FanartAPIKey)
var p page
if err := f.getJSON(ctx, u, &p); err != nil {
return nil, err
}
a := &Artwork{}
if len(p.MoviePoster) > 0 {
a.Poster = p.MoviePoster[0].URL
}
if len(p.MovieBackgr) > 0 {
a.Backdrop = p.MovieBackgr[0].URL
}
if len(p.HDLogo) > 0 {
a.Logo = p.HDLogo[0].URL
}
if len(p.MovieThumb) > 0 {
a.Thumb = p.MovieThumb[0].URL
}
return a, nil
}
func (f *FanartProvider) getJSON(ctx context.Context, u string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := f.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 400 {
return fmt.Errorf("fanart %s: %d", u, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
+31
View File
@@ -86,3 +86,34 @@ func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int)
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
return s.repo.Media.FindByID(ctx, id)
}
// SoftDelete moves a media row to the recycle bin (gorm soft delete).
// The on-disk file is kept; admins can purge it later.
func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
return s.repo.DB.Where("id = ?", id).Delete(&model.Media{}).Error
}
// RestoreDeleted unsets DeletedAt for a single media row.
func (s *MediaService) RestoreDeleted(ctx context.Context, id string) error {
return s.repo.DB.Unscoped().Model(&model.Media{}).
Where("id = ?", id).Update("deleted_at", nil).Error
}
// ListRecycleBin returns every soft-deleted row, newest first.
func (s *MediaService) ListRecycleBin(ctx context.Context, limit int) ([]model.Media, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
var rows []model.Media
err := s.repo.DB.Unscoped().
Where("deleted_at IS NOT NULL").
Order("deleted_at desc").
Limit(limit).
Find(&rows).Error
return rows, err
}
// PurgeDeleted permanently removes a soft-deleted row from the database.
func (s *MediaService) PurgeDeleted(ctx context.Context, id string) error {
return s.repo.DB.Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error
}
+115
View File
@@ -0,0 +1,115 @@
// Package service — NFO writer (Kodi / Jellyfin compatibility).
//
// Kodi and Jellyfin index media using sidecar XML files alongside the
// source video. We export a minimal subset that those scrapers consume
// happily:
//
// movie.mkv -> movie.nfo (<movie>...</movie>)
// tvshow/ -> tvshow.nfo (<tvshow>...</tvshow>) [future]
//
// Today only the per-movie writer is implemented; the per-show / per-episode
// writers are stubbed with TODO markers.
package service
import (
"context"
"encoding/xml"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// NFOService is the entry point used by the admin "导出 NFO" action.
type NFOService struct {
log *zap.Logger
repo *repository.Container
}
// NewNFOService is the constructor.
func NewNFOService(log *zap.Logger, repo *repository.Container) *NFOService {
return &NFOService{log: log, repo: repo}
}
// movieNFO is the on-disk schema. Tag names match Kodi's expectations.
type movieNFO struct {
XMLName xml.Name `xml:"movie"`
Title string `xml:"title"`
Original string `xml:"originaltitle,omitempty"`
Year int `xml:"year,omitempty"`
Plot string `xml:"plot,omitempty"`
Rating float32 `xml:"rating,omitempty"`
Poster string `xml:"thumb,omitempty"`
Fanart string `xml:"fanart,omitempty"`
TMDb int `xml:"tmdbid,omitempty"`
}
// ExportOne writes a movie.nfo file next to the media file. Existing files
// are overwritten so a re-scrape always reflects the latest metadata.
func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", err
}
if m == nil {
return "", errors.New("media not found")
}
if m.Path == "" {
return "", errors.New("media has empty path")
}
doc := movieNFO{
Title: m.Title,
Original: m.OriginalName,
Year: m.Year,
Plot: m.Overview,
Rating: m.Rating,
Poster: m.PosterURL,
Fanart: m.BackdropURL,
TMDb: m.TMDbID,
}
out, err := xml.MarshalIndent(doc, "", " ")
if err != nil {
return "", err
}
body := []byte(xml.Header + string(out) + "\n")
dst := nfoPath(m.Path)
if err := os.WriteFile(dst, body, 0o644); err != nil {
return "", err
}
s.log.Info("nfo exported", zap.String("media_id", m.ID), zap.String("path", dst))
return dst, nil
}
// ExportLibrary loops through every matched movie in a library and writes
// an .nfo for each. Returns (written, error).
func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int, error) {
type row struct{ ID string }
var ids []row
q := s.repo.DB.Table("media").Select("id").Where("scrape_status = ?", "matched")
if libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
if err := q.Scan(&ids).Error; err != nil {
return 0, err
}
written := 0
for _, r := range ids {
if _, err := s.ExportOne(ctx, r.ID); err == nil {
written++
}
}
return written, nil
}
func nfoPath(media string) string {
dir := filepath.Dir(media)
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
}
+36 -16
View File
@@ -4,12 +4,11 @@
// from one or more providers. Selection is driven by the library type:
//
// library.type == "anime" -> Bangumi (fallback: TMDb)
// library.type == "tv" -> TMDb (movies) — TV episodes inherit
// series metadata; episode-level scraping
// is left as a future step
// library.type == "tv" -> TheTVDB (fallback: TMDb)
// default -> TMDb
//
// The orchestrator publishes scrape progress events on the WS hub.
// After the primary match we optionally upgrade poster / backdrop with
// Fanart.tv when an API key is configured.
package service
import (
@@ -34,6 +33,8 @@ type ScraperService struct {
repo *repository.Container
tmdb *TMDbProvider
bangumi *BangumiProvider
thetvdb *TheTVDBProvider
fanart *FanartProvider
hub *Hub
}
@@ -44,11 +45,13 @@ func NewScraperService(
repo *repository.Container,
tmdb *TMDbProvider,
bangumi *BangumiProvider,
thetvdb *TheTVDBProvider,
fanart *FanartProvider,
hub *Hub,
) *ScraperService {
return &ScraperService{
cfg: cfg, log: log, repo: repo,
tmdb: tmdb, bangumi: bangumi, hub: hub,
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, hub: hub,
}
}
@@ -74,7 +77,6 @@ func CleanQuery(raw string) (title string, year int) {
name := strings.TrimSuffix(filepath.Base(raw), filepath.Ext(raw))
lower := strings.ToLower(name)
// 1. Year first — bracketed years (1999) must survive the next step.
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
if v, err := strconv.Atoi(m[1]); err == nil {
year = v
@@ -82,10 +84,8 @@ func CleanQuery(raw string) (title string, year int) {
}
}
// 2. Drop everything inside brackets.
lower = bracketedTag.ReplaceAllString(lower, " ")
// 3. Drop episode markers (S01E02 / 1x02 / EP05 / 第03集).
lower = patSEnE.ReplaceAllString(lower, " ")
lower = patNxE.ReplaceAllString(lower, " ")
lower = patEP.ReplaceAllString(lower, " ")
@@ -102,9 +102,7 @@ func CleanQuery(raw string) (title string, year int) {
return strings.TrimSpace(title), year
}
// EnrichOne runs the provider chain for a single media row. The library's
// type decides which provider goes first; a fallback runs when the primary
// returns nothing.
// EnrichOne runs the provider chain for a single media row.
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
lib, err := s.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil {
@@ -124,12 +122,23 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
match := s.lookup(ctx, lib, query, year)
if match == nil {
// Mark explicitly so we don't retry forever.
_ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error
return nil
}
// Optional Fanart upgrade.
if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 {
if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil {
if a.Poster != "" {
match.PosterURL = a.Poster
}
if a.Backdrop != "" {
match.BackdropURL = a.Backdrop
}
}
}
updates := map[string]any{
"title": match.Title,
"overview": match.Overview,
@@ -165,11 +174,19 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
if lib != nil {
kind = lib.Type
}
if kind == "anime" && s.bangumi != nil {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
return m
switch kind {
case "anime":
if s.bangumi != nil {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
return m
}
}
case "tv":
if s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
return m
}
}
s.log.Debug("bangumi miss, falling back to tmdb", zap.String("query", query))
}
if s.tmdb != nil && s.tmdb.Enabled() {
if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil {
@@ -220,5 +237,8 @@ func (s *ScraperService) AnyEnabled() bool {
if s.bangumi != nil && s.bangumi.Enabled() {
return true
}
if s.thetvdb != nil && s.thetvdb.Enabled() {
return true
}
return false
}
+16 -1
View File
@@ -28,7 +28,10 @@ type Container struct {
FFprobe *FFprobeService
TMDb *TMDbProvider
Bangumi *BangumiProvider
TheTVDB *TheTVDBProvider
Fanart *FanartProvider
Scraper *ScraperService
Discover *DiscoverService
Playback *PlaybackService
ImageProxy *ImageProxy
Watcher *WatcherService
@@ -38,6 +41,8 @@ type Container struct {
Stats *StatsService
Profile *ProfileService
Audit *AuditService
NFO *NFOService
AI *AIService
stopCtx context.Context
stopCancel context.CancelFunc
@@ -51,12 +56,17 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
probe := NewFFprobeService(cfg, log)
tmdb := NewTMDbProvider(cfg, log)
bangumi := NewBangumiProvider(cfg, log)
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, hub)
thetvdb := NewTheTVDBProvider(cfg, log)
fanart := NewFanartProvider(cfg, log)
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub)
discover := NewDiscoverService(log, tmdb)
transcoder := NewTranscoderService(cfg, log, repos, hub)
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
downloads := NewDownloadService(log, repos, hub)
subscription := NewSubscriptionService(log, repos, downloads, hub)
watcher := NewWatcherService(log, repos, scanner)
nfo := NewNFOService(log, repos)
ai := NewAIService(cfg, log)
ctx, cancel := context.WithCancel(context.Background())
@@ -73,7 +83,10 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
FFprobe: probe,
TMDb: tmdb,
Bangumi: bangumi,
TheTVDB: thetvdb,
Fanart: fanart,
Scraper: scraper,
Discover: discover,
Playback: NewPlaybackService(log, repos),
ImageProxy: NewImageProxy(cfg, log),
Watcher: watcher,
@@ -83,6 +96,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
Stats: NewStatsService(log, repos),
Profile: NewProfileService(log, repos),
Audit: NewAuditService(log, repos),
NFO: nfo,
AI: ai,
stopCtx: ctx,
stopCancel: cancel,
}
+159
View File
@@ -0,0 +1,159 @@
// Package service — TheTVDB v4 provider.
//
// TheTVDBProvider implements two methods used by the scraper for TV /
// anime libraries:
//
// Login() -> exchanges secrets.thetvdb_api_key for
// a JWT (cached for 24h).
// SearchSeries(query) -> /search?query=...&type=series
//
// The provider is enabled iff secrets.thetvdb_api_key is non-empty. When
// disabled every method returns nil, nil so the scraper orchestrator can
// gracefully fall through.
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// TheTVDBProvider talks to https://api4.thetvdb.com/v4.
type TheTVDBProvider struct {
cfg *config.Config
log *zap.Logger
client *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// NewTheTVDBProvider is the constructor.
func NewTheTVDBProvider(cfg *config.Config, log *zap.Logger) *TheTVDBProvider {
return &TheTVDBProvider{
cfg: cfg,
log: log,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Enabled reports whether an API key is present.
func (t *TheTVDBProvider) Enabled() bool { return t.cfg.Secrets.TheTVDBAPIKey != "" }
// Login fetches a fresh JWT, cached for 24h.
func (t *TheTVDBProvider) Login(ctx context.Context) (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
if time.Now().Before(t.tokenExp) && t.token != "" {
return t.token, nil
}
body, _ := json.Marshal(map[string]string{"apikey": t.cfg.Secrets.TheTVDBAPIKey})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api4.thetvdb.com/v4/login", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := t.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("thetvdb login: %d", resp.StatusCode)
}
var out struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
t.token = out.Data.Token
t.tokenExp = time.Now().Add(24 * time.Hour)
return t.token, nil
}
// SearchSeries returns the top match for a TV / anime query, or nil.
func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Match, error) {
if !t.Enabled() || query == "" {
return nil, nil
}
tok, err := t.Login(ctx)
if err != nil {
return nil, err
}
u := fmt.Sprintf("https://api4.thetvdb.com/v4/search?query=%s&type=series",
urlEscape(query))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("thetvdb search: %d", resp.StatusCode)
}
type entry struct {
ID string `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
Image string `json:"image_url"`
Year string `json:"year"`
}
type page struct {
Data []entry `json:"data"`
}
var p page
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, err
}
if len(p.Data) == 0 {
return nil, nil
}
r := p.Data[0]
m := &Match{
Title: r.Name,
Overview: r.Overview,
PosterURL: r.Image,
}
if len(r.Year) >= 4 {
fmt.Sscanf(r.Year[:4], "%d", &m.Year)
}
return m, nil
}
// urlEscape is a tiny replacement for net/url.QueryEscape kept inline so
// the file does not pull a second import for one call.
func urlEscape(s string) string {
out := make([]byte, 0, len(s)*3)
for _, r := range []byte(s) {
switch {
case r >= '0' && r <= '9',
r >= 'A' && r <= 'Z',
r >= 'a' && r <= 'z',
r == '-', r == '_', r == '.', r == '~':
out = append(out, r)
default:
out = append(out, '%')
const hex = "0123456789ABCDEF"
out = append(out, hex[r>>4], hex[r&15])
}
}
return string(out)
}
+161 -37
View File
@@ -4,16 +4,22 @@
// 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.
//
// The transcode profile is intentionally conservative: a single 720p/1.5M
// bitrate, AAC stereo audio, MPEG-TS segments. Hardware acceleration
// (NVENC / QSV / VAAPI) is left as a future config-driven extension point.
package service
import (
@@ -34,8 +40,8 @@ import (
// TranscoderService orchestrates background ffmpeg transcodes.
type TranscoderService struct {
cfg *config.Config
log *zap.Logger
cfg *config.Config
log *zap.Logger
repo *repository.Container
hub *Hub
@@ -50,6 +56,7 @@ type hlsJob struct {
cancel context.CancelFunc
startedAt time.Time
playlistOK bool
encoder string
}
// NewTranscoderService is the constructor.
@@ -106,6 +113,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
outputDir: outDir,
cancel: cancel,
startedAt: time.Now(),
encoder: t.cfg.Transcoder.Encoder,
}
t.jobs[mediaID] = job
t.mu.Unlock()
@@ -158,6 +166,30 @@ func (t *TranscoderService) StopAll() {
}
}
// 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 == "" {
@@ -167,45 +199,19 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
playlist := filepath.Join(job.outputDir, "index.m3u8")
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
args := []string{
"-y",
"-fflags", "+genpts",
"-i", source,
// Single 720p H.264 video rendition.
"-map", "0:v:0?",
"-map", "0:a:0?",
"-vf", "scale=-2:min(720\\,ih)",
"-c:v", "libx264",
"-preset", "veryfast",
"-profile:v", "main",
"-level", "4.0",
"-pix_fmt", "yuv420p",
"-b:v", "1500k",
"-maxrate", "1800k",
"-bufsize", "3000k",
"-c:a", "aac",
"-ar", "48000",
"-b:a", "128k",
"-ac", "2",
"-force_key_frames", "expr:gte(t,n_forced*4)",
"-f", "hls",
"-hls_time", "4",
"-hls_list_size", "0",
"-hls_segment_type", "mpegts",
"-hls_flags", "independent_segments",
"-hls_segment_filename", segments,
playlist,
}
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",
})
@@ -227,8 +233,126 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
})
}
// 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, output=%s",
t.cfg.App.FFmpegPath, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
t.cfg.App.FFmpegPath, t.cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
}
+46
View File
@@ -0,0 +1,46 @@
package service
import (
"strings"
"testing"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
func TestBuildFFmpegArgs(t *testing.T) {
base := &config.Config{}
base.Transcoder.MaxHeight = 720
base.Transcoder.SegmentSeconds = 4
base.App.VAAPIDevice = "/dev/dri/renderD128"
cases := []struct {
name string
encoder string
expectVCodec string
expectInArgs []string
expectNotPresetIfBlank bool
}{
{"software", "", "libx264", []string{"-preset", "veryfast", "-c:v", "libx264"}, false},
{"nvenc", "nvenc", "h264_nvenc", []string{"-hwaccel", "cuda", "-c:v", "h264_nvenc", "-preset", "p4"}, false},
{"qsv", "qsv", "h264_qsv", []string{"-hwaccel", "qsv", "-c:v", "h264_qsv"}, false},
{"vaapi", "vaapi", "h264_vaapi", []string{"-hwaccel", "vaapi", "-vaapi_device", "/dev/dri/renderD128", "-c:v", "h264_vaapi"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := *base
cfg.Transcoder.Encoder = tc.encoder
args := buildFFmpegArgs(&cfg, "/x.mkv", "/o/x.m3u8", "/o/seg_%05d.ts")
joined := strings.Join(args, " ")
for _, frag := range tc.expectInArgs {
if !strings.Contains(joined, frag) {
t.Errorf("expected %q in args, got: %s", frag, joined)
}
}
// vaapi has no -preset flag.
if tc.expectNotPresetIfBlank && strings.Contains(joined, "-preset") {
t.Errorf("vaapi should not include -preset, got: %s", joined)
}
})
}
}
+24 -1
View File
@@ -40,8 +40,14 @@ const ProfilePage = lazy(() =>
import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })),
)
const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage })))
const DiscoverPage = lazy(() =>
import('./pages/DiscoverPage').then((m) => ({ default: m.DiscoverPage })),
)
const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default: m.TasksPage })))
const RecycleBinPage = lazy(() =>
import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })),
)
// Fallback shown while a chunk is loading.
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
export default function App() {
@@ -59,6 +65,7 @@ export default function App() {
>
<Route index element={<HomePage />} />
<Route path="library/:id" element={<LibraryPage />} />
<Route path="discover" element={<DiscoverPage />} />
<Route path="search" element={<SearchPage />} />
<Route path="favourites" element={<FavouritesPage />} />
<Route path="playlists" element={<PlaylistsPage />} />
@@ -68,6 +75,22 @@ export default function App() {
<Route path="downloads" element={<DownloadsPage />} />
<Route path="subscriptions" element={<SubscriptionsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route
path="tasks"
element={
<RequireAdmin>
<TasksPage />
</RequireAdmin>
}
/>
<Route
path="recycle"
element={
<RequireAdmin>
<RecycleBinPage />
</RequireAdmin>
}
/>
<Route
path="stats"
element={
+25
View File
@@ -0,0 +1,25 @@
import { api } from './client'
import type { Media } from '../types'
export interface SearchIntent {
query: string
year?: number
genre?: string
type?: string
sort?: string
language?: string
}
export const aiAPI = {
status: () =>
api
.get<{ enabled: boolean; provider: string; model: string }>('/ai/status')
.then((r) => r.data),
smartSearch: (query: string) =>
api
.post<{ intent: SearchIntent; items: Media[] }>('/ai/search', { query })
.then((r) => r.data),
recommend: () => api.get<{ titles: string[] }>('/ai/recommend').then((r) => r.data.titles),
}
+21
View File
@@ -0,0 +1,21 @@
import { api } from './client'
import type { Media } from '../types'
// TMDb-derived "Match" rows used by trending/popular rails. We re-use the
// Media interface — only TMDb id / poster / overview are populated.
export interface DiscoverItem extends Partial<Media> {
tmdb_id: number
title: string
overview: string
poster_url: string
backdrop_url: string
year: number
rating: number
}
export const discoverAPI = {
trending: () =>
api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items),
popular: () =>
api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items),
}
+18
View File
@@ -0,0 +1,18 @@
import { api } from './client'
import type { Media } from '../types'
export const recycleAPI = {
list: () => api.get<{ items: Media[] }>('/recycle').then((r) => r.data.items),
softDelete: (id: string) => api.delete(`/media/${id}`).then((r) => r.data),
restore: (id: string) => api.post(`/media/${id}/restore`).then((r) => r.data),
purge: (id: string) => api.delete(`/media/${id}/purge`).then((r) => r.data),
exportNFO: (id: string) =>
api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data),
exportLibraryNFO: (id: string) =>
api.post<{ written: number }>(`/libraries/${id}/nfo`).then((r) => r.data),
}
+18
View File
@@ -0,0 +1,18 @@
import { api } from './client'
import type { QBitTorrent } from '../types'
export interface ActiveTranscode {
media_id: string
encoder: string
started_at: string
playlist_ok: boolean
}
export interface TasksSnapshot {
transcodes: ActiveTranscode[]
torrents: QBitTorrent[] | null
}
export const tasksAPI = {
snapshot: () => api.get<TasksSnapshot>('/tasks').then((r) => r.data),
}
+6
View File
@@ -3,14 +3,17 @@ import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
Activity,
CloudDownload,
Compass,
Film,
Heart,
Home,
ListChecks,
ListMusic,
LogOut,
Rss,
Search,
Settings,
Trash2,
Library as LibraryIcon,
User as UserIcon,
} from 'lucide-react'
@@ -48,6 +51,7 @@ export function Layout() {
<nav className="flex-1 space-y-1 overflow-y-auto pr-1">
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
@@ -83,7 +87,9 @@ export function Layout() {
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
管理
</div>
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
</>
)}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { Sparkles } from 'lucide-react'
import { discoverAPI, type DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client'
// DiscoverPage shows TMDb trending + popular rails. Rows are empty when
// no TMDb API key is configured (the backend simply returns []).
export function DiscoverPage() {
const [trending, setTrending] = useState<DiscoverItem[]>([])
const [popular, setPopular] = useState<DiscoverItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
discoverAPI.trending().catch(() => [] as DiscoverItem[]),
discoverAPI.popular().catch(() => [] as DiscoverItem[]),
])
.then(([t, p]) => {
setTrending(t)
setPopular(p)
})
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-10">
<header className="flex items-center gap-3">
<Sparkles className="h-6 w-6 text-primary-400" />
<div>
<h1 className="font-display text-3xl font-bold text-white">发现</h1>
<p className="text-sm text-slate-400">
来自 TMDb 的当日热门与流行榜单(需在 secrets.tmdb_api_key 配置 API Key)。
</p>
</div>
</header>
{loading && <p className="text-slate-500">加载中…</p>}
{trending.length > 0 && <Row title="今日趋势" items={trending} />}
{popular.length > 0 && <Row title="热门电影" items={popular} />}
{!loading && trending.length === 0 && popular.length === 0 && (
<div className="glass-panel">
<p className="text-slate-300">
还没有可用的发现数据。前往 <span className="text-primary-400">管理后台 → 设置</span>
,在 secrets.tmdb_api_key 中填写一个有效的 TMDb API Key。
</p>
</div>
)}
</div>
)
}
function Row({ title, items }: { title: string; items: DiscoverItem[] }) {
return (
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">{title}</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<DiscoverCard key={m.tmdb_id} item={m} />
))}
</div>
</section>
)
}
function DiscoverCard({ item }: { item: DiscoverItem }) {
return (
<div className="overflow-hidden rounded-xl border border-white/5 bg-surface-800/60">
<div className="aspect-[2/3] w-full bg-surface-900">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
无海报
</div>
)}
</div>
<div className="px-3 py-2">
<p className="truncate text-sm font-medium text-white">{item.title}</p>
<div className="flex items-center justify-between text-xs text-slate-400">
{item.year > 0 && <span>{item.year}</span>}
{item.rating > 0 && (
<span className="rounded border border-yellow-400/40 px-1.5 py-0.5 text-yellow-400">
★ {item.rating.toFixed(1)}
</span>
)}
</div>
</div>
</div>
)
}
+34 -2
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Heart, Play, RefreshCw, Sparkles } from 'lucide-react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
import { playbackAPI } from '../api/playback'
import { recycleAPI } from '../api/recycle'
import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
@@ -39,6 +40,7 @@ function fmtSize(bytes: number): string {
// - Re-probe (admin only, refreshes ffprobe metadata)
export function MediaDetailPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const role = useAuthStore((s) => s.user?.role)
const [media, setMedia] = useState<Media | null>(null)
const [favourite, setFavourite] = useState(false)
@@ -81,6 +83,27 @@ export function MediaDetailPage() {
await refresh()
}
const exportNFO = async () => {
if (!media) return
try {
const r = await recycleAPI.exportNFO(media.id)
toast.success(`NFO 已写入 ${r.path}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'导出失败'
toast.error(msg)
}
}
const softDelete = async () => {
if (!media) return
if (!confirm(`将「${media.title}」移至回收站? (磁盘文件保留)`)) return
await recycleAPI.softDelete(media.id)
toast.success('已移至回收站')
navigate(-1)
}
if (loading) return <p className="text-slate-500">加载中…</p>
if (!media) return <p className="text-slate-500">媒体不存在</p>
@@ -149,6 +172,15 @@ export function MediaDetailPage() {
<button onClick={reprobe} className="neon-button">
<RefreshCw size={16} /> 重新探测
</button>
<button onClick={exportNFO} className="neon-button">
<FileText size={16} /> 导出 NFO
</button>
<button
onClick={softDelete}
className="neon-button !border-red-400/40 !bg-red-400/10 !text-red-400"
>
<Trash2 size={16} /> 移至回收站
</button>
</>
)}
</div>
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { RotateCcw, Trash2 } from 'lucide-react'
import { recycleAPI } from '../api/recycle'
import type { Media } from '../types'
export function RecycleBinPage() {
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
const refresh = () =>
recycleAPI
.list()
.then(setItems)
.finally(() => setLoading(false))
useEffect(() => {
refresh().catch(() => undefined)
}, [])
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">回收站</h1>
<p className="text-sm text-slate-400">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-slate-400">回收站为空。</p>}
{items.length > 0 && (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">标题</th>
<th>路径</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((m) => (
<tr key={m.id} className="border-t border-white/5">
<td className="py-2 text-white">{m.title}</td>
<td className="max-w-md truncate text-slate-400" title={m.path}>
{m.path}
</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
onClick={async () => {
await recycleAPI.restore(m.id)
toast.success('已恢复')
await refresh()
}}
>
<RotateCcw size={12} />
</button>
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`彻底删除「${m.title}」? (磁盘文件保留)`)) return
await recycleAPI.purge(m.id)
toast.success('已彻底删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+84 -12
View File
@@ -1,36 +1,108 @@
import { ChangeEvent, useEffect, useState } from 'react'
import { ChangeEvent, FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Sparkles } from 'lucide-react'
import { aiAPI, type SearchIntent } from '../api/ai'
import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
// Search-as-you-type with a 300ms debounce.
// SearchPage runs a fast LIKE search by default, with an optional AI
// "smart search" toggle that calls /api/ai/search to produce a structured
// intent before executing the library lookup.
export function SearchPage() {
const [q, setQ] = useState('')
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(false)
const [aiOn, setAiOn] = useState(false)
const [aiAvailable, setAiAvailable] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
useEffect(() => {
aiAPI
.status()
.then((s) => setAiAvailable(s.enabled))
.catch(() => setAiAvailable(false))
}, [])
// Fast LIKE search-as-you-type when AI mode is OFF.
useEffect(() => {
if (aiOn) return
const t = setTimeout(() => {
setLoading(true)
mediaAPI
.search(q, 60)
.then((d) => setItems(d.items))
.then((d) => {
setItems(d.items)
setIntent(null)
})
.finally(() => setLoading(false))
}, 300)
return () => clearTimeout(t)
}, [q])
}, [q, aiOn])
const onAISubmit = async (e: FormEvent) => {
e.preventDefault()
if (!q.trim()) return
setLoading(true)
try {
const data = await aiAPI.smartSearch(q)
setItems(data.items)
setIntent(data.intent)
} catch {
toast.error('AI 搜索失败')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
<input
autoFocus
className="input-base"
placeholder="按标题搜索…"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
/>
<header className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
<button
disabled={!aiAvailable}
className={
'neon-button !px-3 !py-1 !text-xs ' +
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
}
onClick={() => setAiOn((on) => !on)}
title={aiAvailable ? '启用 AI 智能搜索' : '请先在 ai.* 中配置 API Key'}
>
<Sparkles size={12} /> {aiOn ? 'AI 已开启' : 'AI 智能搜索'}
</button>
</header>
{aiOn ? (
<form onSubmit={onAISubmit} className="flex gap-2">
<input
autoFocus
className="input-base"
placeholder='例如:“2010 年后的科幻电影” / “最近的动漫”'
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
/>
<button type="submit" className="neon-button">
搜索
</button>
</form>
) : (
<input
autoFocus
className="input-base"
placeholder="按标题搜索…"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
/>
)}
{intent && (
<div className="glass-panel !p-3 text-xs text-slate-300">
AI 解析:
<span className="ml-2 font-mono text-primary-400">{JSON.stringify(intent)}</span>
</div>
)}
{loading && <p className="text-slate-500">搜索中…</p>}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { Activity } from 'lucide-react'
import { tasksAPI, type TasksSnapshot } from '../api/tasks'
function fmtBytes(n: number): string {
if (!n || n <= 0) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
}
// TasksPage shows everything the backend is doing right now: ffmpeg
// transcodes + qBittorrent downloads. Refreshes every 3 s.
export function TasksPage() {
const [snap, setSnap] = useState<TasksSnapshot | null>(null)
useEffect(() => {
let cancelled = false
const tick = () =>
tasksAPI.snapshot().then((s) => {
if (!cancelled) setSnap(s)
})
void tick()
const id = window.setInterval(tick, 3_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
if (!snap) return <p className="text-slate-500">加载中…</p>
const torrents = snap.torrents ?? []
return (
<div className="space-y-8">
<header className="flex items-center gap-3">
<Activity className="h-6 w-6 text-primary-400" />
<h1 className="font-display text-3xl font-bold text-white">实时任务</h1>
</header>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">转码任务</h2>
{snap.transcodes.length === 0 && <p className="text-slate-500">暂无运行中转码。</p>}
{snap.transcodes.length > 0 && (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">媒体 ID</th>
<th>编码器</th>
<th>开始时间</th>
<th>就绪</th>
</tr>
</thead>
<tbody>
{snap.transcodes.map((t) => (
<tr key={t.media_id} className="border-t border-white/5">
<td className="py-2 font-mono text-xs text-white">{t.media_id}</td>
<td className="text-slate-300">{t.encoder || 'libx264'}</td>
<td className="text-slate-300">{new Date(t.started_at).toLocaleTimeString()}</td>
<td>
{t.playlist_ok ? (
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
ready
</span>
) : (
<span className="rounded border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-400">
starting
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">下载任务</h2>
{torrents.length === 0 && <p className="text-slate-500">暂无运行中下载。</p>}
{torrents.length > 0 && (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">名称</th>
<th>状态</th>
<th>进度</th>
<th>体积</th>
</tr>
</thead>
<tbody>
{torrents.map((t) => (
<tr key={t.hash} className="border-t border-white/5 align-top">
<td className="max-w-md truncate py-2 text-white" title={t.name}>
{t.name}
</td>
<td className="text-slate-300">{t.state}</td>
<td className="text-slate-300">
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
<div
className="h-full bg-primary-400"
style={{ width: `${Math.round(t.progress * 100)}%` }}
/>
</div>
{(t.progress * 100).toFixed(1)}%
</div>
</td>
<td className="text-slate-300">{fmtBytes(t.size)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
)
}