feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit

Backend
  - service/bangumi.go: Bangumi (bgm.tv) scraper for anime libraries.
  - service/episode_parser.go: SxxExx / NxE / EPxx / 第NN集 parser with
    unit tests; consumed by the scanner for tv/anime libraries.
  - service/scraper.go: provider chain orchestrator picks Bangumi for
    anime libraries (TMDb fallback), TMDb for everything else; emits
    'no_match' rows so we don't retry forever; AnyEnabled() for the
    scanner kick.
  - service/scanner.go: writes season/episode numbers for tv/anime libs
    and reports a 'probed' counter alongside 'added'.
  - service/transcoder.go (existing): unchanged, stays per-media.
  - service/subtitle.go: discovers external subtitles next to the source
    file (.srt/.vtt/.ass/.ssa) and converts SRT/ASS to WebVTT on the fly.
  - service/qbittorrent.go: thread-safe qBittorrent v2 Web UI client
    (login / add / list / delete) with cookie-jar reuse.
  - service/downloads.go: persists download tasks, reads runtime
    qbittorrent.* settings, polls /torrents/info every 5 s and pushes
    the result to WS subscribers ('download' topic).
  - service/subscription.go: 10-minute RSS poller with regex filter,
    GUID dedup persisted in the settings table, and 'subscription' WS
    events on enqueue.
  - service/watcher.go: fsnotify watcher with 5 s coalescing debouncer
    that triggers per-library rescans on create/rename/remove.
  - service/stats.go: dashboard snapshot — totals, recently added,
    gopsutil-driven CPU/mem/disk readings.
  - service/profile.go: non-credential profile patch + admin role mutator.
  - service/audit.go: best-effort writer for the access_logs table.
  - service/service.go: container wires every new service; Boot() spins
    up watcher / downloads poller / subscription scheduler; Close()
    tears them down on graceful shutdown.

Handlers
  - new files: downloads.go, subscriptions.go, subtitles.go, series.go,
    stats.go, profile.go, util.go.
  - handler.go: registers PATCH /me, /libraries/:id/seasons,
    /media/:id/subtitles, /subtitles/:id, /downloads*, /subscriptions*,
    /stats, /admin/users/:id/role.
  - auth.go / media.go: write audit rows for login + library CRUD and
    refresh the watcher when libraries change.

Frontend
  - api: new helpers for downloads, subscriptions, profile, series, stats,
    subtitles; library helper gained scrape().
  - hooks/useWebSocket.ts: shared connection with 3 s reconnect.
  - components/GlobalEvents.tsx: surfaces scan / scrape / subscription
    completion as toasts (mounted at app root).
  - pages: Library now switches to a season-grouped layout for tv/anime
    libraries; Player attaches WebVTT <track> elements; new pages for
    Downloads (live torrent table), Subscriptions, Profile, Stats.
  - components/Layout.tsx + App.tsx: sidebar groups (媒体库 / 自动化 /
    账号 / 管理) and routes for the new pages; /stats and /admin remain
    admin-only.
  - types/index.ts: new types — Subscription, DownloadTask, QBitTorrent,
    Hardware, StatsSnapshot.

