mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
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:
@@ -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 \
|
||||
|
||||
@@ -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 | ⏳ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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={
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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="管理后台" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user