mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
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:
@@ -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(), "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user