Verified: go build, go vet, go test (incl. ParseEpisode + srtToVTT +
stripASSTags) all pass; tsc -b && vite build emits 17 route chunks plus
the deferred hls chunk; main bundle 247 KB / 83 KB gzipped.
This commit is contained in:
Kiro
2026-05-14 16:07:49 +00:00
parent 0f30c34463
commit 7bd6bcfb1b
50 changed files with 3134 additions and 146 deletions
+1
View File
@@ -41,6 +41,7 @@ func loginHandler(svc *service.Container) gin.HandlerFunc {
"token": token,
"user": u,
})
svc.Audit.Record(c.Request.Context(), u.ID, "auth.login", u.Username, c.ClientIP(), "")
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package handler — download manager endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type addDownloadReq struct {
URL string `json:"url" binding:"required"`
SavePath string `json:"save_path"`
}
func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req addDownloadReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
t, err := svc.Downloads.AddDownload(c.Request.Context(), uid.(string), req.URL, req.SavePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", req.URL, c.ClientIP(), "")
c.JSON(http.StatusOK, t)
}
}
func listDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, live, err := svc.Downloads.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"tasks": rows,
"torrents": live,
})
}
}
func deleteDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
hash := c.Param("hash")
withFiles := c.Query("delete_files") == "true"
if err := svc.Downloads.Delete(c.Request.Context(), hash, withFiles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func reloadDownloadConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+23
View File
@@ -32,8 +32,10 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
{
authed.GET("/me", meHandler(svc))
authed.PATCH("/me", updateProfileHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
// Libraries.
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
@@ -41,10 +43,15 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
// Media.
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
// Streaming.
authed.GET("/stream/:id", streamHandler(svc))
@@ -69,6 +76,21 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc))
authed.DELETE("/playlists/:id", deletePlaylistHandler(svc))
// Downloads.
authed.GET("/downloads", listDownloadsHandler(svc))
authed.POST("/downloads", addDownloadHandler(svc))
authed.DELETE("/downloads/:hash", middleware.AdminRequired(), deleteDownloadHandler(svc))
authed.POST("/downloads/reload", middleware.AdminRequired(), reloadDownloadConfigHandler(svc))
// Subscriptions.
authed.GET("/subscriptions", listSubscriptionsHandler(svc))
authed.POST("/subscriptions", createSubscriptionHandler(svc))
authed.DELETE("/subscriptions/:id", deleteSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/run", runSubscriptionHandler(svc))
// Stats / dashboard.
authed.GET("/stats", statsHandler(svc))
authed.GET("/ws", wsHandler(svc))
}
@@ -77,6 +99,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
admin.GET("/users", listUsersHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
+7
View File
@@ -40,6 +40,10 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", l.ID, c.ClientIP(), l.Path)
// Refresh fsnotify watcher to pick up the new library root.
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
c.JSON(http.StatusOK, l)
}
}
@@ -51,6 +55,9 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
svc.Audit.Record(c.Request.Context(), toString(uid), "library.delete", id, c.ClientIP(), "")
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
c.Status(http.StatusNoContent)
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package handler — user profile endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func updateProfileHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var patch service.ProfileUpdate
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Profile.UpdateProfile(c.Request.Context(), uid.(string), patch)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
}
type adminUpdateRoleReq struct {
Role string `json:"role" binding:"required"`
}
func adminUpdateRoleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateRoleReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := svc.Profile.AdminUpdateRole(c.Request.Context(), c.Param("id"), req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package handler — TV series endpoints.
//
// These return episode lists grouped by season number for a library that
// holds TV episodes. Series rows are distinct from Movies — the front
// end uses /api/libraries/:id/seasons to render a season selector.
package handler
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// seasonGroup is the JSON returned to the React UI per season.
type seasonGroup struct {
Season int `json:"season"`
Episodes []model.Media `json:"episodes"`
}
func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libID := c.Param("id")
var rows []model.Media
err := svc.Repo.DB.Where(&model.Media{LibraryID: libID}).
Order("season_num asc, episode_num asc").
Find(&rows).Error
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
buckets := make(map[int][]model.Media)
for _, r := range rows {
buckets[r.SeasonNum] = append(buckets[r.SeasonNum], r)
}
out := make([]seasonGroup, 0, len(buckets))
for s, items := range buckets {
out = append(out, seasonGroup{Season: s, Episodes: items})
}
sort.Slice(out, func(i, j int) bool { return out[i].Season < out[j].Season })
c.JSON(http.StatusOK, gin.H{"seasons": out})
}
}
+21
View File
@@ -0,0 +1,21 @@
// Package handler — stats / dashboard endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func statsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, snap)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Package handler — RSS subscription endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type subscriptionReq struct {
Name string `json:"name" binding:"required"`
FeedURL string `json:"feed_url" binding:"required"`
Filter string `json:"filter"`
Enabled *bool `json:"enabled"`
}
func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req subscriptionReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
s := &model.Subscription{
UserID: uid.(string),
Name: req.Name,
FeedURL: req.FeedURL,
Filter: req.Filter,
Enabled: enabled,
}
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
}
func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Subscription.List(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 deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Subscription.RunNow(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{"queued": n})
}
}
+37
View File
@@ -0,0 +1,37 @@
// Package handler — subtitle endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tracks": tracks})
}
}
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
return
}
c.Header("Content-Type", "text/vtt; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
if err := svc.Subtitle.Serve(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
}
}
+14
View File
@@ -0,0 +1,14 @@
// Package handler — small utilities shared across handlers.
package handler
// toString converts a gin context value to a string, returning an empty
// string when the value is missing or of the wrong type.
func toString(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return ""
}