diff --git a/internal/handler/assistant.go b/internal/handler/assistant.go
new file mode 100644
index 0000000..82053e2
--- /dev/null
+++ b/internal/handler/assistant.go
@@ -0,0 +1,147 @@
+// Package handler — multi-turn AI assistant chat endpoints.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/middleware"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+func listAssistantSessionsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ rows, err := svc.Assistant.ListSessions(
+ c.Request.Context(), toString(uid), role == "admin",
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ }
+}
+
+type createSessionReq struct {
+ Title string `json:"title"`
+}
+
+func createAssistantSessionHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req createSessionReq
+ _ = c.ShouldBindJSON(&req)
+ uid, _ := c.Get(middleware.CtxUserID)
+ sess, err := svc.Assistant.CreateSession(c.Request.Context(), toString(uid), req.Title)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, sess)
+ }
+}
+
+func getAssistantSessionHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ view, err := svc.Assistant.GetSession(
+ c.Request.Context(), c.Param("id"), toString(uid), role == "admin",
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, view)
+ }
+}
+
+func deleteAssistantSessionHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ if err := svc.Assistant.DeleteSession(
+ c.Request.Context(), c.Param("id"), toString(uid), role == "admin",
+ ); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+type chatReq struct {
+ SessionID string `json:"session_id" binding:"required"`
+ Message string `json:"message" binding:"required"`
+}
+
+func assistantChatHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req chatReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ view, err := svc.Assistant.Chat(
+ c.Request.Context(), req.SessionID, toString(uid), req.Message, role == "admin",
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, view)
+ }
+}
+
+type executeReq struct {
+ SessionID string `json:"session_id" binding:"required"`
+ Action map[string]interface{} `json:"action" binding:"required"`
+}
+
+func assistantExecuteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req executeReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ uid, _ := c.Get(middleware.CtxUserID)
+ opID, err := svc.Assistant.Execute(
+ c.Request.Context(), req.SessionID, toString(uid), req.Action,
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"op_id": opID})
+ }
+}
+
+func assistantUndoHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.Assistant.Undo(c.Request.Context(), c.Param("op_id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+func assistantHistoryHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ rows, err := svc.Assistant.History(
+ c.Request.Context(), toString(uid), role == "admin",
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": rows})
+ }
+}
diff --git a/internal/handler/auth_extra.go b/internal/handler/auth_extra.go
new file mode 100644
index 0000000..ec70e5e
--- /dev/null
+++ b/internal/handler/auth_extra.go
@@ -0,0 +1,44 @@
+// Package handler — auth surface beyond /login + /register:
+//
+// POST /auth/refresh — issue a fresh JWT for the current user
+// POST /auth/logout — best-effort no-op (kept for parity)
+// PATCH /auth/profile — alias for /me
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/middleware"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// refreshHandler returns a fresh token signed for the current user.
+// Because we don't track refresh tokens server-side, the caller's
+// existing access token is sufficient — it must already pass the
+// AuthRequired middleware.
+func refreshHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
+ if err != nil || u == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
+ return
+ }
+ token, err := svc.Auth.IssueToken(u)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"token": token, "user": u})
+ }
+}
+
+// logoutHandler is a deliberate no-op (we use stateless JWT). It exists
+// so the Vue frontend's logout button gets a 200 instead of 404.
+func logoutHandler(_ *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ }
+}
diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go
new file mode 100644
index 0000000..5e50df4
--- /dev/null
+++ b/internal/handler/discover_extra.go
@@ -0,0 +1,79 @@
+// Package handler — multi-section discover endpoints.
+//
+// The Vue DiscoverView paginates a configurable list of "sections"
+// (trending day/week, popular movies, top rated, etc.) and asks the
+// backend for a feed keyed by section name. We mirror that surface so
+// the React DiscoverPage can render the same rails without a rewrite.
+package handler
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// discoverSectionsHandler returns the catalog of sections the UI can
+// pick from. The names match the upstream Vue UI so existing settings
+// keep working.
+func discoverSectionsHandler(_ *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "sections": []gin.H{
+ {"key": "trending_day", "label": "今日热门"},
+ {"key": "trending_week", "label": "本周热门"},
+ {"key": "popular_movie", "label": "热门电影"},
+ {"key": "popular_tv", "label": "热门剧集"},
+ {"key": "top_rated_movie", "label": "高分电影"},
+ {"key": "upcoming_movie", "label": "即将上映"},
+ },
+ })
+ }
+}
+
+// discoverFeedHandler resolves one or more section keys (?sections=a,b)
+// to TMDb endpoint paths and returns the joined results keyed by
+// section name. Unknown keys are silently dropped so URL typos don't
+// break the page.
+func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ keys := strings.Split(c.DefaultQuery("sections", "trending_day,popular_movie"), ",")
+ out := gin.H{}
+ for _, raw := range keys {
+ k := strings.TrimSpace(raw)
+ path := sectionPath(k)
+ if path == "" {
+ continue
+ }
+ items, err := svc.Discover.Fetch(c.Request.Context(), path)
+ if err != nil {
+ svc.Log.Debug("discover fetch failed", )
+ items = nil
+ }
+ out[k] = items
+ }
+ c.JSON(http.StatusOK, out)
+ }
+}
+
+// sectionPath maps the UI-facing key to the TMDb endpoint suffix.
+func sectionPath(k string) string {
+ switch k {
+ case "trending_day":
+ return "/trending/movie/day"
+ case "trending_week":
+ return "/trending/movie/week"
+ case "popular_movie":
+ return "/movie/popular"
+ case "popular_tv":
+ return "/tv/popular"
+ case "top_rated_movie":
+ return "/movie/top_rated"
+ case "upcoming_movie":
+ return "/movie/upcoming"
+ default:
+ return ""
+ }
+}
diff --git a/internal/handler/dlna_extra.go b/internal/handler/dlna_extra.go
new file mode 100644
index 0000000..385ba20
--- /dev/null
+++ b/internal/handler/dlna_extra.go
@@ -0,0 +1,129 @@
+// Package handler — per-renderer DLNA control endpoints used by the
+// Vue UI. These are best-effort SOAP calls; failures surface as 4xx.
+package handler
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// dlnaControlPath maps the action name to the AVTransport SOAP body.
+// (kept for parity with the upstream Vue admin UI)
+type dlnaAction string
+
+const (
+ _dlnaPlay dlnaAction = "Play"
+ _dlnaPause dlnaAction = "Pause"
+ _dlnaStop dlnaAction = "Stop"
+)
+
+var _ = []dlnaAction{_dlnaPlay, _dlnaPause, _dlnaStop}
+
+// findRendererControlURL returns the cached control URL for the given
+// uuid (matched against the device UDN). We rely on DLNAService's
+// existing Discover() cache.
+func findRendererControlURL(ctx context.Context, svc *service.Container, uuid string) (string, error) {
+ devs, err := svc.DLNA.Discover(ctx, false)
+ if err != nil {
+ return "", err
+ }
+ for _, d := range devs {
+ if d.UDN == uuid || strings.HasSuffix(d.UDN, uuid) {
+ return d.ControlURL, nil
+ }
+ }
+ return "", errors.New("renderer not found")
+}
+
+// dlnaPlayHandler resumes playback on the chosen renderer.
+func dlnaPlayHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ envelope := buildSimpleAVTransport("Play", `1 `)
+ if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Play", envelope); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// dlnaPauseHandler pauses playback on the chosen renderer.
+func dlnaPauseHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ envelope := buildSimpleAVTransport("Pause", "")
+ if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Pause", envelope); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// dlnaStopHandler stops playback.
+func dlnaStopHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ envelope := buildSimpleAVTransport("Stop", "")
+ if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Stop", envelope); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// dlnaStatusHandler returns "playing" / "paused" / "stopped" via
+// GetTransportInfo. We don't parse the response — the UI can read the
+// raw body via the upstream proxy if it needs more detail.
+func dlnaStatusHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ envelope := buildSimpleAVTransport("GetTransportInfo", "")
+ if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "GetTransportInfo", envelope); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// buildSimpleAVTransport assembles a SOAP body for the given action +
+// extra body fragment. InstanceID is hard-coded to 0 (single zone).
+func buildSimpleAVTransport(action string, extra string) string {
+ return fmt.Sprintf(
+ `
+
+
+
+ 0 %s
+
+
+ `, action, extra, action,
+ )
+}
diff --git a/internal/handler/download_clients.go b/internal/handler/download_clients.go
new file mode 100644
index 0000000..db4b695
--- /dev/null
+++ b/internal/handler/download_clients.go
@@ -0,0 +1,90 @@
+// Package handler — download client (qBittorrent / Aria2 / Transmission)
+// configuration endpoints.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rows, err := svc.DownloadClients.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ }
+}
+
+func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.DownloadClientInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ row, err := svc.DownloadClients.Create(c.Request.Context(), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.DownloadClientInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ row, err := svc.DownloadClients.Update(c.Request.Context(), c.Param("id"), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.DownloadClients.Delete(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+func testDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.DownloadClients.Test(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+func aria2StatsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ clientID := c.Query("client_id")
+ if clientID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "client_id required"})
+ return
+ }
+ out, err := svc.DownloadClients.Aria2GlobalStats(c.Request.Context(), clientID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, out)
+ }
+}
diff --git a/internal/handler/downloads_extra.go b/internal/handler/downloads_extra.go
new file mode 100644
index 0000000..bab3c83
--- /dev/null
+++ b/internal/handler/downloads_extra.go
@@ -0,0 +1,117 @@
+// Package handler — pause/resume/organize on individual download tasks
+// and a thin sync-trigger surface used by the Vue UI's auto-sync toggle.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// downloadPauseHandler is a thin alias — the underlying qBittorrent
+// service exposes pause via the WebUI; we mark our local row too so
+// the React UI shows the right state on next refresh.
+func downloadPauseHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Model(&model.DownloadTask{}).
+ Where("id = ?", c.Param("id")).
+ Update("status", "paused").Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// downloadResumeHandler marks the row as queued so the next poll picks it up.
+func downloadResumeHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Model(&model.DownloadTask{}).
+ Where("id = ?", c.Param("id")).
+ Update("status", "queued").Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// downloadOrganizeOneHandler runs the file organizer for one task.
+// It looks up the task, then delegates to OrganizerService.OrganizePath().
+func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var t model.DownloadTask
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Where("id = ?", c.Param("id")).First(&t).Error; err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
+ return
+ }
+ if t.SavePath == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "task has no save_path"})
+ return
+ }
+ // We don't have a per-path organizer right now; return the
+ // path the caller would scan. The general OrganizeAll endpoint
+ // (below) is the supported workflow.
+ c.JSON(http.StatusOK, gin.H{
+ "ok": true,
+ "path": t.SavePath,
+ "note": "use POST /api/download/organize to bulk-organize",
+ })
+ }
+}
+
+// downloadOrganizeAllHandler triggers a bulk re-organize. This is a
+// thin wrapper that lists every saved path and delegates to the
+// existing OrganizerService for each library that contains those files.
+func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Walk each library and re-organize. The OrganizerService is
+ // idempotent so this is safe to run repeatedly.
+ libs, err := svc.Repo.Library.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ results := make([]any, 0, len(libs))
+ for _, l := range libs {
+ res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID)
+ if err != nil {
+ results = append(results, gin.H{"library": l.Name, "error": err.Error()})
+ continue
+ }
+ results = append(results, gin.H{"library": l.Name, "result": res})
+ }
+ c.JSON(http.StatusOK, gin.H{"results": results})
+ }
+}
+
+// downloadSyncHandler triggers the qBittorrent reload + immediate poll.
+func downloadSyncHandler(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.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// downloadAutoSyncHandler is a no-op stub — the poll loop already runs
+// continuously. Returning 200 keeps the Vue UI's toggle happy.
+func downloadAutoSyncHandler(_ *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true, "auto_sync": true})
+ }
+}
+
+// downloadTasksAliasHandler is the alias used by the Vue UI; it
+// returns the same shape as listDownloadsHandler but at /download/tasks.
+func downloadTasksAliasHandler(svc *service.Container) gin.HandlerFunc {
+ return listDownloadsHandler(svc)
+}
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 3092104..e1f3377 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -130,46 +130,156 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
- // Recycle bin.
- authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
+ // Site management + cross-site torrent search (via SiteHandler).
+ siteHandler := NewSiteHandler(svc)
+ authed.GET("/sites", siteHandler.ListSites)
+ authed.GET("/sites/types", siteHandler.GetSiteTypes)
+ authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
+ authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
+ authed.GET("/sites/:id", siteHandler.GetSite)
+ authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
+ authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
+ authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
+ authed.GET("/sites/search", siteSearchHandler(svc))
- authed.GET("/ws", wsHandler(svc))
+ // Recycle bin.
+ authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
- // SSE event stream.
- authed.GET("/events", sseHandler(svc))
+ authed.GET("/ws", wsHandler(svc))
- // Download clients.
- authed.GET("/download-clients", listDownloadClientsHandler(svc))
- authed.POST("/download-clients", middleware.AdminRequired(), createDownloadClientHandler(svc))
- authed.GET("/download-clients/:id", getDownloadClientHandler(svc))
- authed.PUT("/download-clients/:id", middleware.AdminRequired(), updateDownloadClientHandler(svc))
- authed.DELETE("/download-clients/:id", middleware.AdminRequired(), deleteDownloadClientHandler(svc))
- authed.POST("/download-clients/:id/test", middleware.AdminRequired(), testDownloadClientHandler(svc))
+ // SSE event stream.
+ authed.GET("/events", sseHandler(svc))
- // Notify channels.
- authed.GET("/notify-channels", listNotifyChannelsHandler(svc))
- authed.GET("/notify-channels/types", getNotifyChannelTypesHandler(svc))
- authed.POST("/notify-channels", middleware.AdminRequired(), createNotifyChannelHandler(svc))
- authed.GET("/notify-channels/:id", getNotifyChannelHandler(svc))
- authed.PUT("/notify-channels/:id", middleware.AdminRequired(), updateNotifyChannelHandler(svc))
- authed.DELETE("/notify-channels/:id", middleware.AdminRequired(), deleteNotifyChannelHandler(svc))
- authed.POST("/notify-channels/:id/test", middleware.AdminRequired(), testNotifyChannelHandler(svc))
+ // Download clients.
+ authed.GET("/download-clients", listDownloadClientsHandler(svc))
+ authed.POST("/download-clients", middleware.AdminRequired(), createDownloadClientHandler(svc))
+ authed.GET("/download-clients/:id", getDownloadClientHandler(svc))
+ authed.PUT("/download-clients/:id", middleware.AdminRequired(), updateDownloadClientHandler(svc))
+ authed.DELETE("/download-clients/:id", middleware.AdminRequired(), deleteDownloadClientHandler(svc))
+ authed.POST("/download-clients/:id/test", middleware.AdminRequired(), testDownloadClientHandler(svc))
- // Scheduler.
- authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
- authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
- authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
+ // Notify channels.
+ authed.GET("/notify-channels", listNotifyChannelsHandler(svc))
+ authed.GET("/notify-channels/types", getNotifyChannelTypesHandler(svc))
+ authed.POST("/notify-channels", middleware.AdminRequired(), createNotifyChannelHandler(svc))
+ authed.GET("/notify-channels/:id", getNotifyChannelHandler(svc))
+ authed.PUT("/notify-channels/:id", middleware.AdminRequired(), updateNotifyChannelHandler(svc))
+ authed.DELETE("/notify-channels/:id", middleware.AdminRequired(), deleteNotifyChannelHandler(svc))
+ authed.POST("/notify-channels/:id/test", middleware.AdminRequired(), testNotifyChannelHandler(svc))
- // Sites (PT 站点管理).
- siteHandler := NewSiteHandler(svc)
- authed.GET("/sites", siteHandler.ListSites)
- authed.GET("/sites/types", siteHandler.GetSiteTypes)
- authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
- authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
- authed.GET("/sites/:id", siteHandler.GetSite)
- authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
- authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
- authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
+ // Scheduler.
+ authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
+ authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
+ authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
+
+ // ── Auxiliary endpoints used by the React UI rails ──
+ authed.GET("/media/recent", recentMediaHandler(svc))
+ authed.GET("/media/stats", mediaStatsHandler(svc))
+
+ // Watch history (extra surface beyond /history).
+ authed.GET("/watch-history", historyListHandler(svc))
+ authed.GET("/watch-history/stats", historyStatsHandler(svc))
+ authed.GET("/watch-history/continue", historyContinueHandler(svc))
+ authed.DELETE("/watch-history", historyDeleteHandler(svc))
+ authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
+
+ // Multi-section TMDb feed used by DiscoverPage.
+ authed.GET("/discover/sections", discoverSectionsHandler(svc))
+ authed.GET("/discover/feed", discoverFeedHandler(svc))
+
+ // System metadata + read-only scheduler view.
+ authed.GET("/system/info", systemInfoHandler(svc))
+ authed.GET("/system/status", systemStatusHandler(svc))
+ authed.GET("/system/scheduler", systemSchedulerHandler(svc))
+
+ // Richer dashboard rails.
+ authed.GET("/stats/overview", statsOverviewHandler(svc))
+ authed.GET("/stats/trend", statsTrendHandler(svc))
+ authed.GET("/stats/top-content", statsTopContentHandler(svc))
+ authed.GET("/stats/libraries", statsLibrariesHandler(svc))
+ authed.GET("/stats/monitor", statsMonitorHandler(svc))
+
+ // Multi-persona play profiles (caller-scoped, admins via ?all=true).
+ authed.GET("/play-profiles", listPlayProfilesHandler(svc))
+ authed.POST("/play-profiles", createPlayProfileHandler(svc))
+ authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
+ authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
+
+ // ── Search aliases ──
+ authed.GET("/search", searchUnifiedHandler(svc))
+ authed.GET("/search/advanced", searchAdvancedHandler(svc))
+ authed.GET("/search/tmdb", searchTMDbHandler(svc))
+ authed.GET("/search/sites", searchSitesHandler(svc))
+
+ // ── System extras ──
+ authed.GET("/system/config", listSystemConfigHandler(svc))
+ authed.GET("/settings/schema", schemaHandler(svc))
+ authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
+
+ // ── Per-user stats ──
+ authed.GET("/stats/user/:id", statsUserHandler(svc))
+ authed.GET("/stats/top-users", statsTopUsersHandler(svc))
+ authed.POST("/stats/play", statsPlayHandler(svc))
+
+ // ── Sites extras ──
+ authed.GET("/sites/:id/resource", siteResourceHandler(svc))
+ authed.GET("/sites/:id/userdata", siteUserdataHandler(svc))
+
+ // ── Subscription extras ──
+ authed.PUT("/subscriptions/:id", updateSubscriptionHandler(svc))
+ authed.POST("/subscriptions/:id/search", searchSubscriptionHandler(svc))
+
+ // ── Playlist extras ──
+ authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
+ authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
+
+ // ── DLNA per-renderer control ──
+ authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
+ authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
+ authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
+ authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
+
+ // ── Media favourite alias surface ──
+ authed.GET("/favorites", listFavoritesAliasHandler(svc))
+ authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
+ authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
+ authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
+ authed.POST("/media/:id/ai-scrape", aiScrapeMediaHandler(svc))
+ authed.POST("/media/scrape/test", scrapeTestHandler(svc))
+ authed.POST("/media/organize", middleware.AdminRequired(), organizeBulkHandler(svc))
+
+ // ── Playback metadata + external player handoff ──
+ authed.GET("/playback/:id/info", playbackInfoHandler(svc))
+ authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
+ authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
+ authed.GET("/playback/:id/external-url", externalURLHandler(svc))
+ authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
+
+ // ── Download task ops + sync triggers ──
+ authed.POST("/download/:id/pause", downloadPauseHandler(svc))
+ authed.POST("/download/:id/resume", downloadResumeHandler(svc))
+ authed.POST("/download/:id/organize", middleware.AdminRequired(), downloadOrganizeOneHandler(svc))
+ authed.POST("/download/organize", middleware.AdminRequired(), downloadOrganizeAllHandler(svc))
+ authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
+ authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
+ authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
+ authed.POST("/download/add", addDownloadHandler(svc))
+
+ // ── License (anyone authenticated can activate / heartbeat) ──
+ authed.POST("/license/activate", licenseActivateHandler(svc))
+ authed.POST("/license/heartbeat", licenseHeartbeatHandler(svc))
+ authed.GET("/license/status", licenseStatusHandler(svc))
+ authed.GET("/license/heartbeat-status", licenseStatusHandler(svc))
+
+ // ── Assistant (multi-turn AI chat) ──
+ authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
+ authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
+ authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
+ authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
+ authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
+ authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
+ authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
+ authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
}
// Admin-only endpoints.
@@ -183,6 +293,35 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
+ // Permissions admin.
+ admin.GET("/users/:id/permissions", getUserPermissionsHandler(svc))
+ admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
+ admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
+
+ // Storage configs (Alist / S3 / WebDAV).
+ admin.GET("/storage/status", listStorageConfigsHandler(svc))
+ admin.GET("/storage/:type", getStorageConfigHandler(svc))
+ admin.PUT("/storage/:type", saveStorageConfigHandler(svc))
+ admin.POST("/storage/:type/test", testStorageConfigHandler(svc))
+
+ // Download client CRUD.
+ admin.GET("/download/clients", listDownloadClientsHandler(svc))
+ admin.POST("/download/clients", createDownloadClientHandler(svc))
+ admin.PUT("/download/clients/:id", updateDownloadClientHandler(svc))
+ admin.DELETE("/download/clients/:id", deleteDownloadClientHandler(svc))
+ admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
+ admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
+
+ // License generation / revocation.
+ admin.POST("/license/generate", licenseGenerateHandler(svc))
+ admin.GET("/license/list", licenseListHandler(svc))
+ admin.GET("/license/:id/activations", licenseListActivationsHandler(svc))
+ admin.POST("/license/activation/:id/unbind", licenseUnbindHandler(svc))
+ admin.POST("/license/:id/revoke", licenseRevokeHandler(svc))
+
+ // System scheduler trigger alias.
+ admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
+
// Database backup.
admin.GET("/backups", listBackupsHandler(svc))
admin.POST("/backups", createBackupHandler(svc))
@@ -192,6 +331,13 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// Notifications (test endpoint).
admin.POST("/notify/test", notifyTestHandler(svc))
+ // Notify channels CRUD + per-channel test.
+ admin.GET("/notify/channels", listNotifyChannelsHandler(svc))
+ admin.POST("/notify/channels", createNotifyChannelHandler(svc))
+ admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc))
+ admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
+ admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
+
// File organizer.
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
diff --git a/internal/handler/license.go b/internal/handler/license.go
new file mode 100644
index 0000000..6ba15b0
--- /dev/null
+++ b/internal/handler/license.go
@@ -0,0 +1,145 @@
+// Package handler — license key endpoints.
+package handler
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+type generateKeyReq struct {
+ Customer string `json:"customer"`
+ Plan string `json:"plan"`
+ MaxActivations int `json:"max_activations"`
+ ExpiresAt string `json:"expires_at,omitempty"` // RFC3339, "" = perpetual
+ Notes string `json:"notes,omitempty"`
+}
+
+func licenseGenerateHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req generateKeyReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ var expires *time.Time
+ if req.ExpiresAt != "" {
+ t, err := time.Parse(time.RFC3339, req.ExpiresAt)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339"})
+ return
+ }
+ expires = &t
+ }
+ k, err := svc.License.Generate(
+ c.Request.Context(),
+ req.Customer, req.Plan, req.Notes,
+ req.MaxActivations, expires,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, k)
+ }
+}
+
+func licenseListHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rows, err := svc.License.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ }
+}
+
+type activateReq struct {
+ Key string `json:"key" binding:"required"`
+ DeviceID string `json:"device_id" binding:"required"`
+ DeviceName string `json:"device_name"`
+}
+
+func licenseActivateHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req activateReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ a, err := svc.License.Activate(
+ c.Request.Context(), req.Key, req.DeviceID, req.DeviceName, c.ClientIP(),
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, a)
+ }
+}
+
+func licenseListActivationsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rows, err := svc.License.ListActivations(c.Request.Context(), c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ }
+}
+
+func licenseUnbindHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.License.Unbind(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+func licenseRevokeHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.License.Revoke(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ actID := c.Query("activation_id")
+ if actID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "activation_id required"})
+ return
+ }
+ if err := svc.License.Heartbeat(c.Request.Context(), actID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+func licenseStatusHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ keyID := c.Query("key_id")
+ if keyID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "key_id required"})
+ return
+ }
+ out, err := svc.License.Status(c.Request.Context(), keyID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, out)
+ }
+}
diff --git a/internal/handler/media_extra.go b/internal/handler/media_extra.go
new file mode 100644
index 0000000..3e7f943
--- /dev/null
+++ b/internal/handler/media_extra.go
@@ -0,0 +1,70 @@
+// Package handler — auxiliary media endpoints used by the home page
+// rails (recent additions) and the admin dashboard summary card.
+package handler
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+func recentMediaHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "12"))
+ if limit <= 0 || limit > 100 {
+ limit = 12
+ }
+ var items []model.Media
+ if err := svc.Repo.DB.Order("created_at desc").Limit(limit).Find(&items).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, items)
+ }
+}
+
+func mediaStatsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var totals struct {
+ Movies int64 `json:"movies"`
+ TV int64 `json:"tv"`
+ Anime int64 `json:"anime"`
+ Music int64 `json:"music"`
+ Unscaped int64 `json:"unscraped"`
+ }
+ // Per-library type rolls up to per-media-type via the JOIN.
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Joins("JOIN libraries ON libraries.id = media.library_id").
+ Where("libraries.type = ?", "movie").Count(&totals.Movies).Error
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Joins("JOIN libraries ON libraries.id = media.library_id").
+ Where("libraries.type = ?", "tv").Count(&totals.TV).Error
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Joins("JOIN libraries ON libraries.id = media.library_id").
+ Where("libraries.type = ?", "anime").Count(&totals.Anime).Error
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Joins("JOIN libraries ON libraries.id = media.library_id").
+ Where("libraries.type = ?", "music").Count(&totals.Music).Error
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = 'pending'").
+ Count(&totals.Unscaped).Error
+
+ var totalCount, totalSize, totalSeconds int64
+ _ = svc.Repo.DB.Model(&model.Media{}).Count(&totalCount).Error
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&totalSize)
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Select("COALESCE(SUM(duration_sec),0)").Row().Scan(&totalSeconds)
+
+ c.JSON(http.StatusOK, gin.H{
+ "by_type": totals,
+ "total": totalCount,
+ "total_size": totalSize,
+ "total_seconds": totalSeconds,
+ })
+ }
+}
diff --git a/internal/handler/media_favorite.go b/internal/handler/media_favorite.go
new file mode 100644
index 0000000..34083f4
--- /dev/null
+++ b/internal/handler/media_favorite.go
@@ -0,0 +1,141 @@
+// Package handler — alias endpoints used by the Vue UI's media detail
+// page that map onto the existing /favourites surface.
+//
+// POST /media/:id/favorite → add to favourites
+// DELETE /media/:id/favorite → remove from favourites
+// GET /media/:id/favorite/status → boolean
+// GET /favorites → alias of /favourites
+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"
+)
+
+// addMediaFavoriteHandler ensures the (user, media) row exists. If it
+// already does we return 200 with favourite=true so the call is
+// idempotent — different from the Toggle behaviour.
+func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ // Check current state.
+ var existing model.Favorite
+ err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
+ First(&existing).Error
+ if err == nil {
+ c.JSON(http.StatusOK, gin.H{"favourite": true})
+ return
+ }
+ // Otherwise create.
+ fav := &model.Favorite{UserID: toString(uid), MediaID: c.Param("id")}
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(fav).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"favourite": true})
+ }
+}
+
+// removeMediaFavoriteHandler is the idempotent inverse.
+func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
+ Delete(&model.Favorite{}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"favourite": false})
+ }
+}
+
+// getMediaFavoriteStatusHandler returns the current state.
+func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ var n int64
+ _ = svc.Repo.DB.WithContext(c.Request.Context()).
+ Model(&model.Favorite{}).
+ Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
+ Count(&n).Error
+ c.JSON(http.StatusOK, gin.H{"favourite": n > 0})
+ }
+}
+
+// listFavoritesAliasHandler is the /favorites alias of /favourites.
+// We reuse the existing service method.
+func listFavoritesAliasHandler(svc *service.Container) gin.HandlerFunc {
+ return listFavouritesHandler(svc)
+}
+
+// aiScrapeMediaHandler asks the scraper to enrich one media row using
+// AI-assisted matching. Today we just delegate to the existing scrape
+// path; the AI hint comes from svc.AI when configured.
+func aiScrapeMediaHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
+ if err != nil || m == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
+ return
+ }
+ if err := svc.Scraper.EnrichOne(c.Request.Context(), m); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, m)
+ }
+}
+
+// scrapeTestHandler validates a (provider, code) pair without touching
+// the database. Useful for the "preview" workflow in the Vue UI.
+type scrapeTestReq struct {
+ Code string `json:"code" binding:"required"`
+}
+
+func scrapeTestHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req scrapeTestReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ // Try TMDb first; the upstream chain handles fall-back to
+ // Bangumi/TheTVDB when configured.
+ match, err := svc.TMDb.SearchMovie(c.Request.Context(), req.Code, 0)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"match": match})
+ }
+}
+
+// organizeBulkHandler triggers organisation across every library when
+// the caller hits POST /media/organize without a media id. It mirrors
+// the upstream Vue surface.
+func organizeBulkHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ libs, err := svc.Repo.Library.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ out := make([]any, 0, len(libs))
+ for _, l := range libs {
+ res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID)
+ if err != nil {
+ out = append(out, gin.H{"library": l.Name, "error": err.Error()})
+ continue
+ }
+ out = append(out, gin.H{"library": l.Name, "result": res})
+ }
+ c.JSON(http.StatusOK, gin.H{"results": out})
+ }
+}
diff --git a/internal/handler/notify_channels.go b/internal/handler/notify_channels.go
new file mode 100644
index 0000000..b44518f
--- /dev/null
+++ b/internal/handler/notify_channels.go
@@ -0,0 +1,73 @@
+// Package handler — notify channel CRUD + per-channel test endpoint.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rows, err := svc.NotifyChannels.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ }
+}
+
+func createNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.ChannelInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ row, err := svc.NotifyChannels.Create(c.Request.Context(), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func updateNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.ChannelInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ row, err := svc.NotifyChannels.Update(c.Request.Context(), c.Param("id"), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func deleteNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.NotifyChannels.Delete(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+func testNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.NotifyChannels.Test(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "test sent"})
+ }
+}
diff --git a/internal/handler/permissions.go b/internal/handler/permissions.go
new file mode 100644
index 0000000..389509e
--- /dev/null
+++ b/internal/handler/permissions.go
@@ -0,0 +1,74 @@
+// Package handler — per-user feature toggle endpoints.
+//
+// GET /auth/permissions → caller's effective permissions
+// GET /admin/users/:id/permissions
+// PUT /admin/users/:id/permissions
+// POST /admin/users/:id/permissions/reset
+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"
+)
+
+func myPermissionsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ row, err := svc.Permissions.Effective(c.Request.Context(), toString(uid))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if row == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func getUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ row, err := svc.Permissions.Effective(c.Request.Context(), c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if row == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func updateUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var p model.UserPermission
+ if err := c.ShouldBindJSON(&p); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := svc.Permissions.Save(c.Request.Context(), c.Param("id"), &p); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, p)
+ }
+}
+
+func resetUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ row, err := svc.Permissions.Reset(c.Request.Context(), c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
diff --git a/internal/handler/play_profile.go b/internal/handler/play_profile.go
new file mode 100644
index 0000000..d7eff40
--- /dev/null
+++ b/internal/handler/play_profile.go
@@ -0,0 +1,86 @@
+// Package handler — multi-persona play profile CRUD endpoints.
+//
+// Non-admin users see / mutate only their own profiles. Admins see
+// every profile so they can manage child accounts, etc.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/middleware"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// listPlayProfilesHandler returns the caller's profiles, or every
+// profile when the caller is an admin AND ?all=true is set.
+func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ if c.Query("all") == "true" && role == "admin" {
+ rows, err := svc.PlayProfiles.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ return
+ }
+ rows, err := svc.PlayProfiles.ListByUser(c.Request.Context(), toString(uid))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, rows)
+ }
+}
+
+func createPlayProfileHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.PlayProfileInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ // Default the user_id to the caller; admins can override.
+ uid, _ := c.Get(middleware.CtxUserID)
+ role, _ := c.Get(middleware.CtxUserRole)
+ if in.UserID == "" || role != "admin" {
+ in.UserID = toString(uid)
+ }
+ row, err := svc.PlayProfiles.Create(c.Request.Context(), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func updatePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.PlayProfileInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ row, err := svc.PlayProfiles.Update(c.Request.Context(), c.Param("id"), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.PlayProfiles.Delete(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go
new file mode 100644
index 0000000..ca7d8c3
--- /dev/null
+++ b/internal/handler/playback_extra.go
@@ -0,0 +1,130 @@
+// Package handler — playback metadata endpoints expected by the Vue UI:
+//
+// GET /playback/:id/info
+// POST /playback/:id/progress
+// GET /playback/:id/external-players
+// GET /playback/:id/external-url
+// GET /playback/transcode/:job_id/status
+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"
+)
+
+// playbackInfoHandler returns the media row + a `stream_url` the React
+// player can hit. Mirrors the Python project's surface.
+func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
+ if err != nil || m == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "media": m,
+ "stream_url": "/api/stream/" + m.ID,
+ "hls_url": "/api/hls/" + m.ID + "/index.m3u8",
+ })
+ }
+}
+
+type playbackProgressReq struct {
+ PositionMs int64 `json:"position_ms"`
+ DurationMs int64 `json:"duration_ms"`
+ Completed bool `json:"completed"`
+}
+
+func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req playbackProgressReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ uid, _ := c.Get(middleware.CtxUserID)
+ if err := svc.Playback.RecordProgress(
+ c.Request.Context(), toString(uid), c.Param("id"),
+ req.PositionMs, req.DurationMs,
+ ); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+// externalPlayersHandler returns the list of external player URI
+// schemes the UI can offer the user. We lookup the media row to
+// produce the per-player launch URL.
+func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
+ if err != nil || m == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
+ return
+ }
+ streamURL := "/api/stream/" + m.ID
+ c.JSON(http.StatusOK, gin.H{
+ "players": []gin.H{
+ {"name": "VLC", "scheme": "vlc://", "url": "vlc://" + streamURL},
+ {"name": "PotPlayer", "scheme": "potplayer://", "url": "potplayer://" + streamURL},
+ {"name": "MX Player", "scheme": "intent://", "url": "intent://" + streamURL + "#Intent;package=com.mxtech.videoplayer.ad;end"},
+ {"name": "IINA", "scheme": "iina://", "url": "iina://weblink?url=" + streamURL},
+ {"name": "nPlayer", "scheme": "nplayer-", "url": "nplayer-" + streamURL},
+ },
+ })
+ }
+}
+
+// externalURLHandler returns just the raw stream URL plus the auth
+// token query string the external player needs.
+func externalURLHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
+ if err != nil || m == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
+ return
+ }
+ // Re-issue a short-lived token for this stream.
+ uid, _ := c.Get(middleware.CtxUserID)
+ u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
+ if err != nil || u == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
+ return
+ }
+ token, err := svc.Auth.IssueToken(u)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "url": "/api/stream/" + m.ID + "?token=" + token,
+ "token": token,
+ })
+ }
+}
+
+// transcodeStatusHandler reports the live status of one transcode job.
+// We surface the active jobs the transcoder knows about.
+func transcodeStatusHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ jobID := c.Param("job_id")
+ for _, j := range svc.Transcoder.Active() {
+ if j.MediaID == jobID {
+ c.JSON(http.StatusOK, gin.H{"job_id": jobID, "status": "running", "job": j})
+ return
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"job_id": jobID, "status": "idle"})
+ }
+}
+
+// _ keeps imports tidy when the model package isn't otherwise used.
+var _ = model.Media{}
+var _ = service.Container{}
diff --git a/internal/handler/playlist_extra.go b/internal/handler/playlist_extra.go
new file mode 100644
index 0000000..cf988a1
--- /dev/null
+++ b/internal/handler/playlist_extra.go
@@ -0,0 +1,55 @@
+// Package handler — playlist reordering + per-item-id removal that the
+// Vue UI uses on top of the basic /playlists/:id/items surface.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+type reorderReq struct {
+ // Order is a list of media IDs in the desired playback order.
+ Order []string `json:"order" binding:"required"`
+}
+
+// reorderPlaylistHandler updates the Position column on each
+// PlaylistItem to match the supplied order. Items missing from the
+// order keep their existing position.
+func reorderPlaylistHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req reorderReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ pid := c.Param("id")
+ for i, mid := range req.Order {
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Model(&model.PlaylistItem{}).
+ Where("playlist_id = ? AND media_id = ?", pid, mid).
+ Update("position", i).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+// deletePlaylistItemByIDHandler is the alternate route at
+// /playlists/:id/items/:item_id (vs. the existing /:media_id variant).
+func deletePlaylistItemByIDHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Where("playlist_id = ? AND id = ?", c.Param("id"), c.Param("item_id")).
+ Delete(&model.PlaylistItem{}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
diff --git a/internal/handler/search_extra.go b/internal/handler/search_extra.go
new file mode 100644
index 0000000..ada2f84
--- /dev/null
+++ b/internal/handler/search_extra.go
@@ -0,0 +1,106 @@
+// Package handler — unified search surface that mirrors the Python
+// project's /api/search* endpoints. Internally we delegate to the
+// existing media + site adapters; advanced/tmdb/sites variants exist
+// so the upstream Vue UI's queries don't need rewriting.
+package handler
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// searchUnifiedHandler is the basic /api/search endpoint.
+func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ q := c.Query("q")
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
+ if limit <= 0 || limit > 200 {
+ limit = 30
+ }
+ items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
+ }
+}
+
+// searchAdvancedHandler accepts query + optional filters
+// (year, type, library_id) — currently it ignores the filters in the
+// SQL but threads them through to the response so the UI can echo
+// them back. This keeps API parity without a giant query builder.
+func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ q := c.Query("q")
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
+ if limit <= 0 || limit > 200 {
+ limit = 30
+ }
+ items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "items": items,
+ "filters": gin.H{
+ "year": c.Query("year"),
+ "type": c.Query("type"),
+ "library_id": c.Query("library_id"),
+ },
+ })
+ }
+}
+
+// searchTMDbHandler proxies the TMDb /search endpoint via the existing
+// SearchMovie helper. Movies and TV use different URLs upstream but
+// only the movie path is wired today; TV is best-effort.
+func searchTMDbHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ q := c.Query("query")
+ if q == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "query required"})
+ return
+ }
+ if svc.TMDb == nil || !svc.TMDb.Enabled() {
+ c.JSON(http.StatusOK, gin.H{"items": []any{}, "note": "tmdb disabled"})
+ return
+ }
+ match, err := svc.TMDb.SearchMovie(c.Request.Context(), q, 0)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ out := make([]any, 0, 1)
+ if match != nil {
+ out = append(out, match)
+ }
+ c.JSON(http.StatusOK, gin.H{"items": out})
+ }
+}
+
+// searchSitesHandler mirrors the existing /sites/search but at the
+// /search/sites alias the Vue UI uses.
+func searchSitesHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ keyword := c.Query("keyword")
+ if keyword == "" {
+ keyword = c.Query("q")
+ }
+ if keyword == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"})
+ return
+ }
+ results, err := svc.Site.Search(c.Request.Context(), keyword)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": results})
+ }
+}
diff --git a/internal/handler/sites.go b/internal/handler/sites.go
new file mode 100644
index 0000000..274c648
--- /dev/null
+++ b/internal/handler/sites.go
@@ -0,0 +1,148 @@
+// Package handler — site management (PT/BT tracker CRUD + cross-site search).
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// ─── CRUD ────────────────────────────────────────────────────────────────────
+
+func listSitesHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sites, err := svc.Site.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": sites})
+ }
+}
+
+func getSiteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ site, err := svc.Site.FindByID(c.Request.Context(), c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if site == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(http.StatusOK, site)
+ }
+}
+
+type createSiteReq struct {
+ Name string `json:"name" binding:"required"`
+ BaseURL string `json:"base_url" binding:"required"`
+ SiteType string `json:"site_type"`
+ AuthType string `json:"auth_type"`
+ Cookie string `json:"cookie"`
+ APIKey string `json:"api_key"`
+ AuthHeader string `json:"auth_header"`
+ UserAgent string `json:"user_agent"`
+ RSSURL string `json:"rss_url"`
+ Timeout int `json:"timeout"`
+ Priority int `json:"priority"`
+ UseProxy bool `json:"use_proxy"`
+ Enabled *bool `json:"enabled"`
+ Downloader string `json:"downloader"`
+}
+
+func createSiteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req createSiteReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ enabled := true
+ if req.Enabled != nil {
+ enabled = *req.Enabled
+ }
+ site := &model.Site{
+ Name: req.Name,
+ BaseURL: req.BaseURL,
+ SiteType: req.SiteType,
+ AuthType: req.AuthType,
+ Cookie: req.Cookie,
+ APIKey: req.APIKey,
+ AuthHeader: req.AuthHeader,
+ UserAgent: req.UserAgent,
+ RSSURL: req.RSSURL,
+ Timeout: req.Timeout,
+ Priority: req.Priority,
+ UseProxy: req.UseProxy,
+ Enabled: enabled,
+ Downloader: req.Downloader,
+ }
+ if err := svc.Site.Create(c.Request.Context(), site); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, site)
+ }
+}
+
+func updateSiteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var patch map[string]any
+ if err := c.ShouldBindJSON(&patch); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ // Return the updated row.
+ site, _ := svc.Site.FindByID(c.Request.Context(), c.Param("id"))
+ c.JSON(http.StatusOK, site)
+ }
+}
+
+func deleteSiteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+// ─── Connection test ─────────────────────────────────────────────────────────
+
+func testSiteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ok, msg, err := svc.Site.TestConnection(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{"success": ok, "message": msg})
+ }
+}
+
+// ─── Cross-site search ───────────────────────────────────────────────────────
+
+func siteSearchHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ keyword := c.Query("keyword")
+ if keyword == "" {
+ keyword = c.Query("q")
+ }
+ results, err := svc.Site.Search(c.Request.Context(), keyword)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": results, "total": len(results)})
+ }
+}
diff --git a/internal/handler/sites_extra.go b/internal/handler/sites_extra.go
new file mode 100644
index 0000000..e897a2a
--- /dev/null
+++ b/internal/handler/sites_extra.go
@@ -0,0 +1,61 @@
+// Package handler — extra site endpoints used by the Vue UI:
+//
+// GET /sites/:id/resource → keyword search scoped to one site
+// GET /sites/:id/userdata → cookie-derived user info (stubbed)
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// siteResourceHandler runs a search restricted to a single site.
+//
+// We reuse the full SiteService.Search() and post-filter by site_id;
+// it's not the hottest path so we trade simplicity for speed here.
+func siteResourceHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ keyword := c.Query("keyword")
+ if keyword == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"})
+ return
+ }
+ all, err := svc.Site.Search(c.Request.Context(), keyword)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ want := c.Param("id")
+ filtered := make([]service.SearchResult, 0, len(all))
+ for _, r := range all {
+ if r.SiteID == want {
+ filtered = append(filtered, r)
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"items": filtered})
+ }
+}
+
+// siteUserdataHandler returns whatever the site exposes about the
+// authenticated user (upload/download stats, ratio, etc.). This is a
+// stub: we report the cookie length so the UI can confirm a login is
+// present, but full per-site parsing is out of scope here.
+func siteUserdataHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ s, err := svc.Site.FindByID(c.Request.Context(), c.Param("id"))
+ if err != nil || s == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "site not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "site_id": s.ID,
+ "name": s.Name,
+ "cookie_set": len(s.Cookie) > 0,
+ "login_status": s.LoginStatus,
+ "note": "userdata parsing not implemented; stub",
+ })
+ }
+}
diff --git a/internal/handler/stats_extra.go b/internal/handler/stats_extra.go
new file mode 100644
index 0000000..0144cf1
--- /dev/null
+++ b/internal/handler/stats_extra.go
@@ -0,0 +1,155 @@
+// Package handler — richer dashboard statistics endpoints.
+//
+// /api/stats already returns the basic snapshot. The Vue admin
+// dashboard also uses:
+//
+// /api/stats/overview — counts + total size + total seconds
+// /api/stats/trend — daily play count over last N days
+// /api/stats/top-content — top played media (by play count)
+// /api/stats/libraries — per-library item count + size
+// /api/stats/monitor — live CPU/mem/disk
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+func statsOverviewHandler(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, gin.H{
+ "libraries": snap.Libraries,
+ "media_count": snap.MediaCount,
+ "users_count": snap.UsersCount,
+ "total_size": snap.TotalSizeBytes,
+ "total_seconds": snap.TotalSeconds,
+ "generated_at": snap.GeneratedAt,
+ })
+ }
+}
+
+// statsTrendHandler returns play counts per day for the last N days.
+func statsTrendHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ days, _ := strconv.Atoi(c.DefaultQuery("days", "14"))
+ if days <= 0 || days > 90 {
+ days = 14
+ }
+ // Use the playback_history table; one row per (user, media)
+ // per day if we group by date(watched_at).
+ type bucket struct {
+ Day string `json:"day"`
+ Count int64 `json:"count"`
+ }
+ out := make([]bucket, 0, days)
+ now := time.Now().UTC()
+ for i := days - 1; i >= 0; i-- {
+ start := now.AddDate(0, 0, -i).Truncate(24 * time.Hour)
+ end := start.Add(24 * time.Hour)
+ var n int64
+ _ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("watched_at >= ? AND watched_at < ?", start, end).
+ Count(&n).Error
+ out = append(out, bucket{
+ Day: start.Format("2006-01-02"),
+ Count: n,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"trend": out, "days": days})
+ }
+}
+
+// statsTopContentHandler returns the most-watched media items.
+func statsTopContentHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
+ if limit <= 0 || limit > 50 {
+ limit = 10
+ }
+ type row struct {
+ MediaID string `json:"media_id"`
+ PlayCount int64 `json:"play_count"`
+ LastPlayed time.Time `json:"last_played"`
+ }
+ var rows []row
+ _ = svc.Repo.DB.Table("playback_histories").
+ Select("media_id, COUNT(*) as play_count, MAX(watched_at) as last_played").
+ Group("media_id").
+ Order("play_count desc").
+ Limit(limit).
+ Scan(&rows).Error
+ // Hydrate media titles in a single query.
+ ids := make([]string, 0, len(rows))
+ for _, r := range rows {
+ ids = append(ids, r.MediaID)
+ }
+ mIdx := map[string]model.Media{}
+ if len(ids) > 0 {
+ var media []model.Media
+ _ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error
+ for _, m := range media {
+ mIdx[m.ID] = m
+ }
+ }
+ out := make([]gin.H, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, gin.H{
+ "media": mIdx[r.MediaID],
+ "play_count": r.PlayCount,
+ "last_played": r.LastPlayed,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"items": out})
+ }
+}
+
+// statsLibrariesHandler returns per-library counts + size.
+func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var libs []model.Library
+ if err := svc.Repo.DB.Find(&libs).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ out := make([]gin.H, 0, len(libs))
+ for _, l := range libs {
+ var count int64
+ var size int64
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Where("library_id = ?", l.ID).Count(&count).Error
+ _ = svc.Repo.DB.Model(&model.Media{}).
+ Where("library_id = ?", l.ID).
+ Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&size)
+ out = append(out, gin.H{
+ "library": l,
+ "item_count": count,
+ "total_size": size,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"libraries": out})
+ }
+}
+
+// statsMonitorHandler returns live system resource usage; this is just
+// the Hardware portion of the snapshot but with a snappy schema.
+func statsMonitorHandler(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.Hardware)
+ }
+}
diff --git a/internal/handler/stats_user.go b/internal/handler/stats_user.go
new file mode 100644
index 0000000..7775d49
--- /dev/null
+++ b/internal/handler/stats_user.go
@@ -0,0 +1,111 @@
+// Package handler — per-user stats and a play-event recorder.
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/middleware"
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// statsUserHandler returns a watch-time summary for one user.
+func statsUserHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid := c.Param("id")
+ var watched int64
+ _ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("user_id = ?", uid).
+ Select("COALESCE(SUM(position_ms), 0)").
+ Row().Scan(&watched)
+ var total int64
+ _ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("user_id = ?", uid).Count(&total).Error
+ c.JSON(http.StatusOK, gin.H{
+ "user_id": uid,
+ "watched_ms": watched,
+ "plays": total,
+ "watched_hours": float64(watched) / 1000.0 / 3600.0,
+ })
+ }
+}
+
+// statsTopUsersHandler returns the most active users by play count.
+func statsTopUsersHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
+ if limit <= 0 || limit > 50 {
+ limit = 10
+ }
+ type row struct {
+ UserID string `json:"user_id"`
+ Plays int64 `json:"plays"`
+ }
+ var rows []row
+ _ = svc.Repo.DB.Table("playback_histories").
+ Select("user_id, COUNT(*) as plays").
+ Group("user_id").
+ Order("plays desc").
+ Limit(limit).Scan(&rows).Error
+ // Hydrate usernames in one query.
+ ids := make([]string, 0, len(rows))
+ for _, r := range rows {
+ ids = append(ids, r.UserID)
+ }
+ nameIdx := map[string]string{}
+ if len(ids) > 0 {
+ var users []model.User
+ _ = svc.Repo.DB.Where("id IN ?", ids).Find(&users).Error
+ for _, u := range users {
+ nameIdx[u.ID] = u.Username
+ }
+ }
+ out := make([]gin.H, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, gin.H{
+ "user_id": r.UserID,
+ "username": nameIdx[r.UserID],
+ "plays": r.Plays,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"items": out})
+ }
+}
+
+// statsPlayHandler accepts a play event so the Vue analytics panel can
+// emit one even when the actual progress write goes through /history.
+type playEventReq struct {
+ MediaID string `json:"media_id" binding:"required"`
+ PositionMs int64 `json:"position_ms"`
+ DurationMs int64 `json:"duration_ms"`
+ Completed bool `json:"completed"`
+}
+
+func statsPlayHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req playEventReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ uid, _ := c.Get(middleware.CtxUserID)
+ // Just upsert into PlaybackHistory; the existing service
+ // handles the dedup logic.
+ if err := svc.Repo.History.Upsert(c.Request.Context(), &model.PlaybackHistory{
+ UserID: toString(uid),
+ MediaID: req.MediaID,
+ PositionMs: req.PositionMs,
+ DurationMs: req.DurationMs,
+ WatchedAt: time.Now(),
+ Completed: req.Completed,
+ }); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
diff --git a/internal/handler/storage_config.go b/internal/handler/storage_config.go
new file mode 100644
index 0000000..4ed94c0
--- /dev/null
+++ b/internal/handler/storage_config.go
@@ -0,0 +1,75 @@
+// Package handler — Alist / S3 / WebDAV storage config endpoints.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// listStorageConfigsHandler returns the status overview used by the
+// admin storage panel: every persisted backend with secrets redacted.
+func listStorageConfigsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rows, err := svc.StorageCfg.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": rows})
+ }
+}
+
+// getStorageConfigHandler returns one config (with the decrypted body).
+func getStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ row, err := svc.StorageCfg.Get(c.Request.Context(), c.Param("type"))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if row == nil {
+ c.JSON(http.StatusOK, gin.H{"type": c.Param("type"), "config": gin.H{}})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+// saveStorageConfigHandler upserts the config row; the caller passes
+// the type via URL and the body as a JSON object.
+func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.StorageInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ in.Type = c.Param("type")
+ row, err := svc.StorageCfg.Save(c.Request.Context(), in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, row)
+ }
+}
+
+// testStorageConfigHandler probes an unsaved config.
+func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var in service.StorageInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ in.Type = c.Param("type")
+ if err := svc.StorageCfg.Test(c.Request.Context(), in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go
new file mode 100644
index 0000000..19bd297
--- /dev/null
+++ b/internal/handler/subscription_extra.go
@@ -0,0 +1,61 @@
+// Package handler — subscription update + per-subscription site search.
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// updateSubscriptionHandler patches a subscription row.
+func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var patch model.Subscription
+ if err := c.ShouldBindJSON(&patch); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Model(&model.Subscription{}).
+ Where("id = ?", c.Param("id")).
+ Updates(map[string]any{
+ "name": patch.Name,
+ "feed_url": patch.FeedURL,
+ "filter": patch.Filter,
+ "enabled": patch.Enabled,
+ }).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+// searchSubscriptionHandler runs a one-off keyword search against the
+// configured tracker sites for the given subscription. We treat the
+// subscription's filter as the search term; this lets the UI preview
+// what would be queued without actually downloading anything.
+func searchSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var sub model.Subscription
+ err := svc.Repo.DB.WithContext(c.Request.Context()).
+ Where("id = ?", c.Param("id")).First(&sub).Error
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "subscription not found"})
+ return
+ }
+ keyword := sub.Filter
+ if keyword == "" {
+ keyword = sub.Name
+ }
+ results, err := svc.Site.Search(c.Request.Context(), keyword)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"items": results, "subscription": sub})
+ }
+}
diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go
new file mode 100644
index 0000000..7067249
--- /dev/null
+++ b/internal/handler/system_extra.go
@@ -0,0 +1,159 @@
+// Package handler — system config + scheduler trigger + events ticket.
+package handler
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/middleware"
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// listSystemConfigHandler is the non-admin alias for /admin/settings.
+// It returns the same key/value rows so the Vue UI's `system.getConfig`
+// helper keeps working.
+func listSystemConfigHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ rows, err := svc.Repo.Setting.All(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ // Hide secret-flavoured keys for non-admins.
+ role, _ := c.Get(middleware.CtxUserRole)
+ out := make([]model.Setting, 0, len(rows))
+ for _, s := range rows {
+ if role != "admin" && isSecretKey(s.Key) {
+ s.Value = "********"
+ }
+ out = append(out, s)
+ }
+ c.JSON(http.StatusOK, gin.H{"items": out})
+ }
+}
+
+func isSecretKey(k string) bool {
+ for _, suffix := range []string{".token", ".secret", ".password", ".api_key", ".cookie"} {
+ if endsWith(k, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+func endsWith(s, suffix string) bool {
+ return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
+}
+
+// schemaHandler returns the curated settings schema (used by the
+// `getSchema()` Vue helper). It mirrors the SettingsPage groupings but
+// in JSON so the upstream UI can render its dynamic form.
+func schemaHandler(_ *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "groups": []gin.H{
+ {
+ "key": "general",
+ "label": "常规",
+ "items": []gin.H{
+ {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
+ {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
+ {"key": "transcode.hw_accel", "type": "select", "label": "硬件加速"},
+ {"key": "transcode.max_jobs", "type": "number", "label": "最大并发"},
+ {"key": "ffmpeg.path", "type": "text", "label": "FFmpeg 路径"},
+ {"key": "ffprobe.path", "type": "text", "label": "FFprobe 路径"},
+ },
+ },
+ {
+ "key": "organize",
+ "label": "整理 & 刮削",
+ "items": []gin.H{
+ {"key": "organize.auto", "type": "toggle"},
+ {"key": "organize.movie_format", "type": "text"},
+ {"key": "organize.tv_format", "type": "text"},
+ {"key": "organize.anime_format", "type": "text"},
+ {"key": "scrape.auto_on_scan", "type": "toggle"},
+ {"key": "scrape.providers", "type": "text"},
+ {"key": "scrape.language", "type": "text"},
+ },
+ },
+ {
+ "key": "adult",
+ "label": "Adult / NSFW",
+ "items": []gin.H{
+ {"key": "adult.enabled", "type": "toggle"},
+ {"key": "adult.require_pin", "type": "toggle"},
+ {"key": "adult.pin", "type": "text"},
+ },
+ },
+ {
+ "key": "qbittorrent",
+ "label": "qBittorrent",
+ "items": []gin.H{
+ {"key": "qbittorrent.url", "type": "text"},
+ {"key": "qbittorrent.username", "type": "text"},
+ {"key": "qbittorrent.password", "type": "text"},
+ {"key": "qbittorrent.savepath", "type": "text"},
+ },
+ },
+ },
+ })
+ }
+}
+
+// schedulerTriggerHandler is the alternate path for /admin/scheduler/:name/run.
+func schedulerTriggerHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if err := svc.Scheduler.RunNow(c.Request.Context(), c.Param("name")); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
+// ─── SSE ticket store ───────────────────────────────────────────────────────
+//
+// The Vue UI's SSE event stream wants a one-time signed ticket so the
+// EventSource (which can't set Authorization headers) can authenticate.
+// We don't expose the SSE stream itself yet, but we persist short-lived
+// tickets keyed to the user so the upstream consumer keeps working.
+
+type ticket struct {
+ userID string
+ expires time.Time
+}
+
+var (
+ ticketStore = map[string]ticket{}
+ ticketStoreMu sync.Mutex
+)
+
+func newTicket(userID string) string {
+ buf := make([]byte, 16)
+ _, _ = rand.Read(buf)
+ t := hex.EncodeToString(buf)
+ ticketStoreMu.Lock()
+ defer ticketStoreMu.Unlock()
+ ticketStore[t] = ticket{userID: userID, expires: time.Now().Add(60 * time.Second)}
+ // GC expired tickets opportunistically.
+ for k, v := range ticketStore {
+ if time.Now().After(v.expires) {
+ delete(ticketStore, k)
+ }
+ }
+ return t
+}
+
+func systemEventsTicketHandler(_ *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ c.JSON(http.StatusOK, gin.H{"ticket": newTicket(toString(uid))})
+ }
+}
diff --git a/internal/handler/system_meta.go b/internal/handler/system_meta.go
new file mode 100644
index 0000000..a488b14
--- /dev/null
+++ b/internal/handler/system_meta.go
@@ -0,0 +1,66 @@
+// Package handler — system metadata endpoints used by the React shell
+// (footer "powered by", admin status panel, scheduled-task page).
+//
+// These mirror the Vue surface (/api/system/info, /api/system/status,
+// /api/system/scheduler) so the React port can reuse the same calls.
+package handler
+
+import (
+ "net/http"
+ "runtime"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/shirou/gopsutil/v3/cpu"
+ "github.com/shirou/gopsutil/v3/disk"
+ "github.com/shirou/gopsutil/v3/mem"
+
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// startedAt is captured at first call so /system/status can report uptime
+// without threading state through the container.
+var startedAt = time.Now()
+
+func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "name": "MediaStationGo",
+ "version": "0.1.0",
+ "go": runtime.Version(),
+ "os": runtime.GOOS,
+ "arch": runtime.GOARCH,
+ "data_dir": svc.Cfg.App.DataDir,
+ "cache_dir": svc.Cfg.Cache.CacheDir,
+ })
+ }
+}
+
+func systemStatusHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ out := gin.H{
+ "uptime_seconds": int64(time.Since(startedAt).Seconds()),
+ "goroutines": runtime.NumGoroutine(),
+ }
+ if usage, err := cpu.Percent(0, false); err == nil && len(usage) > 0 {
+ out["cpu_percent"] = usage[0]
+ }
+ if v, err := mem.VirtualMemory(); err == nil {
+ out["memory_used"] = v.Used
+ out["memory_total"] = v.Total
+ }
+ if d, err := disk.Usage(svc.Cfg.App.DataDir); err == nil {
+ out["disk_used"] = d.Used
+ out["disk_total"] = d.Total
+ }
+ c.JSON(http.StatusOK, out)
+ }
+}
+
+// systemSchedulerHandler is the read-only (non-admin) variant of
+// /admin/scheduler — handy on user-facing dashboards.
+func systemSchedulerHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()})
+ }
+}
diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go
new file mode 100644
index 0000000..565a7ec
--- /dev/null
+++ b/internal/handler/watch_history.go
@@ -0,0 +1,162 @@
+// Package handler — watch history endpoints.
+//
+// The base /history GET / POST routes already exist; these add the three
+// auxiliary surfaces the React WatchHistoryPage needs:
+//
+// GET /api/watch-history paginated list (admin sees every user)
+// GET /api/watch-history/stats aggregate watch time + completion
+// GET /api/watch-history/continue resume rail (incomplete only)
+// DELETE /api/watch-history clear (?media_item_id= optional)
+// DELETE /api/watch-history/:id remove one row
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ShukeBta/MediaStationGo/internal/middleware"
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/service"
+)
+
+// historyListHandler returns the caller's history rows joined with the
+// matching media in a single response.
+func historyListHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
+ if limit <= 0 || limit > 500 {
+ limit = 50
+ }
+ items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, items)
+ }
+}
+
+// historyStatsHandler returns aggregate watch time + completion counts
+// for the caller. Used by the WatchHistoryPage hero card.
+func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ userID := toString(uid)
+
+ var total int64
+ _ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("user_id = ?", userID).Count(&total).Error
+
+ var completed int64
+ _ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("user_id = ? AND completed = ?", userID, true).Count(&completed).Error
+
+ var watchedMs int64
+ _ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("user_id = ?", userID).
+ Select("COALESCE(SUM(position_ms), 0)").
+ Row().Scan(&watchedMs)
+
+ var last *time.Time
+ row := svc.Repo.DB.Model(&model.PlaybackHistory{}).
+ Where("user_id = ?", userID).
+ Select("MAX(watched_at)").Row()
+ var lastT time.Time
+ if err := row.Scan(&lastT); err == nil && !lastT.IsZero() {
+ last = &lastT
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "total": total,
+ "completed": completed,
+ "watched_ms": watchedMs,
+ "watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
+ "last_watched": last,
+ })
+ }
+}
+
+// historyContinueHandler returns "Continue Watching" rows: incomplete
+// items, most recent first.
+func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
+ if limit <= 0 || limit > 50 {
+ limit = 10
+ }
+ var rows []model.PlaybackHistory
+ if err := svc.Repo.DB.
+ Where("user_id = ? AND completed = ?", toString(uid), false).
+ Order("watched_at desc").
+ Limit(limit).
+ Find(&rows).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ // Hydrate media in one query.
+ ids := make([]string, 0, len(rows))
+ for _, r := range rows {
+ ids = append(ids, r.MediaID)
+ }
+ var media []model.Media
+ if len(ids) > 0 {
+ _ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error
+ }
+ mIdx := make(map[string]model.Media, len(media))
+ for _, m := range media {
+ mIdx[m.ID] = m
+ }
+ out := make([]gin.H, 0, len(rows))
+ for _, r := range rows {
+ m, ok := mIdx[r.MediaID]
+ if !ok {
+ continue
+ }
+ out = append(out, gin.H{
+ "history": r,
+ "media": m,
+ })
+ }
+ c.JSON(http.StatusOK, out)
+ }
+}
+
+// historyDeleteHandler removes one or all history rows for the caller.
+//
+// DELETE /api/watch-history?media_id=xxx → delete just that media's row
+// DELETE /api/watch-history → clear all rows for the user
+func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ userID := toString(uid)
+ mediaID := c.Query("media_id")
+
+ q := svc.Repo.DB.Where("user_id = ?", userID)
+ if mediaID != "" {
+ q = q.Where("media_id = ?", mediaID)
+ }
+ if err := q.Delete(&model.PlaybackHistory{}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
+
+func historyDeleteOneHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ uid, _ := c.Get(middleware.CtxUserID)
+ if err := svc.Repo.DB.
+ Where("user_id = ? AND id = ?", toString(uid), c.Param("id")).
+ Delete(&model.PlaybackHistory{}).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.Status(http.StatusNoContent)
+ }
+}
diff --git a/internal/model/model.go b/internal/model/model.go
index d1d65a8..91d32d9 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -204,7 +204,175 @@ type AccessLog struct {
Detail string `gorm:"type:text" json:"detail"`
}
-// AllModels 返回 gorm.AutoMigrate 使用的切片。
+// Site stores a PT/BT tracker site configuration used by the subscription
+// and cross-site search system. Mirrors the original MediaStation sites table.
+//
+// Supported site types: nexusphp / gazelle / unit3d / mteam / custom_rss
+// Supported auth types: cookie / api_key / authorization
+type Site struct {
+ Base
+ Name string `gorm:"size:128;not null" json:"name"`
+ BaseURL string `gorm:"size:512;not null" json:"base_url"`
+ SiteType string `gorm:"size:32;default:nexusphp" json:"site_type"`
+ AuthType string `gorm:"size:32;default:cookie" json:"auth_type"`
+ Cookie string `gorm:"type:text" json:"cookie,omitempty"`
+ APIKey string `gorm:"size:512" json:"api_key,omitempty"`
+ AuthHeader string `gorm:"size:512" json:"auth_header,omitempty"`
+ UserAgent string `gorm:"size:512" json:"user_agent,omitempty"`
+ RSSURL string `gorm:"size:1024" json:"rss_url,omitempty"`
+ Timeout int `gorm:"default:15" json:"timeout"`
+ Priority int `gorm:"default:50" json:"priority"`
+ UseProxy bool `gorm:"default:false" json:"use_proxy"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
+ LoginStatus string `gorm:"size:20;default:unknown" json:"login_status"`
+ Downloader string `gorm:"size:50" json:"downloader,omitempty"`
+}
+
+// NotifyChannel is one named outbound notification destination.
+//
+// The Config column holds a JSON blob whose schema depends on the
+// ChannelType (telegram/wechat/bark/webhook):
+//
+// telegram → {bot_token, chat_id}
+// wechat → {sendkey}
+// bark → {device_key, server?}
+// webhook → {url, method, headers (JSON string), body_template}
+//
+// The Events column is a JSON array of event-type strings the channel
+// subscribes to; an empty array means "all events".
+type NotifyChannel struct {
+ Base
+ Name string `gorm:"size:128;not null" json:"name"`
+ ChannelType string `gorm:"size:32;not null" json:"channel_type"`
+ Config string `gorm:"type:text;not null" json:"config"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
+ Events string `gorm:"type:text;default:'[]'" json:"events"`
+}
+
+// PlayProfile lets one user define multiple "viewing personas" with
+// different content-rating limits, library access, and player defaults.
+// The original Vue project sketched this out as a forward-looking
+// feature; we materialise it server-side so the React port can fully
+// function without dropping the screen.
+//
+// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all).
+type PlayProfile struct {
+ Base
+ UserID string `gorm:"index;size:36;not null" json:"user_id"`
+ Name string `gorm:"size:64;not null" json:"name"`
+ IsDefault bool `gorm:"default:false" json:"is_default"`
+ ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
+ AllowAdult bool `gorm:"default:false" json:"allow_adult"`
+ RequirePIN bool `gorm:"default:false" json:"require_pin"`
+ PINHash string `gorm:"size:128" json:"-"`
+ PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
+ PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
+ AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
+ SkipIntro bool `gorm:"default:false" json:"skip_intro"`
+ AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
+ TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
+ LastActiveAt *time.Time `json:"last_active_at,omitempty"`
+}
+
+// UserPermission stores per-user feature toggles for the React UI's
+// menu visibility + route guards. The original Python project surfaces
+// 11 boolean flags; we mirror the same set so the existing frontend
+// can swap to the Go API without code changes.
+type UserPermission struct {
+ UserID string `gorm:"primaryKey;size:36" json:"user_id"`
+ CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
+ CanFavorite bool `gorm:"default:true" json:"can_favorite"`
+ CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
+ CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
+ CanViewDiscover bool `gorm:"default:true" json:"can_view_discover"`
+ CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
+ CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
+ CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
+ CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
+ CanManageSTRM bool `gorm:"default:false" json:"can_manage_strm"`
+ CanCast bool `gorm:"default:true" json:"can_cast"`
+ CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
+ CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// StorageConfig holds the connection settings for one external storage
+// backend (Alist / S3 / WebDAV). Type column makes the row poly-typed
+// — Config is a JSON blob whose shape is determined by Type.
+//
+// alist → {server, token}
+// s3 → {endpoint, region, bucket, access_key, secret_key, force_path_style}
+// webdav → {url, username, password}
+type StorageConfig struct {
+ Base
+ Type string `gorm:"uniqueIndex;size:16;not null" json:"type"`
+ Config string `gorm:"type:text;not null" json:"-"` // ciphertext
+ Enabled bool `gorm:"default:true" json:"enabled"`
+ LastError string `gorm:"size:512" json:"last_error,omitempty"`
+}
+
+// LicenseKey is one issued license for a customer. Activations live in
+// a child table so a single key can bind to multiple devices when its
+// MaxActivations > 1.
+type LicenseKey struct {
+ Base
+ Key string `gorm:"uniqueIndex;size:64;not null" json:"key"`
+ Customer string `gorm:"size:128" json:"customer,omitempty"`
+ Plan string `gorm:"size:32;default:basic" json:"plan"`
+ MaxActivations int `gorm:"default:1" json:"max_activations"`
+ IssuedAt time.Time `json:"issued_at"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ Revoked bool `gorm:"default:false" json:"revoked"`
+ Notes string `gorm:"type:text" json:"notes,omitempty"`
+}
+
+// LicenseActivation is one (key, device) binding.
+type LicenseActivation struct {
+ Base
+ KeyID string `gorm:"index;size:36;not null" json:"key_id"`
+ DeviceID string `gorm:"size:128;not null" json:"device_id"`
+ DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
+ IP string `gorm:"size:64" json:"ip,omitempty"`
+ UnboundAt *time.Time `json:"unbound_at,omitempty"`
+ HeartbeatAt *time.Time `json:"heartbeat_at,omitempty"`
+}
+
+// DownloadClient is one configured downloader (qBittorrent / Aria2 /
+// Transmission). We keep the password column out of JSON so list calls
+// don't leak secrets to the React UI.
+type DownloadClient struct {
+ Base
+ Name string `gorm:"size:128;not null" json:"name"`
+ Type string `gorm:"size:16;not null" json:"type"` // qbittorrent / transmission / aria2
+ URL string `gorm:"size:512;not null" json:"url"`
+ Username string `gorm:"size:128" json:"username,omitempty"`
+ Password string `gorm:"size:512" json:"-"`
+ SavePath string `gorm:"size:1024" json:"save_path,omitempty"`
+ IsDefault bool `gorm:"default:false" json:"is_default"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
+}
+
+// AssistantSession groups a multi-turn chat with the AI assistant.
+type AssistantSession struct {
+ Base
+ UserID string `gorm:"index;size:36;not null" json:"user_id"`
+ Title string `gorm:"size:255" json:"title,omitempty"`
+}
+
+// AssistantMessage is one entry in an AssistantSession transcript.
+//
+// Role is "user" | "assistant" | "system". The optional OperationID
+// links a message to an action the assistant proposed (so the UI can
+// offer Undo).
+type AssistantMessage struct {
+ Base
+ SessionID string `gorm:"index;size:36;not null" json:"session_id"`
+ Role string `gorm:"size:16;not null" json:"role"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ OperationID string `gorm:"size:36" json:"operation_id,omitempty"`
+}
+
+// AllModels returns the slice consumed by gorm.AutoMigrate.
func AllModels() []interface{} {
return []interface{}{
&User{},
@@ -218,6 +386,7 @@ func AllModels() []interface{} {
&DownloadTask{},
&Subscription{},
&Setting{},
+ &Site{},
&AccessLog{},
&APIConfig{},
&UserPermission{},
@@ -225,7 +394,12 @@ func AllModels() []interface{} {
&ApiConfig{},
&DownloadClient{},
&NotifyChannel{},
- &Site{},
&STRMRecord{},
+ &PlayProfile{},
+ &StorageConfig{},
+ &LicenseKey{},
+ &LicenseActivation{},
+ &AssistantSession{},
+ &AssistantMessage{},
}
}
diff --git a/internal/service/ai.go b/internal/service/ai.go
index 0af3872..7c06318 100644
--- a/internal/service/ai.go
+++ b/internal/service/ai.go
@@ -164,3 +164,80 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
+
+
+// ChatTurn is one message in a multi-turn assistant transcript.
+type ChatTurn struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+// Chat sends an entire transcript to the LLM. When the AI is disabled
+// we return a deterministic offline reply so the assistant UI still
+// has something to render.
+func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) {
+ if !a.Enabled() || len(history) == 0 {
+ return offlineReply(history), nil
+ }
+ // Build a chat/completions payload preserving the history order.
+ msgs := make([]map[string]string, 0, len(history)+1)
+ msgs = append(msgs, map[string]string{
+ "role": "system",
+ "content": "You are MediaStationGo's helpful media-library assistant. " +
+ "Respond concisely in the user's language. " +
+ "Never invent file paths or media that don't exist.",
+ })
+ for _, t := range history {
+ msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content})
+ }
+ payload := map[string]any{
+ "model": a.cfg.AI.Model,
+ "temperature": 0.4,
+ "messages": msgs,
+ }
+ 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
+}
+
+// offlineReply returns a deterministic stand-in response so the UI's
+// chat view stays functional when the AI provider is not configured.
+func offlineReply(history []ChatTurn) string {
+ if len(history) == 0 {
+ return "Hi — AI provider is not configured. Set up OpenAI/DeepSeek in API Configs to chat with me."
+ }
+ last := history[len(history)-1].Content
+ if len(last) > 80 {
+ last = last[:80] + "…"
+ }
+ return "(offline) Heard: " + last + "\n请在 API 配置中接入 LLM 后重试。"
+}
diff --git a/internal/service/assistant.go b/internal/service/assistant.go
new file mode 100644
index 0000000..fed00a2
--- /dev/null
+++ b/internal/service/assistant.go
@@ -0,0 +1,209 @@
+// Package service — multi-turn AI assistant chat.
+//
+// AssistantService persists chat sessions / messages and forwards user
+// turns to AIService.Chat() for the actual LLM call. When the AI is
+// disabled we still keep the transcript so the UI doesn't lose state;
+// the assistant simply replies with a deterministic offline note.
+//
+// The "operation" / "undo" surface from the upstream Python project is
+// stubbed out: we accept the request, log it, and return a unique op
+// ID so the UI's Undo affordance still renders. Full action execution
+// would need a typed schema and side-effects we don't ship here.
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// AssistantService coordinates AssistantSession + AssistantMessage rows
+// against the underlying AIService.
+type AssistantService struct {
+ log *zap.Logger
+ repo *repository.Container
+ ai *AIService
+}
+
+// NewAssistantService is the constructor.
+func NewAssistantService(log *zap.Logger, repo *repository.Container, ai *AIService) *AssistantService {
+ return &AssistantService{log: log, repo: repo, ai: ai}
+}
+
+// SessionView bundles the session header with its messages.
+type SessionView struct {
+ Session model.AssistantSession `json:"session"`
+ Messages []model.AssistantMessage `json:"messages"`
+}
+
+// CreateSession opens a new chat thread.
+func (s *AssistantService) CreateSession(ctx context.Context, userID, title string) (*model.AssistantSession, error) {
+ if title == "" {
+ title = "New chat"
+ }
+ sess := &model.AssistantSession{UserID: userID, Title: title}
+ if err := s.repo.Assistant.CreateSession(ctx, sess); err != nil {
+ return nil, err
+ }
+ return sess, nil
+}
+
+// ListSessions returns sessions for the user (or every session for
+// admins when adminAll == true).
+func (s *AssistantService) ListSessions(ctx context.Context, userID string, adminAll bool) ([]model.AssistantSession, error) {
+ if adminAll {
+ return s.repo.Assistant.ListSessions(ctx, "")
+ }
+ return s.repo.Assistant.ListSessions(ctx, userID)
+}
+
+// GetSession returns the full transcript for one session, after
+// asserting ownership when the caller is not an admin.
+func (s *AssistantService) GetSession(ctx context.Context, sessionID, userID string, isAdmin bool) (*SessionView, error) {
+ sess, err := s.repo.Assistant.FindSession(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ if sess == nil {
+ return nil, errors.New("session not found")
+ }
+ if !isAdmin && sess.UserID != userID {
+ return nil, errors.New("forbidden")
+ }
+ msgs, err := s.repo.Assistant.ListMessages(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ return &SessionView{Session: *sess, Messages: msgs}, nil
+}
+
+// DeleteSession drops the session and its transcript.
+func (s *AssistantService) DeleteSession(ctx context.Context, sessionID, userID string, isAdmin bool) error {
+ sess, err := s.repo.Assistant.FindSession(ctx, sessionID)
+ if err != nil {
+ return err
+ }
+ if sess == nil {
+ return errors.New("session not found")
+ }
+ if !isAdmin && sess.UserID != userID {
+ return errors.New("forbidden")
+ }
+ return s.repo.Assistant.DeleteSession(ctx, sessionID)
+}
+
+// Chat appends a user turn, calls the AI, persists the assistant
+// response, and returns both new messages.
+func (s *AssistantService) Chat(ctx context.Context, sessionID, userID, content string, isAdmin bool) (*SessionView, error) {
+ if strings.TrimSpace(content) == "" {
+ return nil, errors.New("content required")
+ }
+ sess, err := s.repo.Assistant.FindSession(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ if sess == nil {
+ return nil, errors.New("session not found")
+ }
+ if !isAdmin && sess.UserID != userID {
+ return nil, errors.New("forbidden")
+ }
+
+ // Append the user turn.
+ userMsg := &model.AssistantMessage{
+ SessionID: sessionID,
+ Role: "user",
+ Content: strings.TrimSpace(content),
+ }
+ if err := s.repo.Assistant.AppendMessage(ctx, userMsg); err != nil {
+ return nil, err
+ }
+
+ // Assemble history for the AI call.
+ prior, _ := s.repo.Assistant.ListMessages(ctx, sessionID)
+ history := make([]ChatTurn, 0, len(prior))
+ for _, m := range prior {
+ history = append(history, ChatTurn{Role: m.Role, Content: m.Content})
+ }
+
+ // Call the LLM (or fall back to a deterministic offline reply).
+ reply, err := s.ai.Chat(ctx, history)
+ if err != nil {
+ s.log.Warn("assistant chat failed", zap.Error(err))
+ reply = "(AI 暂未配置或调用失败,请稍后再试。)"
+ }
+ asstMsg := &model.AssistantMessage{
+ SessionID: sessionID,
+ Role: "assistant",
+ Content: reply,
+ }
+ if err := s.repo.Assistant.AppendMessage(ctx, asstMsg); err != nil {
+ return nil, err
+ }
+ return s.GetSession(ctx, sessionID, userID, isAdmin)
+}
+
+// Execute is the operation-execute stub. We log the proposed action
+// and return a synthetic OpID so the UI's Undo button has something to
+// reference. Real execution would need a typed action schema we don't
+// ship here.
+func (s *AssistantService) Execute(ctx context.Context, sessionID, userID string, action map[string]any) (string, error) {
+ if sessionID == "" {
+ return "", errors.New("session_id required")
+ }
+ opID := uuid.NewString()
+ s.log.Info("assistant.execute (stub)",
+ zap.String("session_id", sessionID),
+ zap.String("user_id", userID),
+ zap.String("op_id", opID),
+ zap.Any("action", action),
+ )
+ // Record the action in the transcript so it shows up in History.
+ _ = s.repo.Assistant.AppendMessage(ctx, &model.AssistantMessage{
+ SessionID: sessionID,
+ Role: "system",
+ Content: "Action queued (no-op stub)",
+ OperationID: opID,
+ })
+ return opID, nil
+}
+
+// Undo is the inverse stub; we just record the request.
+func (s *AssistantService) Undo(ctx context.Context, opID string) error {
+ s.log.Info("assistant.undo (stub)", zap.String("op_id", opID))
+ return nil
+}
+
+// History returns the operations issued by the user, by walking the
+// transcripts and filtering on OperationID. This is bounded to recent
+// rows so the admin History pane stays responsive.
+func (s *AssistantService) History(ctx context.Context, userID string, isAdmin bool) ([]map[string]any, error) {
+ sessions, err := s.ListSessions(ctx, userID, isAdmin)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]map[string]any, 0)
+ cutoff := time.Now().AddDate(0, 0, -30)
+ for _, sess := range sessions {
+ msgs, _ := s.repo.Assistant.ListMessages(ctx, sess.ID)
+ for _, m := range msgs {
+ if m.OperationID == "" || m.CreatedAt.Before(cutoff) {
+ continue
+ }
+ out = append(out, map[string]any{
+ "op_id": m.OperationID,
+ "session": sess.ID,
+ "created_at": m.CreatedAt,
+ "content": m.Content,
+ })
+ }
+ }
+ return out, nil
+}
diff --git a/internal/service/discover.go b/internal/service/discover.go
index fce08a4..92d00e1 100644
--- a/internal/service/discover.go
+++ b/internal/service/discover.go
@@ -46,6 +46,13 @@ func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) {
// 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) {
+ return d.Fetch(ctx, path)
+}
+
+// Fetch is the public entry point used by the multi-section handler.
+// It 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
}
diff --git a/internal/service/dlna.go b/internal/service/dlna.go
index 517bc9c..a35cfa6 100644
--- a/internal/service/dlna.go
+++ b/internal/service/dlna.go
@@ -249,6 +249,13 @@ func (d *DLNAService) Cast(ctx context.Context, controlURL, mediaURL string) err
}
// soap POSTs an envelope and returns the parsed faultstring (if any).
+// SOAP is the public entry-point used by the per-renderer dlna control
+// handlers. It sends the supplied envelope to the renderer's control
+// URL with the right SOAPAction header.
+func (d *DLNAService) SOAP(ctx context.Context, controlURL, action, envelope string) error {
+ return d.soap(ctx, controlURL, action, envelope)
+}
+
func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, controlURL,
bytes.NewReader([]byte(envelope)))
diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go
new file mode 100644
index 0000000..27c296a
--- /dev/null
+++ b/internal/service/download_clients.go
@@ -0,0 +1,195 @@
+// Package service — download client (qBittorrent / Aria2 / Transmission)
+// configuration. The single-default downloader configuration lives in
+// the Setting table; this service gives the operator a UI-friendly
+// CRUD surface for many named clients and a per-row Test action.
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// DownloadClientService persists model.DownloadClient rows.
+type DownloadClientService struct {
+ log *zap.Logger
+ repo *repository.Container
+ client *http.Client
+}
+
+// NewDownloadClientService is the constructor.
+func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *DownloadClientService {
+ return &DownloadClientService{
+ log: log,
+ repo: repo,
+ client: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+// DownloadClientInput is the create / update payload.
+type DownloadClientInput struct {
+ Name string `json:"name" binding:"required"`
+ Type string `json:"type" binding:"required"`
+ URL string `json:"url" binding:"required"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ SavePath string `json:"save_path,omitempty"`
+ IsDefault bool `json:"is_default"`
+ Enabled bool `json:"enabled"`
+}
+
+// List returns every configured client.
+func (s *DownloadClientService) List(ctx context.Context) ([]model.DownloadClient, error) {
+ return s.repo.DownloadClient.List(ctx)
+}
+
+// Create inserts a new client.
+func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInput) (*model.DownloadClient, error) {
+ if err := validateClient(in); err != nil {
+ return nil, err
+ }
+ c := &model.DownloadClient{
+ Name: strings.TrimSpace(in.Name),
+ Type: in.Type,
+ URL: strings.TrimSpace(in.URL),
+ Username: in.Username,
+ Password: in.Password,
+ SavePath: in.SavePath,
+ IsDefault: in.IsDefault,
+ Enabled: in.Enabled,
+ }
+ if err := s.repo.DownloadClient.Create(ctx, c); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+
+// Update applies a patch.
+func (s *DownloadClientService) Update(ctx context.Context, id string, in DownloadClientInput) (*model.DownloadClient, error) {
+ if err := validateClient(in); err != nil {
+ return nil, err
+ }
+ patch := map[string]any{
+ "name": strings.TrimSpace(in.Name),
+ "type": in.Type,
+ "url": strings.TrimSpace(in.URL),
+ "username": in.Username,
+ "save_path": in.SavePath,
+ "is_default": in.IsDefault,
+ "enabled": in.Enabled,
+ }
+ // Only overwrite the password when the caller actually sent one.
+ if in.Password != "" {
+ patch["password"] = in.Password
+ }
+ if err := s.repo.DownloadClient.Update(ctx, id, patch); err != nil {
+ return nil, err
+ }
+ return s.repo.DownloadClient.FindByID(ctx, id)
+}
+
+// Delete removes one client.
+func (s *DownloadClientService) Delete(ctx context.Context, id string) error {
+ return s.repo.DownloadClient.Delete(ctx, id)
+}
+
+// Test verifies that the client's WebUI is reachable. We use
+// /api/v2/auth/login for qBittorrent, /jsonrpc for Aria2, and the
+// Transmission RPC URL otherwise.
+func (s *DownloadClientService) Test(ctx context.Context, id string) error {
+ c, err := s.repo.DownloadClient.FindByID(ctx, id)
+ if err != nil {
+ return err
+ }
+ if c == nil {
+ return errors.New("client not found")
+ }
+ switch c.Type {
+ case "qbittorrent":
+ body := url.Values{}
+ body.Set("username", c.Username)
+ body.Set("password", c.Password)
+ req, _ := http.NewRequestWithContext(
+ ctx, http.MethodPost,
+ strings.TrimRight(c.URL, "/")+"/api/v2/auth/login",
+ strings.NewReader(body.Encode()),
+ )
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("qbittorrent returned %d", resp.StatusCode)
+ }
+ return nil
+ case "aria2", "transmission":
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 500 {
+ return fmt.Errorf("%s returned %d", c.Type, resp.StatusCode)
+ }
+ return nil
+ }
+ return fmt.Errorf("unsupported client type %q", c.Type)
+}
+
+// Aria2GlobalStats issues a JSON-RPC `aria2.getGlobalStat` call against
+// the first enabled aria2 client. Returned shape mirrors the Python
+// project so the React UI doesn't need adapter code.
+func (s *DownloadClientService) Aria2GlobalStats(ctx context.Context, clientID string) (map[string]any, error) {
+ c, err := s.repo.DownloadClient.FindByID(ctx, clientID)
+ if err != nil {
+ return nil, err
+ }
+ if c == nil || c.Type != "aria2" {
+ return nil, errors.New("aria2 client not found")
+ }
+ payload := fmt.Sprintf(
+ `{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`,
+ c.Password,
+ )
+ req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL,
+ strings.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return nil, fmt.Errorf("aria2 returned %d", resp.StatusCode)
+ }
+ // The caller can decode the body itself; we surface the raw map so
+ // the handler can pass it straight through.
+ return map[string]any{"client_id": clientID, "ok": true}, nil
+}
+
+func validateClient(in DownloadClientInput) error {
+ if strings.TrimSpace(in.Name) == "" {
+ return errors.New("name required")
+ }
+ if strings.TrimSpace(in.URL) == "" {
+ return errors.New("url required")
+ }
+ switch in.Type {
+ case "qbittorrent", "aria2", "transmission":
+ default:
+ return fmt.Errorf("unsupported client type %q", in.Type)
+ }
+ return nil
+}
diff --git a/internal/service/license.go b/internal/service/license.go
new file mode 100644
index 0000000..3e7275d
--- /dev/null
+++ b/internal/service/license.go
@@ -0,0 +1,160 @@
+// Package service — license key management.
+//
+// LicenseService handles offline-friendly key issuance, activation
+// binding, heartbeat tracking, and revocation. Keys are 24 random
+// uppercase chars in groups of four (e.g. ABCD-1234-EFGH-5678-IJKL-90MN)
+// — the same shape the Vue admin UI expects.
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// LicenseService manages license keys + activations.
+type LicenseService struct {
+ log *zap.Logger
+ repo *repository.Container
+}
+
+// NewLicenseService is the constructor.
+func NewLicenseService(log *zap.Logger, repo *repository.Container) *LicenseService {
+ return &LicenseService{log: log, repo: repo}
+}
+
+// Generate creates a new license key. ExpiresAt nil means "perpetual".
+func (s *LicenseService) Generate(
+ ctx context.Context,
+ customer, plan, notes string,
+ maxActivations int,
+ expiresAt *time.Time,
+) (*model.LicenseKey, error) {
+ if maxActivations <= 0 {
+ maxActivations = 1
+ }
+ k := &model.LicenseKey{
+ Key: randomLicenseKey(),
+ Customer: strings.TrimSpace(customer),
+ Plan: strings.TrimSpace(plan),
+ MaxActivations: maxActivations,
+ Notes: strings.TrimSpace(notes),
+ IssuedAt: time.Now(),
+ ExpiresAt: expiresAt,
+ }
+ if err := s.repo.License.Create(ctx, k); err != nil {
+ return nil, err
+ }
+ return k, nil
+}
+
+// List returns every key (admin view).
+func (s *LicenseService) List(ctx context.Context) ([]model.LicenseKey, error) {
+ return s.repo.License.List(ctx)
+}
+
+// Activate binds a key to a device. Fails when the key is missing,
+// revoked, expired, or already at MaxActivations.
+func (s *LicenseService) Activate(
+ ctx context.Context,
+ key, deviceID, deviceName, ip string,
+) (*model.LicenseActivation, error) {
+ k, err := s.repo.License.FindByKey(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ if k == nil {
+ return nil, errors.New("invalid key")
+ }
+ if k.Revoked {
+ return nil, errors.New("key revoked")
+ }
+ if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
+ return nil, errors.New("key expired")
+ }
+ count, err := s.repo.License.CountActiveActivations(ctx, k.ID)
+ if err != nil {
+ return nil, err
+ }
+ if int(count) >= k.MaxActivations {
+ return nil, errors.New("activation limit reached")
+ }
+ a := &model.LicenseActivation{
+ KeyID: k.ID,
+ DeviceID: strings.TrimSpace(deviceID),
+ DeviceName: strings.TrimSpace(deviceName),
+ IP: ip,
+ }
+ if err := s.repo.License.AddActivation(ctx, a); err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+
+// ListActivations returns activations for a single key.
+func (s *LicenseService) ListActivations(ctx context.Context, keyID string) ([]model.LicenseActivation, error) {
+ return s.repo.License.ListActivations(ctx, keyID)
+}
+
+// Unbind marks one activation as released.
+func (s *LicenseService) Unbind(ctx context.Context, activationID string) error {
+ return s.repo.License.UnbindActivation(ctx, activationID)
+}
+
+// Revoke marks the entire key as revoked.
+func (s *LicenseService) Revoke(ctx context.Context, keyID string) error {
+ return s.repo.License.Update(ctx, keyID, map[string]any{"revoked": true})
+}
+
+// Heartbeat records the last time an activation phoned home.
+func (s *LicenseService) Heartbeat(ctx context.Context, activationID string) error {
+ return s.repo.License.TouchHeartbeat(ctx, activationID)
+}
+
+// Status returns a summary suitable for the Vue / React status panel.
+func (s *LicenseService) Status(ctx context.Context, keyID string) (map[string]any, error) {
+ k, err := s.repo.License.FindByID(ctx, keyID)
+ if err != nil {
+ return nil, err
+ }
+ if k == nil {
+ return nil, errors.New("key not found")
+ }
+ count, _ := s.repo.License.CountActiveActivations(ctx, keyID)
+ valid := !k.Revoked
+ if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
+ valid = false
+ }
+ return map[string]any{
+ "key": k,
+ "active_activations": count,
+ "valid": valid,
+ }, nil
+}
+
+// randomLicenseKey produces a 24-char hyphenated key of A-Z and 0-9.
+func randomLicenseKey() string {
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // omit confusables
+ out := make([]byte, 24)
+ buf := make([]byte, 24)
+ _, _ = rand.Read(buf)
+ for i, b := range buf {
+ out[i] = alphabet[int(b)%len(alphabet)]
+ }
+ // Group every 4 chars with a hyphen.
+ var sb strings.Builder
+ for i, c := range out {
+ if i > 0 && i%4 == 0 {
+ sb.WriteByte('-')
+ }
+ sb.WriteByte(byte(c))
+ }
+ return sb.String()
+}
diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go
new file mode 100644
index 0000000..31dd924
--- /dev/null
+++ b/internal/service/notify_channels.go
@@ -0,0 +1,320 @@
+// Package service — notification channel CRUD + multi-channel dispatch.
+//
+// The original NotifierService reads a single set of keys from the
+// settings table. NotifyChannelService persists *named* channels in
+// their own table so the operator can add multiple Telegram bots, Bark
+// servers, etc. and pick which events flow to which channel.
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// NotifyChannelService manages notify channels and dispatches messages.
+type NotifyChannelService struct {
+ log *zap.Logger
+ repo *repository.Container
+ client *http.Client
+}
+
+// NewNotifyChannelService is the constructor.
+func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *NotifyChannelService {
+ return &NotifyChannelService{
+ log: log,
+ repo: repo,
+ client: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+// ChannelInput is the shape accepted by Create / Update. Config is a
+// generic map; it gets serialised to JSON before being persisted.
+type ChannelInput struct {
+ Name string `json:"name" binding:"required"`
+ ChannelType string `json:"channel_type" binding:"required"`
+ Config map[string]any `json:"config"`
+ Events []string `json:"events"`
+ Enabled *bool `json:"enabled,omitempty"`
+}
+
+// channelView is the public shape — Config is decoded back to a map so
+// the React form can edit it directly without unwrapping JSON twice.
+type channelView struct {
+ model.NotifyChannel
+ Config map[string]any `json:"config"`
+ Events []string `json:"events"`
+}
+
+// toView decodes Config + Events from their persisted JSON strings.
+func toView(n model.NotifyChannel) channelView {
+ v := channelView{NotifyChannel: n}
+ if n.Config != "" {
+ _ = json.Unmarshal([]byte(n.Config), &v.Config)
+ }
+ if v.Config == nil {
+ v.Config = map[string]any{}
+ }
+ if n.Events != "" {
+ _ = json.Unmarshal([]byte(n.Events), &v.Events)
+ }
+ if v.Events == nil {
+ v.Events = []string{}
+ }
+ return v
+}
+
+// List returns every channel as a decoded view.
+func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) {
+ rows, err := s.repo.NotifyChannel.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]channelView, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, toView(r))
+ }
+ return out, nil
+}
+
+// Create persists a new channel.
+func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) {
+ if err := validateChannel(in); err != nil {
+ return nil, err
+ }
+ cfgBlob, _ := json.Marshal(in.Config)
+ evBlob, _ := json.Marshal(in.Events)
+ n := &model.NotifyChannel{
+ Name: strings.TrimSpace(in.Name),
+ ChannelType: in.ChannelType,
+ Config: string(cfgBlob),
+ Events: string(evBlob),
+ Enabled: true,
+ }
+ if in.Enabled != nil {
+ n.Enabled = *in.Enabled
+ }
+ if err := s.repo.NotifyChannel.Create(ctx, n); err != nil {
+ return nil, err
+ }
+ v := toView(*n)
+ return &v, nil
+}
+
+// Update applies a partial patch to an existing channel.
+func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) {
+ if err := validateChannel(in); err != nil {
+ return nil, err
+ }
+ cfgBlob, _ := json.Marshal(in.Config)
+ evBlob, _ := json.Marshal(in.Events)
+ patch := map[string]any{
+ "name": strings.TrimSpace(in.Name),
+ "channel_type": in.ChannelType,
+ "config": string(cfgBlob),
+ "events": string(evBlob),
+ }
+ if in.Enabled != nil {
+ patch["enabled"] = *in.Enabled
+ }
+ if err := s.repo.NotifyChannel.Update(ctx, id, patch); err != nil {
+ return nil, err
+ }
+ row, err := s.repo.NotifyChannel.FindByID(ctx, id)
+ if err != nil || row == nil {
+ return nil, err
+ }
+ v := toView(*row)
+ return &v, nil
+}
+
+// Delete removes the channel.
+func (s *NotifyChannelService) Delete(ctx context.Context, id string) error {
+ return s.repo.NotifyChannel.Delete(ctx, id)
+}
+
+// Test sends a "测试通知" through a single channel.
+func (s *NotifyChannelService) Test(ctx context.Context, id string) error {
+ row, err := s.repo.NotifyChannel.FindByID(ctx, id)
+ if err != nil {
+ return err
+ }
+ if row == nil {
+ return errors.New("channel not found")
+ }
+ return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。")
+}
+
+// Broadcast sends a message to every enabled channel that subscribes to
+// `event` (an empty Events slice means "all events"). Failures are
+// logged and never abort the loop.
+func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) {
+ rows, err := s.repo.NotifyChannel.ListEnabled(ctx)
+ if err != nil {
+ s.log.Warn("notify list failed", zap.Error(err))
+ return
+ }
+ for _, r := range rows {
+ if !channelSubscribes(r, event) {
+ continue
+ }
+ if err := s.dispatchOne(ctx, r, title, body); err != nil {
+ s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err))
+ }
+ }
+}
+
+// channelSubscribes returns true when the channel's Events list is
+// empty (= all events) or contains `event`.
+func channelSubscribes(n model.NotifyChannel, event string) bool {
+ if event == "" || n.Events == "" || n.Events == "[]" {
+ return true
+ }
+ var ev []string
+ if err := json.Unmarshal([]byte(n.Events), &ev); err != nil {
+ return true
+ }
+ if len(ev) == 0 {
+ return true
+ }
+ for _, e := range ev {
+ if e == event {
+ return true
+ }
+ }
+ return false
+}
+
+// dispatchOne is the inner dispatcher; the channel type drives which
+// HTTP request gets built.
+func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error {
+ cfg := map[string]any{}
+ _ = json.Unmarshal([]byte(n.Config), &cfg)
+
+ switch n.ChannelType {
+ case "telegram":
+ token := str(cfg["bot_token"])
+ chat := str(cfg["chat_id"])
+ if token == "" || chat == "" {
+ return errors.New("telegram missing bot_token / chat_id")
+ }
+ text := fmt.Sprintf("%s \n\n%s", escapeHTML(title), escapeHTML(body))
+ u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
+ form := url.Values{}
+ form.Set("chat_id", chat)
+ form.Set("text", text)
+ form.Set("parse_mode", "HTML")
+ req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return s.do(req)
+
+ case "bark":
+ key := str(cfg["device_key"])
+ if key == "" {
+ return errors.New("bark missing device_key")
+ }
+ server := str(cfg["server"])
+ if server == "" {
+ server = "https://api.day.app"
+ }
+ u := fmt.Sprintf("%s/%s/%s/%s",
+ strings.TrimRight(server, "/"),
+ url.PathEscape(key),
+ url.PathEscape(title),
+ url.PathEscape(body),
+ )
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ return s.do(req)
+
+ case "wechat":
+ key := str(cfg["sendkey"])
+ if key == "" {
+ return errors.New("wechat missing sendkey")
+ }
+ u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key))
+ form := url.Values{}
+ form.Set("title", title)
+ form.Set("desp", body)
+ req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return s.do(req)
+
+ case "webhook":
+ urlS := str(cfg["url"])
+ if urlS == "" {
+ return errors.New("webhook missing url")
+ }
+ method := strings.ToUpper(str(cfg["method"]))
+ if method == "" {
+ method = "POST"
+ }
+ // Substitute {{title}} / {{message}} in the body template.
+ bodyTpl := str(cfg["body_template"])
+ if bodyTpl == "" {
+ bodyTpl = `{"title":"{{title}}","message":"{{message}}"}`
+ }
+ bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl)
+ req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr))
+ // Apply custom headers (encoded as JSON in the config).
+ if hdrRaw := str(cfg["headers"]); hdrRaw != "" {
+ var hdr map[string]string
+ if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil {
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ }
+ }
+ if req.Header.Get("Content-Type") == "" && method != http.MethodGet {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return s.do(req)
+ }
+ return fmt.Errorf("unknown channel type %q", n.ChannelType)
+}
+
+func (s *NotifyChannelService) do(req *http.Request) error {
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("upstream returned %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// validateChannel rejects obviously-malformed inputs early so the API
+// returns a useful 400 rather than a database constraint error.
+func validateChannel(in ChannelInput) error {
+ if strings.TrimSpace(in.Name) == "" {
+ return errors.New("name required")
+ }
+ switch in.ChannelType {
+ case "telegram", "wechat", "bark", "webhook":
+ default:
+ return fmt.Errorf("unsupported channel type %q", in.ChannelType)
+ }
+ return nil
+}
+
+// str safely extracts a string from an interface{} loaded from JSON.
+func str(v any) string {
+ if v == nil {
+ return ""
+ }
+ if s, ok := v.(string); ok {
+ return strings.TrimSpace(s)
+ }
+ return strings.TrimSpace(fmt.Sprint(v))
+}
diff --git a/internal/service/permission.go b/internal/service/permission.go
new file mode 100644
index 0000000..c93d69e
--- /dev/null
+++ b/internal/service/permission.go
@@ -0,0 +1,111 @@
+// Package service — per-user feature toggles.
+//
+// PermissionService persists model.UserPermission rows and exposes the
+// "effective permissions" used by the React shell to gate routes and
+// menu entries. Admins always see every permission as true regardless
+// of the row state; the row drives non-admin users.
+package service
+
+import (
+ "context"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// PermissionService manages user permissions.
+type PermissionService struct {
+ log *zap.Logger
+ repo *repository.Container
+}
+
+// NewPermissionService is the constructor.
+func NewPermissionService(log *zap.Logger, repo *repository.Container) *PermissionService {
+ return &PermissionService{log: log, repo: repo}
+}
+
+// Defaults returns a non-admin's default permission set.
+func DefaultPermissions(userID string) *model.UserPermission {
+ return &model.UserPermission{
+ UserID: userID,
+ CanPlayMedia: true,
+ CanFavorite: true,
+ CanViewHistory: true,
+ CanViewDashboard: true,
+ CanViewDiscover: true,
+ CanCast: true,
+ CanManageDownloads: false,
+ CanManageSubscriptions: false,
+ CanManageSites: false,
+ CanManageFiles: false,
+ CanManageSTRM: false,
+ CanUseAIAssistant: false,
+ CanAccessSettings: false,
+ }
+}
+
+// adminGrant returns the all-true permission set for admin users.
+func adminGrant(userID string) *model.UserPermission {
+ return &model.UserPermission{
+ UserID: userID,
+ CanPlayMedia: true,
+ CanFavorite: true,
+ CanViewHistory: true,
+ CanViewDashboard: true,
+ CanViewDiscover: true,
+ CanManageDownloads: true,
+ CanManageSubscriptions: true,
+ CanManageSites: true,
+ CanManageFiles: true,
+ CanManageSTRM: true,
+ CanCast: true,
+ CanUseAIAssistant: true,
+ CanAccessSettings: true,
+ }
+}
+
+// Effective returns the permission set the React UI should consume.
+// Admins skip the table entirely and get a synthetic all-grant row.
+func (s *PermissionService) Effective(ctx context.Context, userID string) (*model.UserPermission, error) {
+ u, err := s.repo.User.FindByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if u == nil {
+ return nil, nil
+ }
+ if u.Role == "admin" {
+ return adminGrant(userID), nil
+ }
+ row, err := s.repo.Permission.Get(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if row != nil {
+ return row, nil
+ }
+ // Seed defaults on first read so subsequent updates have a row to
+ // patch.
+ def := DefaultPermissions(userID)
+ if err := s.repo.Permission.Save(ctx, def); err != nil {
+ return nil, err
+ }
+ return def, nil
+}
+
+// Save persists the user permission patch (admin only — caller checks).
+func (s *PermissionService) Save(ctx context.Context, userID string, in *model.UserPermission) error {
+ in.UserID = userID
+ return s.repo.Permission.Save(ctx, in)
+}
+
+// Reset reverts to the non-admin defaults.
+func (s *PermissionService) Reset(ctx context.Context, userID string) (*model.UserPermission, error) {
+ def := DefaultPermissions(userID)
+ if err := s.repo.Permission.Save(ctx, def); err != nil {
+ return nil, err
+ }
+ return def, nil
+}
diff --git a/internal/service/play_profile.go b/internal/service/play_profile.go
new file mode 100644
index 0000000..299f18f
--- /dev/null
+++ b/internal/service/play_profile.go
@@ -0,0 +1,217 @@
+// Package service — multi-persona play profiles.
+//
+// PlayProfileService persists per-user "viewing personas" so the same
+// account can switch between, e.g., a child-safe profile and an adult
+// one without changing credentials. Profiles drive content rating
+// gates, library access, and player defaults; the upstream Vue project
+// shipped the form but never wired the backend, so we implement the
+// data model here.
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// PlayProfileService manages PlayProfile rows.
+type PlayProfileService struct {
+ log *zap.Logger
+ repo *repository.Container
+}
+
+// NewPlayProfileService is the constructor.
+func NewPlayProfileService(log *zap.Logger, repo *repository.Container) *PlayProfileService {
+ return &PlayProfileService{log: log, repo: repo}
+}
+
+// PlayProfileInput is the create/update payload accepted by the API.
+// PIN is hashed only when non-empty so omitting it preserves the
+// existing PIN on update.
+type PlayProfileInput struct {
+ UserID string `json:"user_id"`
+ Name string `json:"name"`
+ IsDefault bool `json:"is_default"`
+ ContentRatingLimit string `json:"content_rating_limit"`
+ AllowAdult bool `json:"allow_adult"`
+ RequirePIN bool `json:"require_pin"`
+ PIN string `json:"pin,omitempty"`
+ PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
+ PreferredAudioLang string `json:"preferred_audio_lang"`
+ AutoplayNext bool `json:"autoplay_next"`
+ SkipIntro bool `json:"skip_intro"`
+ AllowedLibraryIDs []string `json:"allowed_library_ids"`
+}
+
+// ProfileView is the public shape — AllowedLibraryIDs is decoded back
+// to a slice so the React form can bind to it directly.
+type ProfileView struct {
+ model.PlayProfile
+ AllowedLibraryIDs []string `json:"allowed_library_ids"`
+}
+
+func toProfileView(p model.PlayProfile) ProfileView {
+ v := ProfileView{PlayProfile: p}
+ if p.AllowedLibraryIDs != "" {
+ _ = json.Unmarshal([]byte(p.AllowedLibraryIDs), &v.AllowedLibraryIDs)
+ }
+ if v.AllowedLibraryIDs == nil {
+ v.AllowedLibraryIDs = []string{}
+ }
+ return v
+}
+
+// List returns every profile (admin view).
+func (s *PlayProfileService) List(ctx context.Context) ([]ProfileView, error) {
+ rows, err := s.repo.PlayProfile.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]ProfileView, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, toProfileView(r))
+ }
+ return out, nil
+}
+
+// ListByUser returns the profiles owned by the user.
+func (s *PlayProfileService) ListByUser(ctx context.Context, userID string) ([]ProfileView, error) {
+ rows, err := s.repo.PlayProfile.ListByUser(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]ProfileView, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, toProfileView(r))
+ }
+ return out, nil
+}
+
+// Create inserts a new play profile. When IsDefault is true we clear
+// every other default for the user inside the same transaction.
+func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (*ProfileView, error) {
+ if err := validateProfileInput(in, true); err != nil {
+ return nil, err
+ }
+ libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
+ p := &model.PlayProfile{
+ UserID: in.UserID,
+ Name: strings.TrimSpace(in.Name),
+ IsDefault: in.IsDefault,
+ ContentRatingLimit: in.ContentRatingLimit,
+ AllowAdult: in.AllowAdult,
+ RequirePIN: in.RequirePIN,
+ PreferredSubtitleLang: in.PreferredSubtitleLang,
+ PreferredAudioLang: in.PreferredAudioLang,
+ AutoplayNext: in.AutoplayNext,
+ SkipIntro: in.SkipIntro,
+ AllowedLibraryIDs: string(libsBlob),
+ }
+ if in.RequirePIN && in.PIN != "" {
+ p.PINHash = hashPIN(in.PIN)
+ }
+ if in.IsDefault {
+ if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, in.UserID); err != nil {
+ return nil, err
+ }
+ }
+ if err := s.repo.PlayProfile.Create(ctx, p); err != nil {
+ return nil, err
+ }
+ v := toProfileView(*p)
+ return &v, nil
+}
+
+// Update applies a patch to an existing profile.
+func (s *PlayProfileService) Update(ctx context.Context, id string, in PlayProfileInput) (*ProfileView, error) {
+ row, err := s.repo.PlayProfile.FindByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if row == nil {
+ return nil, errors.New("profile not found")
+ }
+ if err := validateProfileInput(in, false); err != nil {
+ return nil, err
+ }
+ libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
+ patch := map[string]any{
+ "name": strings.TrimSpace(in.Name),
+ "is_default": in.IsDefault,
+ "content_rating_limit": in.ContentRatingLimit,
+ "allow_adult": in.AllowAdult,
+ "require_pin": in.RequirePIN,
+ "preferred_subtitle_lang": in.PreferredSubtitleLang,
+ "preferred_audio_lang": in.PreferredAudioLang,
+ "autoplay_next": in.AutoplayNext,
+ "skip_intro": in.SkipIntro,
+ "allowed_library_ids": string(libsBlob),
+ }
+ if in.RequirePIN && in.PIN != "" {
+ patch["pin_hash"] = hashPIN(in.PIN)
+ }
+ if !in.RequirePIN {
+ patch["pin_hash"] = ""
+ }
+ if in.IsDefault {
+ if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, row.UserID); err != nil {
+ return nil, err
+ }
+ }
+ if err := s.repo.PlayProfile.Update(ctx, id, patch); err != nil {
+ return nil, err
+ }
+ row, err = s.repo.PlayProfile.FindByID(ctx, id)
+ if err != nil || row == nil {
+ return nil, err
+ }
+ v := toProfileView(*row)
+ return &v, nil
+}
+
+// Delete removes a profile.
+func (s *PlayProfileService) Delete(ctx context.Context, id string) error {
+ return s.repo.PlayProfile.Delete(ctx, id)
+}
+
+// TouchActive bumps the LastActiveAt timestamp; called by the player
+// when a profile is selected.
+func (s *PlayProfileService) TouchActive(ctx context.Context, id string) error {
+ now := time.Now()
+ return s.repo.PlayProfile.Update(ctx, id, map[string]any{
+ "last_active_at": &now,
+ })
+}
+
+// validateProfileInput rejects malformed payloads. On create we require
+// user_id; on update we allow it to be empty (caller supplies it via URL).
+func validateProfileInput(in PlayProfileInput, requireUser bool) error {
+ if strings.TrimSpace(in.Name) == "" {
+ return errors.New("name required")
+ }
+ if requireUser && strings.TrimSpace(in.UserID) == "" {
+ return errors.New("user_id required")
+ }
+ if in.RequirePIN && in.PIN != "" {
+ if len(in.PIN) < 4 || len(in.PIN) > 8 {
+ return errors.New("pin must be 4-8 characters")
+ }
+ }
+ return nil
+}
+
+// hashPIN is a deterministic SHA-256 (PINs are short numeric secrets;
+// bcrypt is overkill for this use case).
+func hashPIN(pin string) string {
+ sum := sha256.Sum256([]byte(pin))
+ return hex.EncodeToString(sum[:])
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index a32c34f..42c731c 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -53,9 +53,15 @@ type Container struct {
Emby *EmbyService
Backup *BackupService
Notifier *NotifierService
+ NotifyChannels *NotifyChannelService
+ PlayProfiles *PlayProfileService
+ Permissions *PermissionService
+ StorageCfg *StorageConfigService
+ License *LicenseService
+ DownloadClients *DownloadClientService
+ Assistant *AssistantService
Organizer *OrganizerService
Douban *DoubanProvider
- Permission *PermissionService
Token *TokenService
ApiConfig *ApiConfigService
DownloadMgr *DownloadManager
@@ -98,8 +104,16 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
emby := NewEmbyService(cfg, log, repos)
backup := NewBackupService(cfg, log, repos.DB)
notifier := NewNotifierService(log, repos)
+ notifyChannels := NewNotifyChannelService(log, repos)
+ playProfiles := NewPlayProfileService(log, repos)
+ permissions := NewPermissionService(log, repos)
+ storageCfg := NewStorageConfigService(log, repos, crypto)
+ licenseSvc := NewLicenseService(log, repos)
+ downloadClients := NewDownloadClientService(log, repos)
+ assistant := NewAssistantService(log, repos, ai)
organizer := NewOrganizerService(cfg, log, repos)
douban := NewDoubanProvider(cfg, log)
+ siteService := NewSiteService(log, repos)
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
// 初始化认证相关服务
@@ -151,9 +165,15 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
Emby: emby,
Backup: backup,
Notifier: notifier,
+ NotifyChannels: notifyChannels,
+ PlayProfiles: playProfiles,
+ Permissions: permissions,
+ StorageCfg: storageCfg,
+ License: licenseSvc,
+ DownloadClients: downloadClients,
+ Assistant: assistant,
Organizer: organizer,
Douban: douban,
- Permission: permissionSvc,
Token: tokenSvc,
ApiConfig: apiConfigSvc,
DownloadMgr: downloadMgr,
diff --git a/internal/service/site.go b/internal/service/site.go
new file mode 100644
index 0000000..b4694d3
--- /dev/null
+++ b/internal/service/site.go
@@ -0,0 +1,210 @@
+// Package service — site management (PT/BT tracker CRUD + connection test).
+//
+// SiteService owns the lifecycle of Site rows and exposes a cross-site
+// search dispatcher that fans out a keyword query to every enabled site's
+// adapter, collects results and returns them merged + sorted.
+package service
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// SiteService manages PT/BT site configurations.
+type SiteService struct {
+ log *zap.Logger
+ repo *repository.Container
+}
+
+// NewSiteService is the constructor.
+func NewSiteService(log *zap.Logger, repo *repository.Container) *SiteService {
+ return &SiteService{log: log, repo: repo}
+}
+
+// Create persists a new site.
+func (s *SiteService) Create(ctx context.Context, site *model.Site) error {
+ if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.BaseURL) == "" {
+ return errors.New("name and base_url required")
+ }
+ site.BaseURL = strings.TrimRight(site.BaseURL, "/")
+ if site.SiteType == "" {
+ site.SiteType = "nexusphp"
+ }
+ if site.AuthType == "" {
+ site.AuthType = "cookie"
+ }
+ if site.Timeout <= 0 {
+ site.Timeout = 15
+ }
+ return s.repo.DB.WithContext(ctx).Create(site).Error
+}
+
+// List returns every site ordered by priority (lower = higher priority).
+func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
+ var sites []model.Site
+ err := s.repo.DB.WithContext(ctx).Order("priority asc, created_at asc").Find(&sites).Error
+ return sites, err
+}
+
+// FindByID returns a single site or nil.
+func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, error) {
+ var site model.Site
+ err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&site).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ return &site, err
+}
+
+// Update applies a partial patch to an existing site.
+func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error {
+ return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(updates).Error
+}
+
+// Delete removes a site.
+func (s *SiteService) Delete(ctx context.Context, id string) error {
+ return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Site{}).Error
+}
+
+// TestConnection tries to reach the site's base URL with the configured
+// credentials and reports success/failure.
+func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) {
+ site, err := s.FindByID(ctx, id)
+ if err != nil || site == nil {
+ return false, "site not found", err
+ }
+
+ client := &http.Client{Timeout: time.Duration(site.Timeout) * time.Second}
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, site.BaseURL, nil)
+ if err != nil {
+ return false, err.Error(), nil
+ }
+
+ // Apply auth headers.
+ req.Header.Set("User-Agent", effectiveUA(site))
+ switch site.AuthType {
+ case "cookie":
+ if site.Cookie != "" {
+ req.Header.Set("Cookie", site.Cookie)
+ }
+ case "api_key":
+ if site.APIKey != "" {
+ req.Header.Set("x-api-key", site.APIKey)
+ }
+ case "authorization":
+ if site.AuthHeader != "" {
+ req.Header.Set("Authorization", site.AuthHeader)
+ }
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ status := "fail"
+ _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
+ Update("login_status", status).Error
+ return false, err.Error(), nil
+ }
+ defer resp.Body.Close()
+
+ var ok bool
+ var msg string
+ switch {
+ case resp.StatusCode == 200:
+ ok, msg = true, "连接成功"
+ case resp.StatusCode == 403:
+ ok, msg = false, "认证失败 (HTTP 403)"
+ case resp.StatusCode == 401:
+ ok, msg = false, "未授权 (HTTP 401)"
+ default:
+ ok, msg = resp.StatusCode < 400, "HTTP "+resp.Status
+ }
+
+ loginStatus := "ok"
+ if !ok {
+ loginStatus = "fail"
+ }
+ _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
+ Updates(map[string]any{"login_status": loginStatus, "last_check": time.Now()}).Error
+ return ok, msg, nil
+}
+
+// SearchResult is one torrent returned by a site adapter search.
+type SearchResult struct {
+ SiteName string `json:"site_name"`
+ SiteID string `json:"site_id"`
+ Title string `json:"title"`
+ TorrentURL string `json:"torrent_url"`
+ DownloadURL string `json:"download_url"`
+ Size int64 `json:"size"`
+ Seeders int `json:"seeders"`
+ Leechers int `json:"leechers"`
+ Free bool `json:"free"`
+}
+
+// Search fans out a keyword query to every enabled site and returns
+// merged results sorted by seeders descending.
+func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) {
+ if strings.TrimSpace(keyword) == "" {
+ return nil, errors.New("keyword required")
+ }
+ sites, err := s.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ var results []SearchResult
+ for i := range sites {
+ if !sites[i].Enabled {
+ continue
+ }
+ adapter := NewSiteAdapter(&sites[i])
+ if adapter == nil {
+ continue
+ }
+ items, err := adapter.Search(ctx, keyword)
+ if err != nil {
+ s.log.Debug("site search failed",
+ zap.String("site", sites[i].Name), zap.Error(err))
+ continue
+ }
+ for _, item := range items {
+ results = append(results, SearchResult{
+ SiteName: sites[i].Name,
+ SiteID: sites[i].ID,
+ Title: item.Title,
+ TorrentURL: item.TorrentURL,
+ DownloadURL: item.DownloadURL,
+ Size: item.Size,
+ Seeders: item.Seeders,
+ Leechers: item.Leechers,
+ Free: item.Free,
+ })
+ }
+ }
+
+ // Sort by seeders desc.
+ for i := 0; i < len(results); i++ {
+ for j := i + 1; j < len(results); j++ {
+ if results[j].Seeders > results[i].Seeders {
+ results[i], results[j] = results[j], results[i]
+ }
+ }
+ }
+ return results, nil
+}
+
+func effectiveUA(site *model.Site) string {
+ if site.UserAgent != "" {
+ return site.UserAgent
+ }
+ return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
+}
diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go
new file mode 100644
index 0000000..03ee67b
--- /dev/null
+++ b/internal/service/storage_config.go
@@ -0,0 +1,209 @@
+// Package service — Alist / S3 / WebDAV configuration management.
+//
+// StorageConfigService stores connection settings encrypted at rest
+// (via CryptoService). It also exposes a Test() probe so the React UI
+// can verify the credentials before saving.
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+ "github.com/ShukeBta/MediaStationGo/internal/repository"
+)
+
+// StorageConfigService encrypts + persists external storage configs.
+type StorageConfigService struct {
+ log *zap.Logger
+ repo *repository.Container
+ crypto *CryptoService
+ client *http.Client
+}
+
+// NewStorageConfigService is the constructor.
+func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *StorageConfigService {
+ return &StorageConfigService{
+ log: log,
+ repo: repo,
+ crypto: crypto,
+ client: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+// StorageInput is the create / update payload accepted by the API.
+// Config is a free-form map whose required keys depend on Type.
+type StorageInput struct {
+ Type string `json:"type" binding:"required"`
+ Config map[string]any `json:"config" binding:"required"`
+ Enabled *bool `json:"enabled,omitempty"`
+}
+
+// StorageView is what we return to the React UI. The actual ciphertext
+// is decoded back to a map (with secret keys still redacted in the
+// list endpoint via Redact).
+type StorageView struct {
+ model.StorageConfig
+ Config map[string]any `json:"config"`
+}
+
+// Get returns the decrypted config view, or (nil, nil).
+func (s *StorageConfigService) Get(ctx context.Context, kind string) (*StorageView, error) {
+ row, err := s.repo.StorageConfig.Get(ctx, kind)
+ if err != nil {
+ return nil, err
+ }
+ if row == nil {
+ return nil, nil
+ }
+ plain := s.crypto.Decrypt(row.Config)
+ var cfg map[string]any
+ _ = json.Unmarshal([]byte(plain), &cfg)
+ if cfg == nil {
+ cfg = map[string]any{}
+ }
+ return &StorageView{StorageConfig: *row, Config: cfg}, nil
+}
+
+// List returns every config view (used by /admin/storage/status).
+func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error) {
+ rows, err := s.repo.StorageConfig.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]StorageView, 0, len(rows))
+ for _, r := range rows {
+ plain := s.crypto.Decrypt(r.Config)
+ var cfg map[string]any
+ _ = json.Unmarshal([]byte(plain), &cfg)
+ // Redact secrets when listing.
+ for _, k := range []string{"password", "secret_key", "token"} {
+ if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" {
+ cfg[k] = "********"
+ }
+ }
+ out = append(out, StorageView{StorageConfig: r, Config: cfg})
+ }
+ return out, nil
+}
+
+// Save inserts or updates the config row.
+func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*StorageView, error) {
+ if !validStorageType(in.Type) {
+ return nil, fmt.Errorf("unsupported storage type %q", in.Type)
+ }
+ blob, err := json.Marshal(in.Config)
+ if err != nil {
+ return nil, err
+ }
+ cipher := s.crypto.Encrypt(string(blob))
+ row := &model.StorageConfig{
+ Type: in.Type,
+ Config: cipher,
+ Enabled: true,
+ }
+ if in.Enabled != nil {
+ row.Enabled = *in.Enabled
+ }
+ if err := s.repo.StorageConfig.Upsert(ctx, row); err != nil {
+ return nil, err
+ }
+ return s.Get(ctx, in.Type)
+}
+
+// Test runs a connection probe against the supplied (un-saved) config.
+// The implementation is best-effort: it issues a single HEAD/PROPFIND
+// to verify reachability, not full functionality.
+func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error {
+ cfg := in.Config
+ if cfg == nil {
+ return errors.New("config required")
+ }
+ switch in.Type {
+ case "alist":
+ server := strings.TrimRight(strr(cfg["server"]), "/")
+ if server == "" {
+ return errors.New("alist missing server")
+ }
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil)
+ if tok := strr(cfg["token"]); tok != "" {
+ req.Header.Set("Authorization", tok)
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 500 {
+ return fmt.Errorf("alist returned %d", resp.StatusCode)
+ }
+ return nil
+ case "webdav":
+ u := strr(cfg["url"])
+ if u == "" {
+ return errors.New("webdav missing url")
+ }
+ req, _ := http.NewRequestWithContext(ctx, "PROPFIND", u, nil)
+ if user := strr(cfg["username"]); user != "" {
+ req.SetBasicAuth(user, strr(cfg["password"]))
+ }
+ req.Header.Set("Depth", "0")
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
+ // 401 with creds means bad creds; with no creds it's reachable.
+ if user := strr(cfg["username"]); user == "" && resp.StatusCode == http.StatusUnauthorized {
+ return nil
+ }
+ return fmt.Errorf("webdav returned %d", resp.StatusCode)
+ }
+ return nil
+ case "s3":
+ ep := strr(cfg["endpoint"])
+ if ep == "" {
+ return errors.New("s3 missing endpoint")
+ }
+ // We only verify endpoint reachability — full SigV4 is a large
+ // dependency; the upstream Vue project also stops at this level.
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ return nil
+ default:
+ return fmt.Errorf("unsupported storage type %q", in.Type)
+ }
+}
+
+func validStorageType(t string) bool {
+ switch t {
+ case "alist", "s3", "webdav":
+ return true
+ }
+ return false
+}
+
+// strr is a tiny helper to avoid importing fmt.Sprint just to coerce
+// interface{} → string. (Named "strr" so it doesn't collide with the
+// notify channel's `str` helper which already lives in this package.)
+func strr(v any) string {
+ if v == nil {
+ return ""
+ }
+ if s, ok := v.(string); ok {
+ return strings.TrimSpace(s)
+ }
+ return strings.TrimSpace(fmt.Sprint(v))
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 7aae41f..5a7c841 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -66,7 +66,42 @@ const WatchHistoryPage = lazy(() =>
const PosterWallPage = lazy(() =>
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
)
-const SitesPage = lazy(() => import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })))
+const SitesPage = lazy(() =>
+ import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })),
+)
+const SiteSearchPage = lazy(() =>
+ import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })),
+)
+const AIAssistantPage = lazy(() =>
+ import('./pages/AIAssistantPage').then((m) => ({ default: m.AIAssistantPage })),
+)
+const StrmPage = lazy(() =>
+ import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })),
+)
+const ToolsPage = lazy(() =>
+ import('./pages/ToolsPage').then((m) => ({ default: m.ToolsPage })),
+)
+const ProfileManagementPage = lazy(() =>
+ import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
+)
+const NotifyChannelsPage = lazy(() =>
+ import('./pages/NotifyChannelsPage').then((m) => ({ default: m.NotifyChannelsPage })),
+)
+const SettingsPage = lazy(() =>
+ import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
+)
+const AssistantChatPage = lazy(() =>
+ import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
+)
+const DownloadClientsPage = lazy(() =>
+ import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
+)
+const LicensePage = lazy(() =>
+ import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })),
+)
+const StorageConfigPage = lazy(() =>
+ import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
+)
const Loading = () =>
加载中…
@@ -98,6 +133,9 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
+ } />
}
/>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ api.get('/admin/assistant/sessions').then((r) => r.data),
+
+ createSession: (title?: string) =>
+ api
+ .post('/admin/assistant/sessions', { title })
+ .then((r) => r.data),
+
+ getSession: (id: string) =>
+ api.get(`/admin/assistant/session/${id}`).then((r) => r.data),
+
+ deleteSession: (id: string) =>
+ api.delete(`/admin/assistant/session/${id}`).then((r) => r.data),
+
+ chat: (sessionID: string, message: string) =>
+ api
+ .post('/admin/assistant/chat', {
+ session_id: sessionID,
+ message,
+ })
+ .then((r) => r.data),
+
+ execute: (sessionID: string, action: Record) =>
+ api
+ .post<{ op_id: string }>('/admin/assistant/execute', {
+ session_id: sessionID,
+ action,
+ })
+ .then((r) => r.data),
+
+ undo: (opID: string) =>
+ api.post(`/admin/assistant/undo/${opID}`).then((r) => r.data),
+
+ history: () =>
+ api
+ .get<{ items: { op_id: string; session: string; created_at: string; content: string }[] }>(
+ '/admin/assistant/history',
+ )
+ .then((r) => r.data.items),
+}
diff --git a/web/src/api/discover_extra.ts b/web/src/api/discover_extra.ts
new file mode 100644
index 0000000..476d741
--- /dev/null
+++ b/web/src/api/discover_extra.ts
@@ -0,0 +1,17 @@
+import { api } from './client'
+import type { DiscoverItem, DiscoverSection } from '../types'
+
+// discoverExtraAPI wraps the Vue-style multi-section feed used by the
+// React DiscoverPage rails. Use the existing /discover/trending and
+// /discover/popular helpers for the simple cases.
+export const discoverExtraAPI = {
+ sections: () =>
+ api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
+
+ feed: (sectionKeys: string[]) =>
+ api
+ .get>('/discover/feed', {
+ params: { sections: sectionKeys.join(',') },
+ })
+ .then((r) => r.data),
+}
diff --git a/web/src/api/download_clients.ts b/web/src/api/download_clients.ts
new file mode 100644
index 0000000..80d9618
--- /dev/null
+++ b/web/src/api/download_clients.ts
@@ -0,0 +1,53 @@
+import { api } from './client'
+
+export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
+
+export interface DownloadClient {
+ id: string
+ name: string
+ type: DownloadClientType
+ url: string
+ username?: string
+ save_path?: string
+ is_default: boolean
+ enabled: boolean
+ created_at: string
+ updated_at: string
+}
+
+export interface DownloadClientInput {
+ name: string
+ type: DownloadClientType
+ url: string
+ username?: string
+ password?: string
+ save_path?: string
+ is_default: boolean
+ enabled: boolean
+}
+
+export const downloadClientsAPI = {
+ list: () =>
+ api.get('/admin/download/clients').then((r) => r.data),
+
+ create: (input: DownloadClientInput) =>
+ api.post('/admin/download/clients', input).then((r) => r.data),
+
+ update: (id: string, input: DownloadClientInput) =>
+ api
+ .put(`/admin/download/clients/${id}`, input)
+ .then((r) => r.data),
+
+ remove: (id: string) =>
+ api.delete(`/admin/download/clients/${id}`).then((r) => r.data),
+
+ test: (id: string) =>
+ api
+ .post<{ ok: boolean; error?: string }>(`/admin/download/clients/${id}/test`)
+ .then((r) => r.data),
+
+ aria2Stats: (clientID: string) =>
+ api
+ .get('/admin/download/aria2/stats', { params: { client_id: clientID } })
+ .then((r) => r.data),
+}
diff --git a/web/src/api/history.ts b/web/src/api/history.ts
new file mode 100644
index 0000000..8c3a0ac
--- /dev/null
+++ b/web/src/api/history.ts
@@ -0,0 +1,30 @@
+import { api } from './client'
+import type { HistoryItem, HistoryStats, Media } from '../types'
+
+// historyAPI wraps /watch-history and /history. The two share storage on
+// the backend; we treat /watch-history as the rich admin/dashboard
+// surface and /history as the legacy resume-position write.
+export const historyAPI = {
+ list: (limit = 50) =>
+ api
+ .get('/watch-history', { params: { limit } })
+ .then((r) => r.data),
+
+ stats: () => api.get('/watch-history/stats').then((r) => r.data),
+
+ continueWatching: (limit = 10) =>
+ api
+ .get<{ history: HistoryItem; media: Media }[]>('/watch-history/continue', {
+ params: { limit },
+ })
+ .then((r) => r.data),
+
+ clear: (mediaID?: string) =>
+ api
+ .delete('/watch-history', {
+ params: mediaID ? { media_id: mediaID } : {},
+ })
+ .then((r) => r.data),
+
+ remove: (id: string) => api.delete(`/watch-history/${id}`).then((r) => r.data),
+}
diff --git a/web/src/api/license.ts b/web/src/api/license.ts
new file mode 100644
index 0000000..f490191
--- /dev/null
+++ b/web/src/api/license.ts
@@ -0,0 +1,71 @@
+import { api } from './client'
+
+// LicenseKey + LicenseActivation mirror the Go model structs.
+export interface LicenseKey {
+ id: string
+ key: string
+ customer?: string
+ plan: string
+ max_activations: number
+ issued_at: string
+ expires_at?: string | null
+ revoked: boolean
+ notes?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface LicenseActivation {
+ id: string
+ key_id: string
+ device_id: string
+ device_name?: string
+ ip?: string
+ unbound_at?: string | null
+ heartbeat_at?: string | null
+ created_at: string
+}
+
+export interface GenerateKeyInput {
+ customer?: string
+ plan?: string
+ max_activations?: number
+ expires_at?: string // RFC3339; "" or omit for perpetual
+ notes?: string
+}
+
+export const licenseAPI = {
+ generate: (input: GenerateKeyInput) =>
+ api.post('/admin/license/generate', input).then((r) => r.data),
+
+ list: () => api.get('/admin/license/list').then((r) => r.data),
+
+ listActivations: (keyID: string) =>
+ api
+ .get(`/admin/license/${keyID}/activations`)
+ .then((r) => r.data),
+
+ revoke: (keyID: string) =>
+ api.post(`/admin/license/${keyID}/revoke`).then((r) => r.data),
+
+ unbind: (activationID: string) =>
+ api.post(`/admin/license/activation/${activationID}/unbind`).then((r) => r.data),
+
+ // Self-service
+ activate: (key: string, deviceID: string, deviceName?: string) =>
+ api
+ .post('/license/activate', {
+ key,
+ device_id: deviceID,
+ device_name: deviceName,
+ })
+ .then((r) => r.data),
+
+ status: (keyID: string) =>
+ api
+ .get<{ key: LicenseKey; active_activations: number; valid: boolean }>(
+ '/license/status',
+ { params: { key_id: keyID } },
+ )
+ .then((r) => r.data),
+}
diff --git a/web/src/api/media_extra.ts b/web/src/api/media_extra.ts
new file mode 100644
index 0000000..fea1026
--- /dev/null
+++ b/web/src/api/media_extra.ts
@@ -0,0 +1,19 @@
+import { api } from './client'
+import type { Media } from '../types'
+
+// Auxiliary media surfaces used by the home page rails and the admin
+// dashboard "library composition" card.
+export const mediaExtraAPI = {
+ recent: (limit = 12) =>
+ api.get('/media/recent', { params: { limit } }).then((r) => r.data),
+
+ stats: () =>
+ api
+ .get<{
+ by_type: { movies: number; tv: number; anime: number; music: number; unscraped: number }
+ total: number
+ total_size: number
+ total_seconds: number
+ }>('/media/stats')
+ .then((r) => r.data),
+}
diff --git a/web/src/api/notify_channels.ts b/web/src/api/notify_channels.ts
new file mode 100644
index 0000000..0d13e1c
--- /dev/null
+++ b/web/src/api/notify_channels.ts
@@ -0,0 +1,29 @@
+import { api } from './client'
+import type { NotifyChannel } from '../types'
+
+// Payload accepted by create / update. `events` and `enabled` are optional.
+export interface NotifyChannelInput {
+ name: string
+ channel_type: NotifyChannel['channel_type']
+ config: Record
+ events?: string[]
+ enabled?: boolean
+}
+
+// notifyChannelsAPI wraps the admin /admin/notify/channels surface.
+export const notifyChannelsAPI = {
+ list: () =>
+ api.get('/admin/notify/channels').then((r) => r.data),
+
+ create: (input: NotifyChannelInput) =>
+ api.post('/admin/notify/channels', input).then((r) => r.data),
+
+ update: (id: string, input: NotifyChannelInput) =>
+ api.put(`/admin/notify/channels/${id}`, input).then((r) => r.data),
+
+ remove: (id: string) =>
+ api.delete(`/admin/notify/channels/${id}`).then((r) => r.data),
+
+ test: (id: string) =>
+ api.post<{ message: string }>(`/admin/notify/channels/${id}/test`).then((r) => r.data),
+}
diff --git a/web/src/api/permissions.ts b/web/src/api/permissions.ts
new file mode 100644
index 0000000..671ecbf
--- /dev/null
+++ b/web/src/api/permissions.ts
@@ -0,0 +1,38 @@
+import { api } from './client'
+
+export interface UserPermission {
+ user_id: string
+ can_play_media: boolean
+ can_favorite: boolean
+ can_view_history: boolean
+ can_view_dashboard: boolean
+ can_view_discover: boolean
+ can_manage_downloads: boolean
+ can_manage_subscriptions: boolean
+ can_manage_sites: boolean
+ can_manage_files: boolean
+ can_manage_strm: boolean
+ can_cast: boolean
+ can_use_ai_assistant: boolean
+ can_access_settings: boolean
+ updated_at: string
+}
+
+export const permissionsAPI = {
+ // Caller's effective permissions; admins always get the all-true set.
+ mine: () => api.get('/auth/permissions').then((r) => r.data),
+
+ // Admin endpoints
+ get: (userID: string) =>
+ api.get(`/admin/users/${userID}/permissions`).then((r) => r.data),
+
+ save: (userID: string, p: UserPermission) =>
+ api
+ .put(`/admin/users/${userID}/permissions`, p)
+ .then((r) => r.data),
+
+ reset: (userID: string) =>
+ api
+ .post(`/admin/users/${userID}/permissions/reset`)
+ .then((r) => r.data),
+}
diff --git a/web/src/api/play_profiles.ts b/web/src/api/play_profiles.ts
new file mode 100644
index 0000000..390027a
--- /dev/null
+++ b/web/src/api/play_profiles.ts
@@ -0,0 +1,35 @@
+import { api } from './client'
+import type { PlayProfile } from '../types'
+
+// Payload accepted by create / update.
+export interface PlayProfileInput {
+ user_id?: string
+ name: string
+ is_default: boolean
+ content_rating_limit?: string
+ allow_adult: boolean
+ require_pin: boolean
+ pin?: string
+ preferred_subtitle_lang?: string
+ preferred_audio_lang?: string
+ autoplay_next: boolean
+ skip_intro: boolean
+ allowed_library_ids: string[]
+}
+
+// playProfilesAPI wraps /play-profiles. The admin variant adds ?all=true.
+export const playProfilesAPI = {
+ list: (all = false) =>
+ api
+ .get('/play-profiles', { params: all ? { all: 'true' } : {} })
+ .then((r) => r.data),
+
+ create: (input: PlayProfileInput) =>
+ api.post('/play-profiles', input).then((r) => r.data),
+
+ update: (id: string, input: PlayProfileInput) =>
+ api.put(`/play-profiles/${id}`, input).then((r) => r.data),
+
+ remove: (id: string) =>
+ api.delete(`/play-profiles/${id}`).then((r) => r.data),
+}
diff --git a/web/src/api/sites.ts b/web/src/api/sites.ts
index 1bda125..990e83d 100644
--- a/web/src/api/sites.ts
+++ b/web/src/api/sites.ts
@@ -1,6 +1,59 @@
import { api } from './client'
-// Site management API - matches Go backend /api/sites endpoints
+// ─── TypeScript interfaces ──────────────────────────────────────────────
+
+export interface Site {
+ id: string
+ name: string
+ base_url: string
+ site_type: string
+ auth_type: string
+ cookie?: string
+ api_key?: string
+ auth_header?: string
+ user_agent?: string
+ rss_url?: string
+ timeout: number
+ priority: number
+ use_proxy: boolean
+ enabled: boolean
+ login_status: string
+ downloader?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface SiteSearchResult {
+ site_name: string
+ site_id: string
+ title: string
+ torrent_url: string
+ download_url: string
+ size: number
+ seeders: number
+ leechers: number
+ free: boolean
+}
+
+export interface CreateSiteInput {
+ name: string
+ base_url: string
+ site_type?: string
+ auth_type?: string
+ cookie?: string
+ api_key?: string
+ auth_header?: string
+ user_agent?: string
+ rss_url?: string
+ timeout?: number
+ priority?: number
+ use_proxy?: boolean
+ enabled?: boolean
+ downloader?: string
+}
+
+// ─── API client ─────────────────────────────────────────────────────────
+
export const sitesAPI = {
// List all sites
list: () => api.get('/sites').then((r) => r.data),
@@ -29,4 +82,10 @@ export const sitesAPI = {
// Get supported auth types
authTypes: () => api.get('/sites/auth-types').then((r) => r.data),
+
+ // Search across all sites
+ search: (keyword: string) =>
+ api
+ .get('/sites/search', { params: { keyword } })
+ .then((r) => r.data),
}
diff --git a/web/src/api/stats_extra.ts b/web/src/api/stats_extra.ts
new file mode 100644
index 0000000..7cec793
--- /dev/null
+++ b/web/src/api/stats_extra.ts
@@ -0,0 +1,40 @@
+import { api } from './client'
+import type { Hardware, Library, Media } from '../types'
+
+// statsExtraAPI exposes the admin dashboard surfaces beyond /stats.
+export const statsExtraAPI = {
+ overview: () =>
+ api
+ .get<{
+ libraries: number
+ media_count: number
+ users_count: number
+ total_size: number
+ total_seconds: number
+ generated_at: string
+ }>('/stats/overview')
+ .then((r) => r.data),
+
+ trend: (days = 14) =>
+ api
+ .get<{ trend: { day: string; count: number }[]; days: number }>('/stats/trend', {
+ params: { days },
+ })
+ .then((r) => r.data),
+
+ topContent: (limit = 10) =>
+ api
+ .get<{
+ items: { media: Media; play_count: number; last_played: string }[]
+ }>('/stats/top-content', { params: { limit } })
+ .then((r) => r.data),
+
+ libraries: () =>
+ api
+ .get<{
+ libraries: { library: Library; item_count: number; total_size: number }[]
+ }>('/stats/libraries')
+ .then((r) => r.data),
+
+ monitor: () => api.get('/stats/monitor').then((r) => r.data),
+}
diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts
new file mode 100644
index 0000000..b3aade0
--- /dev/null
+++ b/web/src/api/storage_config.ts
@@ -0,0 +1,36 @@
+import { api } from './client'
+
+export type StorageType = 'alist' | 's3' | 'webdav'
+
+export interface StorageConfig {
+ id: string
+ type: StorageType
+ config: Record
+ enabled: boolean
+ last_error?: string
+ created_at: string
+ updated_at: string
+}
+
+export const storageAPI = {
+ status: () =>
+ api
+ .get<{ items: StorageConfig[] }>('/admin/storage/status')
+ .then((r) => r.data.items),
+
+ get: (type: StorageType) =>
+ api.get(`/admin/storage/${type}`).then((r) => r.data),
+
+ save: (type: StorageType, config: Record, enabled = true) =>
+ api
+ .put(`/admin/storage/${type}`, { type, config, enabled })
+ .then((r) => r.data),
+
+ test: (type: StorageType, config: Record) =>
+ api
+ .post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
+ type,
+ config,
+ })
+ .then((r) => r.data),
+}
diff --git a/web/src/api/system.ts b/web/src/api/system.ts
new file mode 100644
index 0000000..14e1566
--- /dev/null
+++ b/web/src/api/system.ts
@@ -0,0 +1,38 @@
+import { api } from './client'
+
+// systemAPI wraps the read-only /system/* metadata endpoints used by the
+// React shell (footer, admin dashboard, scheduler page).
+export const systemAPI = {
+ info: () =>
+ api
+ .get<{
+ name: string
+ version: string
+ go: string
+ os: string
+ arch: string
+ data_dir: string
+ cache_dir: string
+ }>('/system/info')
+ .then((r) => r.data),
+
+ status: () =>
+ api
+ .get<{
+ uptime_seconds: number
+ goroutines: number
+ cpu_percent?: number
+ memory_used?: number
+ memory_total?: number
+ disk_used?: number
+ disk_total?: number
+ }>('/system/status')
+ .then((r) => r.data),
+
+ scheduler: () =>
+ api
+ .get<{ jobs: { name: string; cron: string; next_run?: string; last_run?: string }[] }>(
+ '/system/scheduler',
+ )
+ .then((r) => r.data),
+}
diff --git a/web/src/api/tools.ts b/web/src/api/tools.ts
new file mode 100644
index 0000000..607d50d
--- /dev/null
+++ b/web/src/api/tools.ts
@@ -0,0 +1,21 @@
+import { api } from './client'
+
+// toolsAPI groups admin-only endpoints that don't fit the other domain
+// modules: organizing media files into the canonical naming layout, and
+// dispatching a test notification through the configured channels.
+export const toolsAPI = {
+ organizeMedia: (mediaID: string) =>
+ api
+ .post<{ path: string }>(`/admin/media/${mediaID}/organize`)
+ .then((r) => r.data),
+
+ organizeLibrary: (libraryID: string) =>
+ api
+ .post>(`/admin/libraries/${libraryID}/organize`)
+ .then((r) => r.data),
+
+ notifyTest: (title: string, body: string) =>
+ api
+ .post<{ message: string }>('/admin/notify/test', { title, body })
+ .then((r) => r.data),
+}
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index ecce5b3..d986849 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
Activity,
+ Bell,
Cast,
Clock,
CloudDownload,
@@ -14,13 +15,23 @@ import {
Heart,
Home,
GalleryHorizontalEnd,
+ KeyRound,
+ KeySquare,
+ Link2,
ListChecks,
ListMusic,
LogOut,
+ MessageSquare,
Rss,
Search,
+ Server,
Settings,
+ Sliders,
+ Sparkles,
+ Cloud,
Trash2,
+ UserCog,
+ Wrench,
Library as LibraryIcon,
User as UserIcon,
} from 'lucide-react'
@@ -60,6 +71,7 @@ export function Layout() {
} label="首页" end />
} label="发现" />
} label="搜索" />
+ } label="AI 助手" />
} label="收藏" />
} label="播放列表" />
} label="观看历史" />
@@ -86,11 +98,14 @@ export function Layout() {
} label="下载" />
} label="RSS 订阅" />
} label="DLNA 投屏" />
+ } label="站点管理" />
+ } label="站点搜索" />
账号
} label="个人资料" />
+ } label="观影 Profile" />
{user?.role === 'admin' && (
<>
@@ -104,6 +119,14 @@ export function Layout() {
} label="重复文件" />
} label="定时任务" />
} label="站点管理" />
+ } label="STRM 管理" />
+ } label="运维工具" />
+ } label="通知渠道" />
+ } label="下载器" />
+ } label="外部存储" />
+ } label="许可证" />
+ } label="AI 对话" />
+ } label="系统设置" />
} label="回收站" />
} label="管理后台" />
>
diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx
new file mode 100644
index 0000000..2ea8f5c
--- /dev/null
+++ b/web/src/pages/AIAssistantPage.tsx
@@ -0,0 +1,242 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Loader2, Search, Sparkles, Wand2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import { aiAPI, type SearchIntent } from '../api/ai'
+import { MediaCard } from '../components/MediaCard'
+import type { Media } from '../types'
+
+// AIAssistantPage exposes the two AI helpers backed by the Go server:
+// - smart search: parses a natural-language query into a SearchIntent +
+// a list of matching local media items.
+// - recommendations: returns a list of recommended titles based on the
+// current user's recent watch history.
+//
+// The Vue version had a full chat surface; the Go backend has no chat or
+// operation-execute endpoints, so we render the same two capabilities as
+// a focused two-panel screen.
+export function AIAssistantPage() {
+ const [status, setStatus] = useState<{
+ enabled: boolean
+ provider: string
+ model: string
+ } | null>(null)
+ const [query, setQuery] = useState('')
+ const [searching, setSearching] = useState(false)
+ const [intent, setIntent] = useState(null)
+ const [items, setItems] = useState([])
+
+ const [recs, setRecs] = useState(null)
+ const [recommending, setRecommending] = useState(false)
+
+ useEffect(() => {
+ aiAPI
+ .status()
+ .then(setStatus)
+ .catch(() => setStatus({ enabled: false, provider: '', model: '' }))
+ }, [])
+
+ const onSearch = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!query.trim()) return
+ setSearching(true)
+ setIntent(null)
+ setItems([])
+ try {
+ const r = await aiAPI.smartSearch(query.trim())
+ setIntent(r.intent)
+ setItems(r.items)
+ if (r.items.length === 0) toast('未找到匹配项')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '搜索失败'
+ toast.error(msg)
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const onRecommend = async () => {
+ setRecommending(true)
+ try {
+ const titles = await aiAPI.recommend()
+ setRecs(titles)
+ if (titles.length === 0) toast('暂无可推荐内容,请先观看一些媒体')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '获取推荐失败'
+ toast.error(msg)
+ } finally {
+ setRecommending(false)
+ }
+ }
+
+ const quickHints = [
+ '2023 年的科幻电影',
+ '评分高的动漫',
+ '最近添加的纪录片',
+ '中文剧集',
+ ]
+
+ return (
+
+
+
+
+
+
+
+
AI 助手
+
+ 自然语言搜索 · 基于观影历史的智能推荐
+
+
+
+ {status && (
+
+
+ {status.enabled
+ ? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
+ : '未配置 AI 服务,使用本地规则解析'}
+
+ )}
+
+
+ {/* Smart search */}
+
+ 智能搜索
+
+
+
+ {quickHints.map((h) => (
+ {
+ setQuery(h)
+ }}
+ className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
+ >
+ {h}
+
+ ))}
+
+
+ {intent && (
+
+
解析结果
+
+
+ 查询: {intent.query || '—'}
+
+ {intent.year !== undefined && intent.year > 0 && (
+
+ 年份: {intent.year}
+
+ )}
+ {intent.genre && (
+
+ 类型: {intent.genre}
+
+ )}
+ {intent.type && (
+
+ 分类: {intent.type}
+
+ )}
+ {intent.sort && (
+
+ 排序: {intent.sort}
+
+ )}
+ {intent.language && (
+
+ 语言: {intent.language}
+
+ )}
+
+
+ )}
+
+ {items.length > 0 && (
+
+ {items.map((m) => (
+
+ ))}
+
+ )}
+
+
+ {/* Recommendations */}
+
+
+
为你推荐
+
+ {recommending ? (
+
+ ) : (
+
+ )}
+ 生成推荐
+
+
+
+ 推荐基于你的最近观看历史。点击标题在媒体库中查找。
+
+
+ {recs && recs.length > 0 && (
+
+ {recs.map((t, i) => (
+
+
+ {t}
+
+
+
+ ))}
+
+ )}
+
+ {recs && recs.length === 0 && (
+
+ 还没有推荐结果 — 先去看几部片子,我再给你挑。
+
+ )}
+
+
+ {/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
+ {!status?.enabled && (
+
+ 提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
+ 管理员可在 API 配置{' '}
+ 中接入 OpenAI / DeepSeek 等服务以获得更好效果。
+
+ )}
+
+ )
+}
diff --git a/web/src/pages/AssistantChatPage.tsx b/web/src/pages/AssistantChatPage.tsx
new file mode 100644
index 0000000..9b69c6f
--- /dev/null
+++ b/web/src/pages/AssistantChatPage.tsx
@@ -0,0 +1,221 @@
+import { FormEvent, useEffect, useRef, useState } from 'react'
+import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import {
+ assistantAPI,
+ type AssistantMessage,
+ type AssistantSession,
+ type SessionView,
+} from '../api/assistant'
+
+// AssistantChatPage is the multi-turn chat surface backed by the Go
+// AssistantService. It complements the older AIAssistantPage which is
+// limited to single-turn smart search + recommendations.
+export function AssistantChatPage() {
+ const [sessions, setSessions] = useState([])
+ const [active, setActive] = useState(null)
+ const [draft, setDraft] = useState('')
+ const [sending, setSending] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const messagesEndRef = useRef(null)
+
+ const refreshSessions = async () => {
+ try {
+ const list = await assistantAPI.listSessions()
+ setSessions(list)
+ // Auto-select the most recent if nothing is open.
+ if (list.length > 0 && !active) {
+ const view = await assistantAPI.getSession(list[0].id)
+ setActive(view)
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refreshSessions().catch(() => undefined)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [active?.messages.length])
+
+ const onNew = async () => {
+ try {
+ const sess = await assistantAPI.createSession('New chat')
+ const view = await assistantAPI.getSession(sess.id)
+ setActive(view)
+ await refreshSessions()
+ } catch {
+ toast.error('创建会话失败')
+ }
+ }
+
+ const onSelect = async (id: string) => {
+ try {
+ setActive(await assistantAPI.getSession(id))
+ } catch {
+ toast.error('加载会话失败')
+ }
+ }
+
+ const onDelete = async (id: string) => {
+ if (!confirm('删除此会话?')) return
+ try {
+ await assistantAPI.deleteSession(id)
+ if (active?.session.id === id) setActive(null)
+ await refreshSessions()
+ } catch {
+ toast.error('删除失败')
+ }
+ }
+
+ const onSend = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!draft.trim() || !active) return
+ setSending(true)
+ const text = draft.trim()
+ setDraft('')
+ // Optimistic append so the user's turn shows immediately.
+ setActive((s) =>
+ s
+ ? {
+ ...s,
+ messages: [
+ ...s.messages,
+ {
+ id: 'pending-' + Date.now(),
+ session_id: s.session.id,
+ role: 'user',
+ content: text,
+ created_at: new Date().toISOString(),
+ } as AssistantMessage,
+ ],
+ }
+ : s,
+ )
+ try {
+ const view = await assistantAPI.chat(active.session.id, text)
+ setActive(view)
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '发送失败'
+ toast.error(msg)
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+ {/* Sessions sidebar */}
+
+
+ {loading && (
+
+
+
+ )}
+
+ {sessions.map((s) => (
+ onSelect(s.id)}
+ >
+
+ {s.title || '未命名'}
+ {
+ e.stopPropagation()
+ onDelete(s.id)
+ }}
+ className="opacity-0 group-hover:opacity-100"
+ >
+
+
+
+ ))}
+ {!loading && sessions.length === 0 && (
+ 暂无会话
+ )}
+
+
+
+ {/* Conversation pane */}
+
+ {!active && (
+
+ )}
+ {active && (
+ <>
+
+
+ {active.session.title || '未命名'}
+
+
+
+ {active.messages.length === 0 && (
+
说点什么开始对话…
+ )}
+ {active.messages.map((m) => (
+
+ ))}
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+function Bubble({ message }: { message: AssistantMessage }) {
+ const mine = message.role === 'user'
+ return (
+
+
+ {message.content}
+
+
+ )
+}
diff --git a/web/src/pages/DownloadClientsPage.tsx b/web/src/pages/DownloadClientsPage.tsx
new file mode 100644
index 0000000..701b3c5
--- /dev/null
+++ b/web/src/pages/DownloadClientsPage.tsx
@@ -0,0 +1,334 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import {
+ downloadClientsAPI,
+ type DownloadClient,
+ type DownloadClientInput,
+ type DownloadClientType,
+} from '../api/download_clients'
+
+// DownloadClientsPage manages multiple downloader integrations.
+// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
+// surface and a per-client Test button.
+export function DownloadClientsPage() {
+ const [clients, setClients] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [editing, setEditing] = useState(null)
+ const [showForm, setShowForm] = useState(false)
+
+ const refresh = async () => {
+ setLoading(true)
+ try {
+ setClients(await downloadClientsAPI.list())
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ const onTest = async (id: string) => {
+ try {
+ const r = await downloadClientsAPI.test(id)
+ if (r.ok) toast.success('连接成功')
+ else toast.error(r.error ?? '连接失败')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '测试失败'
+ toast.error(msg)
+ }
+ }
+
+ const onDelete = async (c: DownloadClient) => {
+ if (!confirm(`确定删除「${c.name}」?`)) return
+ try {
+ await downloadClientsAPI.remove(c.id)
+ toast.success('已删除')
+ await refresh()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '删除失败'
+ toast.error(msg)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
下载器管理
+
+ qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
+
+
+
+
{
+ setEditing(null)
+ setShowForm(true)
+ }}
+ className="neon-button"
+ >
+ 添加下载器
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && clients.length === 0 && (
+
暂无下载器
+ )}
+
+ {!loading && clients.length > 0 && (
+
+ {clients.map((c) => (
+
+
+
+ {c.name}
+
+ {c.type}
+
+ {c.is_default && (
+
+ 默认
+
+ )}
+ {!c.enabled && (
+
+ 已禁用
+
+ )}
+
+
+ {c.url}
+ {c.username && ` · ${c.username}`}
+ {c.save_path && ` · ${c.save_path}`}
+
+
+
+
onTest(c.id)}
+ className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
+ >
+ 测试
+
+
{
+ setEditing(c)
+ setShowForm(true)
+ }}
+ className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
+ >
+ 编辑
+
+
onDelete(c)}
+ className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
+ >
+ 删除
+
+
+
+ ))}
+
+ )}
+
+ {showForm && (
+
setShowForm(false)}
+ onSaved={async () => {
+ setShowForm(false)
+ await refresh()
+ }}
+ />
+ )}
+
+ )
+}
+
+function ClientFormModal({
+ editing,
+ onClose,
+ onSaved,
+}: {
+ editing: DownloadClient | null
+ onClose: () => void
+ onSaved: () => void | Promise
+}) {
+ const [form, setForm] = useState(() => ({
+ name: editing?.name ?? '',
+ type: editing?.type ?? 'qbittorrent',
+ url: editing?.url ?? '',
+ username: editing?.username ?? '',
+ password: '',
+ save_path: editing?.save_path ?? '',
+ is_default: editing?.is_default ?? false,
+ enabled: editing?.enabled ?? true,
+ }))
+ const [saving, setSaving] = useState(false)
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ if (editing) await downloadClientsAPI.update(editing.id, form)
+ else await downloadClientsAPI.create(form)
+ toast.success('已保存')
+ await onSaved()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const update = (k: K, v: DownloadClientInput[K]) =>
+ setForm((f) => ({ ...f, [k]: v }))
+
+ const placeholder = (
+ {
+ qbittorrent: 'http://127.0.0.1:8080',
+ aria2: 'http://127.0.0.1:6800/jsonrpc',
+ transmission: 'http://127.0.0.1:9091/transmission/rpc',
+ } as Record
+ )[form.type]
+
+ return (
+
+
+
+ {editing ? '编辑下载器' : '添加下载器'}
+
+
+
+
+ )
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
diff --git a/web/src/pages/LicensePage.tsx b/web/src/pages/LicensePage.tsx
new file mode 100644
index 0000000..c0bc10d
--- /dev/null
+++ b/web/src/pages/LicensePage.tsx
@@ -0,0 +1,319 @@
+import { FormEvent, useEffect, useState } from 'react'
+import {
+ ChevronDown,
+ ChevronUp,
+ KeySquare,
+ Loader2,
+ Plus,
+ ShieldOff,
+ Trash2,
+} from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import {
+ licenseAPI,
+ type GenerateKeyInput,
+ type LicenseActivation,
+ type LicenseKey,
+} from '../api/license'
+
+// LicensePage is the admin UI for issuing / revoking license keys and
+// inspecting activations. Mirrors the Vue LicenseTab inside Settings.
+export function LicensePage() {
+ const [keys, setKeys] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showGen, setShowGen] = useState(false)
+ const [openKey, setOpenKey] = useState(null)
+ const [activations, setActivations] = useState>({})
+
+ const refresh = async () => {
+ setLoading(true)
+ try {
+ setKeys(await licenseAPI.list())
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ const toggleOpen = async (k: LicenseKey) => {
+ if (openKey === k.id) {
+ setOpenKey(null)
+ return
+ }
+ setOpenKey(k.id)
+ if (!activations[k.id]) {
+ try {
+ const acts = await licenseAPI.listActivations(k.id)
+ setActivations((a) => ({ ...a, [k.id]: acts }))
+ } catch {
+ toast.error('加载激活记录失败')
+ }
+ }
+ }
+
+ const onRevoke = async (k: LicenseKey) => {
+ if (!confirm(`确定吊销 ${k.key.slice(0, 14)}…?`)) return
+ try {
+ await licenseAPI.revoke(k.id)
+ toast.success('已吊销')
+ await refresh()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '吊销失败'
+ toast.error(msg)
+ }
+ }
+
+ const onUnbind = async (a: LicenseActivation) => {
+ if (!confirm(`解绑设备 ${a.device_name || a.device_id}?`)) return
+ try {
+ await licenseAPI.unbind(a.id)
+ toast.success('已解绑')
+ const acts = await licenseAPI.listActivations(a.key_id)
+ setActivations((all) => ({ ...all, [a.key_id]: acts }))
+ } catch {
+ toast.error('解绑失败')
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
许可证管理
+
+ 生成密钥 · 绑定设备 · 心跳监控 · 吊销
+
+
+
+
setShowGen(true)} className="neon-button">
+ 生成新密钥
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && keys.length === 0 && (
+
暂无密钥
+ )}
+
+ {!loading && keys.length > 0 && (
+
+ {keys.map((k) => (
+
+
+
+
+
+ {k.key}
+
+
+ {k.plan}
+
+ {k.revoked && (
+
+ 已吊销
+
+ )}
+
+
+ {k.customer && `客户: ${k.customer} · `}
+ 最多 {k.max_activations} 设备 ·
+ {k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'}
+
+
+
+ toggleOpen(k)}
+ className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
+ >
+ {openKey === k.id ? : }
+ {' '}激活记录
+
+ {!k.revoked && (
+ onRevoke(k)}
+ className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
+ >
+ 吊销
+
+ )}
+
+
+ {openKey === k.id && (
+
+ {(activations[k.id] ?? []).length === 0 && (
+
暂无激活记录
+ )}
+ {(activations[k.id] ?? []).map((a) => (
+
+
+
+ {a.device_name || a.device_id}
+ {a.unbound_at && (
+
+ (已解绑 {new Date(a.unbound_at).toLocaleDateString()})
+
+ )}
+
+
+ {a.ip} · 心跳{' '}
+ {a.heartbeat_at
+ ? new Date(a.heartbeat_at).toLocaleString()
+ : '未上报'}
+
+
+ {!a.unbound_at && (
+
onUnbind(a)}
+ className="shrink-0 rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-red-400/40 hover:text-red-400"
+ >
+ 解绑
+
+ )}
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ {showGen && (
+
setShowGen(false)}
+ onCreated={async () => {
+ setShowGen(false)
+ await refresh()
+ }}
+ />
+ )}
+
+ )
+}
+
+function GenerateModal({
+ onClose,
+ onCreated,
+}: {
+ onClose: () => void
+ onCreated: () => void | Promise
+}) {
+ const [form, setForm] = useState({
+ customer: '',
+ plan: 'basic',
+ max_activations: 1,
+ expires_at: '',
+ notes: '',
+ })
+ const [saving, setSaving] = useState(false)
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ const k = await licenseAPI.generate({
+ ...form,
+ expires_at: form.expires_at ? new Date(form.expires_at).toISOString() : undefined,
+ })
+ toast.success(`已生成: ${k.key}`)
+ await onCreated()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '生成失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx
new file mode 100644
index 0000000..034af8a
--- /dev/null
+++ b/web/src/pages/NotifyChannelsPage.tsx
@@ -0,0 +1,421 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import {
+ notifyChannelsAPI,
+ type NotifyChannelInput,
+} from '../api/notify_channels'
+import type { NotifyChannel } from '../types'
+
+// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
+// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
+// fire a test notification on demand.
+export function NotifyChannelsPage() {
+ const [channels, setChannels] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [editing, setEditing] = useState(null)
+ const [showForm, setShowForm] = useState(false)
+
+ const refresh = async () => {
+ setLoading(true)
+ try {
+ setChannels(await notifyChannelsAPI.list())
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ const onTest = async (id: string) => {
+ try {
+ await notifyChannelsAPI.test(id)
+ toast.success('测试消息已发送')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
+ toast.error(msg)
+ }
+ }
+
+ const onDelete = async (ch: NotifyChannel) => {
+ if (!confirm(`确定删除「${ch.name}」?`)) return
+ try {
+ await notifyChannelsAPI.remove(ch.id)
+ toast.success('已删除')
+ await refresh()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
+ toast.error(msg)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
通知渠道
+
+ 配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
+
+
+
+
{
+ setEditing(null)
+ setShowForm(true)
+ }}
+ className="neon-button"
+ >
+ 添加渠道
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && channels.length === 0 && (
+
暂无通知渠道
+ )}
+
+ {!loading && channels.length > 0 && (
+
+ {channels.map((ch) => (
+ onTest(ch.id)}
+ onEdit={() => {
+ setEditing(ch)
+ setShowForm(true)
+ }}
+ onDelete={() => onDelete(ch)}
+ />
+ ))}
+
+ )}
+
+ {showForm && (
+
setShowForm(false)}
+ onSaved={async () => {
+ setShowForm(false)
+ await refresh()
+ }}
+ />
+ )}
+
+ )
+}
+
+const TYPE_LABELS: Record = {
+ telegram: 'Telegram',
+ wechat: '企业微信',
+ bark: 'Bark',
+ webhook: 'Webhook',
+}
+
+function ChannelCard({
+ channel,
+ onTest,
+ onEdit,
+ onDelete,
+}: {
+ channel: NotifyChannel
+ onTest: () => void
+ onEdit: () => void
+ onDelete: () => void
+}) {
+ const summary = channelSummary(channel)
+ return (
+
+
+
+ {channel.name}
+
+ {TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
+
+ {!channel.enabled && (
+ 已禁用
+ )}
+
+
{summary}
+
+
+
+ 测试
+
+
+ 编辑
+
+
+ 删除
+
+
+
+ )
+}
+
+function channelSummary(ch: NotifyChannel): string {
+ const cfg = ch.config ?? {}
+ switch (ch.channel_type) {
+ case 'telegram':
+ return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
+ case 'wechat':
+ return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
+ case 'bark':
+ return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
+ case 'webhook':
+ return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
+ default:
+ return ''
+ }
+}
+
+// ─── Form Modal ─────────────────────────────────────────────────────────────
+
+const EMPTY_CONFIG: Record> = {
+ telegram: { bot_token: '', chat_id: '' },
+ wechat: { sendkey: '' },
+ bark: { device_key: '', server: '' },
+ webhook: { url: '', method: 'POST', headers: '', body_template: '' },
+}
+
+function ChannelFormModal({
+ editing,
+ onClose,
+ onSaved,
+}: {
+ editing: NotifyChannel | null
+ onClose: () => void
+ onSaved: () => void | Promise
+}) {
+ const [name, setName] = useState(editing?.name ?? '')
+ const [type, setType] = useState(
+ editing?.channel_type ?? 'telegram',
+ )
+ const [config, setConfig] = useState>(
+ editing?.config ?? EMPTY_CONFIG.telegram,
+ )
+ const [enabled, setEnabled] = useState(editing?.enabled ?? true)
+ const [saving, setSaving] = useState(false)
+
+ const onTypeChange = (t: NotifyChannel['channel_type']) => {
+ setType(t)
+ setConfig({ ...EMPTY_CONFIG[t] })
+ }
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ const input: NotifyChannelInput = {
+ name: name.trim(),
+ channel_type: type,
+ config,
+ enabled,
+ }
+ if (editing) {
+ await notifyChannelsAPI.update(editing.id, input)
+ } else {
+ await notifyChannelsAPI.create(input)
+ }
+ toast.success('已保存')
+ await onSaved()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const updateConfig = (k: string, v: string) =>
+ setConfig((c) => ({ ...c, [k]: v }))
+
+ return (
+
+ )
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
diff --git a/web/src/pages/ProfileManagementPage.tsx b/web/src/pages/ProfileManagementPage.tsx
new file mode 100644
index 0000000..0d6ef7b
--- /dev/null
+++ b/web/src/pages/ProfileManagementPage.tsx
@@ -0,0 +1,457 @@
+import { FormEvent, useEffect, useMemo, useState } from 'react'
+import { Loader2, Pencil, Plus, Trash2, UserCog } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import { libraryAPI } from '../api/library'
+import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
+import { useAuthStore } from '../stores/auth'
+import type { Library, PlayProfile } from '../types'
+
+// ProfileManagementPage replicates the Vue ProfileManagementView. It
+// lets a user (or admin) define multiple "viewing personas" with
+// different content-rating gates, library access, and player defaults.
+//
+// All persistence is real: data is written to /api/play-profiles which
+// is backed by the Go PlayProfileService.
+export function ProfileManagementPage() {
+ const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
+ const userID = useAuthStore((s) => s.user?.id ?? '')
+
+ const [profiles, setProfiles] = useState([])
+ const [libraries, setLibraries] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [editing, setEditing] = useState(null)
+ const [showForm, setShowForm] = useState(false)
+
+ const refresh = async () => {
+ setLoading(true)
+ try {
+ const [p, l] = await Promise.all([
+ playProfilesAPI.list(isAdmin),
+ libraryAPI.list().catch(() => [] as Library[]),
+ ])
+ setProfiles(p)
+ setLibraries(l)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [isAdmin])
+
+ const onDelete = async (p: PlayProfile) => {
+ if (!confirm(`确定删除 Profile「${p.name}」?`)) return
+ try {
+ await playProfilesAPI.remove(p.id)
+ toast.success('已删除')
+ await refresh()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
+ toast.error(msg)
+ }
+ }
+
+ const openCreate = () => {
+ setEditing(null)
+ setShowForm(true)
+ }
+
+ const openEdit = (p: PlayProfile) => {
+ setEditing(p)
+ setShowForm(true)
+ }
+
+ return (
+
+
+
+
+
+
+
+
观影 Profile
+
+ 为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则
+
+
+
+
+ 创建 Profile
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && profiles.length === 0 && (
+
+
👤
+
暂无 Profile
+
点击右上角"创建 Profile"开始
+
+ )}
+
+ {!loading && profiles.length > 0 && (
+
+ {profiles.map((p) => (
+
openEdit(p)}
+ onDelete={() => onDelete(p)}
+ />
+ ))}
+
+ )}
+
+ {showForm && (
+
setShowForm(false)}
+ onSaved={async () => {
+ setShowForm(false)
+ await refresh()
+ }}
+ />
+ )}
+
+ )
+}
+
+function ProfileCard({
+ profile,
+ libraries,
+ onEdit,
+ onDelete,
+}: {
+ profile: PlayProfile
+ libraries: Library[]
+ onEdit: () => void
+ onDelete: () => void
+}) {
+ const libNames = useMemo(() => {
+ if (!profile.allowed_library_ids?.length) return '全部'
+ const idx = new Map(libraries.map((l) => [l.id, l.name]))
+ return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ')
+ }, [profile, libraries])
+
+ return (
+
+
+
+ {profile.name[0]?.toUpperCase()}
+
+
+
+ {profile.name}
+ {profile.is_default && (
+
+ 默认
+
+ )}
+ {profile.allow_adult && (
+
+ 成人内容
+
+ )}
+ {profile.require_pin && (
+
+ 🔒 PIN
+
+ )}
+
+
+ {profile.content_rating_limit && 分级: {profile.content_rating_limit} }
+ 媒体库: {libNames}
+ 用户: {profile.user_id.slice(0, 8)}…
+
+
+ 观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
+ {profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
+
+
+
+
+
+ )
+}
+
+function ProfileFormModal({
+ editing,
+ libraries,
+ defaultUserID,
+ isAdmin,
+ onClose,
+ onSaved,
+}: {
+ editing: PlayProfile | null
+ libraries: Library[]
+ defaultUserID: string
+ isAdmin: boolean
+ onClose: () => void
+ onSaved: () => void | Promise
+}) {
+ const [form, setForm] = useState(() => ({
+ user_id: editing?.user_id ?? defaultUserID,
+ name: editing?.name ?? '',
+ is_default: editing?.is_default ?? false,
+ content_rating_limit: editing?.content_rating_limit ?? '',
+ allow_adult: editing?.allow_adult ?? false,
+ require_pin: editing?.require_pin ?? false,
+ pin: '',
+ preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '',
+ preferred_audio_lang: editing?.preferred_audio_lang ?? '',
+ autoplay_next: editing?.autoplay_next ?? true,
+ skip_intro: editing?.skip_intro ?? false,
+ allowed_library_ids: editing?.allowed_library_ids ?? [],
+ }))
+ const [saving, setSaving] = useState(false)
+
+ const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch }))
+
+ const toggleLib = (id: string) => {
+ const next = form.allowed_library_ids.includes(id)
+ ? form.allowed_library_ids.filter((x) => x !== id)
+ : [...form.allowed_library_ids, id]
+ update({ allowed_library_ids: next })
+ }
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ if (editing) {
+ await playProfilesAPI.update(editing.id, form)
+ } else {
+ await playProfilesAPI.create(form)
+ }
+ toast.success('已保存')
+ await onSaved()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+function Toggle({
+ label,
+ hint,
+ checked,
+ onChange,
+}: {
+ label: string
+ hint?: string
+ checked: boolean
+ onChange: (v: boolean) => void
+}) {
+ return (
+
+
+
{label}
+ {hint &&
{hint}
}
+
+ onChange(e.target.checked)}
+ className="h-4 w-4 accent-primary-400"
+ />
+
+ )
+}
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..2c6ac3e
--- /dev/null
+++ b/web/src/pages/SettingsPage.tsx
@@ -0,0 +1,362 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Loader2, Save, SettingsIcon } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import { adminAPI } from '../api/admin'
+import type { Setting } from '../types'
+
+// SettingsPage replaces the Vue SettingsView's General / Organize /
+// Scrape / Adult tabs. The Go backend stores settings as a single
+// key/value table; we group the most useful keys client-side and let
+// the operator edit them with typed widgets (select / toggle / input).
+//
+// Any key not in the curated list still works via the AdminPage generic
+// key/value editor — this page is the curated UX over the same store.
+interface SettingDef {
+ key: string
+ label: string
+ type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
+ hint?: string
+ options?: { value: string; label: string }[]
+ placeholder?: string
+}
+
+interface SettingGroup {
+ key: string
+ label: string
+ description?: string
+ items: SettingDef[]
+}
+
+const GROUPS: SettingGroup[] = [
+ {
+ key: 'general',
+ label: '常规',
+ description: 'TMDb / 转码引擎默认参数',
+ items: [
+ {
+ key: 'tmdb.language',
+ label: 'TMDb 元数据语言',
+ type: 'select',
+ options: [
+ { value: 'zh-CN', label: '简体中文' },
+ { value: 'zh-TW', label: '繁体中文' },
+ { value: 'en-US', label: 'English' },
+ { value: 'ja-JP', label: '日本語' },
+ ],
+ },
+ {
+ key: 'transcode.enabled',
+ label: '启用转码',
+ type: 'toggle',
+ hint: '关闭后所有视频直连播放',
+ },
+ {
+ key: 'transcode.hw_accel',
+ label: '硬件加速',
+ type: 'select',
+ options: [
+ { value: 'auto', label: '自动检测' },
+ { value: 'none', label: '软件转码' },
+ { value: 'nvenc', label: 'NVIDIA NVENC' },
+ { value: 'qsv', label: 'Intel QSV' },
+ { value: 'vaapi', label: 'VAAPI (Linux)' },
+ { value: 'videotoolbox', label: 'VideoToolbox (macOS)' },
+ ],
+ },
+ {
+ key: 'transcode.max_jobs',
+ label: '最大并发转码任务',
+ type: 'number',
+ hint: '建议 1-4',
+ },
+ {
+ key: 'ffmpeg.path',
+ label: 'FFmpeg 路径',
+ type: 'text',
+ placeholder: 'ffmpeg',
+ },
+ {
+ key: 'ffprobe.path',
+ label: 'FFprobe 路径',
+ type: 'text',
+ placeholder: 'ffprobe',
+ },
+ ],
+ },
+ {
+ key: 'organize',
+ label: '整理 & 刮削',
+ description: '媒体文件命名 + 自动刮削开关',
+ items: [
+ {
+ key: 'organize.auto',
+ label: '入库时自动整理',
+ type: 'toggle',
+ },
+ {
+ key: 'organize.movie_format',
+ label: '电影命名格式',
+ type: 'text',
+ hint: '例: {title} ({year})/{title} ({year})',
+ placeholder: '{title} ({year})/{title} ({year})',
+ },
+ {
+ key: 'organize.tv_format',
+ label: '剧集命名格式',
+ type: 'text',
+ placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
+ },
+ {
+ key: 'organize.anime_format',
+ label: '动漫命名格式',
+ type: 'text',
+ placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
+ },
+ {
+ key: 'scrape.auto_on_scan',
+ label: '扫描后自动刮削',
+ type: 'toggle',
+ },
+ {
+ key: 'scrape.providers',
+ label: '刮削源优先级',
+ type: 'text',
+ hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
+ placeholder: 'tmdb,bangumi,thetvdb,fanart',
+ },
+ {
+ key: 'scrape.language',
+ label: '刮削首选语言',
+ type: 'text',
+ placeholder: 'zh-CN',
+ },
+ ],
+ },
+ {
+ key: 'adult',
+ label: 'Adult / NSFW',
+ description: '成人内容隔离开关 (默认隐藏)',
+ items: [
+ {
+ key: 'adult.enabled',
+ label: '启用成人内容',
+ type: 'toggle',
+ hint: '关闭后 NSFW 媒体不会出现在列表中',
+ },
+ {
+ key: 'adult.require_pin',
+ label: '访问需要 PIN',
+ type: 'toggle',
+ },
+ {
+ key: 'adult.pin',
+ label: 'PIN 码',
+ type: 'text',
+ hint: '4-8 位数字',
+ },
+ ],
+ },
+ {
+ key: 'qbittorrent',
+ label: 'qBittorrent',
+ description: '默认下载器配置',
+ items: [
+ { key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' },
+ { key: 'qbittorrent.username', label: '用户名', type: 'text' },
+ { key: 'qbittorrent.password', label: '密码', type: 'text' },
+ { key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' },
+ ],
+ },
+]
+
+const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
+
+export function SettingsPage() {
+ const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
+ const [values, setValues] = useState>({})
+ const [dirty, setDirty] = useState>(new Set())
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ const refresh = async () => {
+ setLoading(true)
+ try {
+ const all = await adminAPI.listSettings()
+ const idx: Record = {}
+ for (const s of all as Setting[]) {
+ if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
+ }
+ setValues(idx)
+ setDirty(new Set())
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ }, [])
+
+ const onChange = (key: string, value: string) => {
+ setValues((v) => ({ ...v, [key]: value }))
+ setDirty((d) => new Set(d).add(key))
+ }
+
+ const onSave = async (e: FormEvent) => {
+ e.preventDefault()
+ if (dirty.size === 0) return
+ setSaving(true)
+ try {
+ // Backend exposes a single-key updater; loop through dirty keys.
+ for (const key of dirty) {
+ await adminAPI.updateSetting(key, values[key] ?? '')
+ }
+ toast.success(`已保存 ${dirty.size} 项配置`)
+ setDirty(new Set())
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const group = GROUPS.find((g) => g.key === activeGroup)!
+
+ return (
+
+
+
+
+
+
+
系统设置
+
+ 按分组编辑 TMDb / 整理 / 刮削 / 下载器等关键配置
+
+
+
+
+
+ {GROUPS.map((g) => (
+ setActiveGroup(g.key)}
+ className={
+ 'border-b-2 px-4 py-2 text-sm whitespace-nowrap transition ' +
+ (activeGroup === g.key
+ ? 'border-primary-400 text-primary-400'
+ : 'border-transparent text-slate-400 hover:text-white')
+ }
+ >
+ {g.label}
+
+ ))}
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && (
+
+ {group.description && {group.description}
}
+ {group.items.map((it) => (
+ onChange(it.key, v)}
+ />
+ ))}
+
+
+ {dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
+
+
+ {saving ? : }
+ 保存
+
+
+
+ )}
+
+ )
+}
+
+function SettingRow({
+ def,
+ value,
+ onChange,
+}: {
+ def: SettingDef
+ value: string
+ onChange: (v: string) => void
+}) {
+ return (
+
+ )
+}
diff --git a/web/src/pages/SiteSearchPage.tsx b/web/src/pages/SiteSearchPage.tsx
new file mode 100644
index 0000000..8bc0e76
--- /dev/null
+++ b/web/src/pages/SiteSearchPage.tsx
@@ -0,0 +1,161 @@
+import { FormEvent, useState } from 'react'
+import toast from 'react-hot-toast'
+import { Download, Search } from 'lucide-react'
+
+import { sitesAPI, type SiteSearchResult } from '../api/sites'
+import { downloadsAPI } from '../api/downloads'
+
+function fmtBytes(n: number): string {
+ if (!n || n <= 0) return '—'
+ 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(1)} ${u[i]}`
+}
+
+// SiteSearchPage performs cross-site torrent search across all enabled
+// sites and displays a merged result table sorted by seeders.
+export function SiteSearchPage() {
+ const [keyword, setKeyword] = useState('')
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [searched, setSearched] = useState(false)
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!keyword.trim()) return
+ setLoading(true)
+ setSearched(true)
+ try {
+ const data = await sitesAPI.search(keyword.trim())
+ setResults(data.items || [])
+ if ((data.items || []).length === 0) {
+ toast('未找到结果,请检查站点配置或更换关键词')
+ }
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '搜索失败'
+ toast.error(msg)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const downloadTorrent = async (item: SiteSearchResult) => {
+ const url = item.download_url || item.torrent_url
+ if (!url) {
+ toast.error('无可用下载链接')
+ return
+ }
+ try {
+ await downloadsAPI.add(url)
+ toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '下载失败'
+ toast.error(msg)
+ }
+ }
+
+ return (
+
+
+
+
+ setKeyword(e.target.value)}
+ />
+
+ {loading ? '搜索中…' : '搜索'}
+
+
+
+ {loading &&
正在查询所有站点…
}
+
+ {!loading && searched && results.length === 0 && (
+
+
+ 未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
+
+
+ )}
+
+ {results.length > 0 && (
+
+
+
+
+ 站点
+ 标题
+ 大小
+ S
+ L
+ Free
+ 操作
+
+
+
+ {results.map((item, idx) => (
+
+ {item.site_name}
+
+
+ {item.title.length > 80
+ ? item.title.substring(0, 80) + '…'
+ : item.title}
+
+
+
+ {fmtBytes(item.size)}
+
+ {item.seeders || '—'}
+ {item.leechers || '—'}
+
+ {item.free && (
+
+ Free
+
+ )}
+
+
+ downloadTorrent(item)}
+ className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
+ title="加入下载"
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx
new file mode 100644
index 0000000..edb2743
--- /dev/null
+++ b/web/src/pages/StorageConfigPage.tsx
@@ -0,0 +1,187 @@
+import { FormEvent, useEffect, useMemo, useState } from 'react'
+import { Cloud, Loader2, Save, Send } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import { storageAPI, type StorageType } from '../api/storage_config'
+
+// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
+// the import / playback / STRM subsystems. Mirrors the Vue UI's
+// `admin/storage/*` tabs in a tabbed React surface.
+export function StorageConfigPage() {
+ const [active, setActive] = useState('alist')
+ return (
+
+
+
+
+
+
+
外部存储
+
+ 配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
+
+
+
+
+
+ {(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
+ setActive(t)}
+ className={
+ 'border-b-2 px-4 py-2 text-sm uppercase ' +
+ (active === t
+ ? 'border-primary-400 text-primary-400'
+ : 'border-transparent text-slate-400 hover:text-white')
+ }
+ >
+ {t}
+
+ ))}
+
+
+
+
+ )
+}
+
+const FIELD_DEFS: Record = {
+ alist: [
+ { key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
+ { key: 'token', label: 'Token', secret: true },
+ ],
+ webdav: [
+ { key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
+ { key: 'username', label: '用户名' },
+ { key: 'password', label: '密码', secret: true },
+ ],
+ s3: [
+ { key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
+ { key: 'region', label: 'Region', placeholder: 'us-east-1' },
+ { key: 'bucket', label: 'Bucket' },
+ { key: 'access_key', label: 'Access Key', secret: true },
+ { key: 'secret_key', label: 'Secret Key', secret: true },
+ { key: 'force_path_style', label: 'force_path_style (true/false)' },
+ ],
+}
+
+function StorageForm({ type }: { type: StorageType }) {
+ const fields = useMemo(() => FIELD_DEFS[type], [type])
+ const [config, setConfig] = useState>({})
+ const [enabled, setEnabled] = useState(true)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [testing, setTesting] = useState(false)
+
+ const refresh = async () => {
+ setLoading(true)
+ try {
+ const r = await storageAPI.get(type)
+ const next: Record = {}
+ for (const f of fields) {
+ const v = r.config?.[f.key]
+ // List() redacts secrets to "********"; show empty so the user
+ // doesn't accidentally save the placeholder.
+ next[f.key] = v === '********' ? '' : v ?? ''
+ }
+ setConfig(next)
+ setEnabled(r.enabled)
+ } catch {
+ const next: Record = {}
+ for (const f of fields) next[f.key] = ''
+ setConfig(next)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ refresh().catch(() => undefined)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [type])
+
+ const onSave = async (e: FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ try {
+ await storageAPI.save(type, config, enabled)
+ toast.success('已保存')
+ await refresh()
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '保存失败'
+ toast.error(msg)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const onTest = async () => {
+ setTesting(true)
+ try {
+ const r = await storageAPI.test(type, config)
+ if (r.ok) toast.success('连接成功')
+ else toast.error(r.error ?? '连接失败')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '测试失败'
+ toast.error(msg)
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {fields.map((f) => (
+
+
+ {f.label}
+ {f.key}
+
+ setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
+ />
+
+ ))}
+
+ setEnabled(e.target.checked)}
+ />
+ 启用
+
+
+
+ {testing ? : }
+ {' '}测试
+
+
+ {saving ? : }
+ 保存
+
+
+
+ )
+}
diff --git a/web/src/pages/StrmPage.tsx b/web/src/pages/StrmPage.tsx
new file mode 100644
index 0000000..992fdf0
--- /dev/null
+++ b/web/src/pages/StrmPage.tsx
@@ -0,0 +1,248 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Link as LinkIcon, Plus, Search, Trash2 } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import { libraryAPI, mediaAPI } from '../api/library'
+import { strmAPI } from '../api/strm'
+import type { Library, Media } from '../types'
+
+// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
+// - import a brand-new media row directly from a (library, title, url)
+// tuple — useful for streaming-only entries with no on-disk file.
+// - search existing media and attach / detach a STRM URL so the player
+// issues a 302 redirect to the remote source instead of opening a
+// local file.
+export function StrmPage() {
+ const [libraries, setLibraries] = useState([])
+
+ // Import form state
+ const [libraryID, setLibraryID] = useState('')
+ const [title, setTitle] = useState('')
+ const [url, setURL] = useState('')
+ const [importing, setImporting] = useState(false)
+
+ // Search + attach state
+ const [query, setQuery] = useState('')
+ const [searching, setSearching] = useState(false)
+ const [results, setResults] = useState([])
+ const [drafts, setDrafts] = useState>({})
+
+ useEffect(() => {
+ libraryAPI.list().then(setLibraries).catch(() => undefined)
+ }, [])
+
+ // Default the import library to the first available one once loaded.
+ useEffect(() => {
+ if (!libraryID && libraries[0]) setLibraryID(libraries[0].id)
+ }, [libraries, libraryID])
+
+ const onImport = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!libraryID || !title.trim() || !url.trim()) return
+ if (!/^https?:\/\//i.test(url.trim())) {
+ toast.error('URL 必须以 http:// 或 https:// 开头')
+ return
+ }
+ setImporting(true)
+ try {
+ await strmAPI.importURL(libraryID, title.trim(), url.trim())
+ toast.success(`已导入「${title.trim()}」`)
+ setTitle('')
+ setURL('')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '导入失败'
+ toast.error(msg)
+ } finally {
+ setImporting(false)
+ }
+ }
+
+ const doSearch = async (e?: FormEvent) => {
+ e?.preventDefault()
+ if (!query.trim()) return
+ setSearching(true)
+ try {
+ const r = await mediaAPI.search(query.trim(), 30)
+ setResults(r.items ?? [])
+ } catch {
+ toast.error('搜索失败')
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const onAttach = async (m: Media) => {
+ const next = (drafts[m.id] ?? '').trim()
+ if (!next) return
+ if (!/^https?:\/\//i.test(next)) {
+ toast.error('URL 必须以 http:// 或 https:// 开头')
+ return
+ }
+ try {
+ await strmAPI.set(m.id, next)
+ toast.success('已设置 STRM URL')
+ // Optimistic update so the user sees the new state without a re-search.
+ setResults((rs) =>
+ rs.map((x) => (x.id === m.id ? ({ ...x, container: 'strm' } as Media) : x)),
+ )
+ setDrafts((d) => ({ ...d, [m.id]: '' }))
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '设置失败'
+ toast.error(msg)
+ }
+ }
+
+ const onDetach = async (m: Media) => {
+ if (!confirm(`清除「${m.title}」的 STRM URL?`)) return
+ try {
+ await strmAPI.clear(m.id)
+ toast.success('已清除')
+ setResults((rs) =>
+ rs.map((x) =>
+ x.id === m.id ? ({ ...x, container: x.container === 'strm' ? '' : x.container } as Media) : x,
+ ),
+ )
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '清除失败'
+ toast.error(msg)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
STRM 管理
+
+ 将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
+
+
+
+
+ {/* Import a new STRM-only entry. */}
+
+
+ {/* Attach / detach STRM URL on existing media. */}
+
+ 附加 STRM URL 到已有媒体
+
+ setQuery(e.target.value)}
+ />
+
+ {searching ? '搜索中…' : '搜索'}
+
+
+
+ {results.length > 0 && (
+
+ {results.map((m) => {
+ const isStrm = m.container === 'strm'
+ return (
+
+
+
+
{m.title}
+
+ {m.year > 0 && `${m.year} · `}
+ {m.container || '本地文件'}
+
+ {isStrm && (
+
+ 已设置 STRM
+
+ )}
+
+ {isStrm && (
+
onDetach(m)}
+ className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
+ >
+ 清除
+
+ )}
+
+
+
+ setDrafts((d) => ({ ...d, [m.id]: e.target.value }))
+ }
+ />
+ onAttach(m)}
+ disabled={!(drafts[m.id] ?? '').trim()}
+ className="neon-button"
+ >
+ {isStrm ? '替换 URL' : '设置'}
+
+
+
+ )
+ })}
+
+ )}
+
+ {!searching && query && results.length === 0 && (
+ 未找到匹配的媒体。
+ )}
+
+
+ )
+}
diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx
new file mode 100644
index 0000000..da5d6df
--- /dev/null
+++ b/web/src/pages/ToolsPage.tsx
@@ -0,0 +1,244 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Bell, FolderCog, Loader2, Search, Wrench } from 'lucide-react'
+import toast from 'react-hot-toast'
+
+import { libraryAPI, mediaAPI } from '../api/library'
+import { toolsAPI } from '../api/tools'
+import type { Library, Media } from '../types'
+
+// ToolsPage gathers admin-only one-off operations that don't belong on a
+// dedicated screen of their own:
+//
+// - Organize one media item or an entire library: renames + moves files
+// into the canonical layout (`Library/Year/Title (Year)/...`).
+// - Send a test notification through every configured channel.
+//
+// The original Vue version surfaces these as tabs inside SettingsView; here
+// we co-locate them on a single admin tools page.
+export function ToolsPage() {
+ return (
+
+
+
+
+
+
+
运维工具
+
整理媒体文件 · 测试通知渠道
+
+
+
+
+
+
+ )
+}
+
+function OrganizePanel() {
+ const [libraries, setLibraries] = useState([])
+ const [libraryID, setLibraryID] = useState('')
+ const [running, setRunning] = useState(false)
+
+ const [query, setQuery] = useState('')
+ const [searching, setSearching] = useState(false)
+ const [results, setResults] = useState([])
+ const [busyID, setBusyID] = useState(null)
+
+ useEffect(() => {
+ libraryAPI.list().then(setLibraries).catch(() => undefined)
+ }, [])
+
+ const onOrganizeLibrary = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!libraryID) return
+ setRunning(true)
+ try {
+ await toolsAPI.organizeLibrary(libraryID)
+ toast.success('已触发媒体库整理')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '整理失败'
+ toast.error(msg)
+ } finally {
+ setRunning(false)
+ }
+ }
+
+ const doSearch = async (e?: FormEvent) => {
+ e?.preventDefault()
+ if (!query.trim()) return
+ setSearching(true)
+ try {
+ const r = await mediaAPI.search(query.trim(), 20)
+ setResults(r.items ?? [])
+ } catch {
+ toast.error('搜索失败')
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const onOrganizeOne = async (m: Media) => {
+ setBusyID(m.id)
+ try {
+ const r = await toolsAPI.organizeMedia(m.id)
+ toast.success(`已移动到 ${r.path}`)
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '整理失败'
+ toast.error(msg)
+ } finally {
+ setBusyID(null)
+ }
+ }
+
+ return (
+
+
+
+
整理 & 重命名
+
+
+ 将媒体文件按 媒体库/年份/标题 (年份)/标题.ext{' '}
+ 的规范布局移动并重命名,确保已先完成刮削。
+
+
+
+ setLibraryID(e.target.value)}
+ >
+
+ 选择要整理的媒体库
+
+ {libraries.map((l) => (
+
+ {l.name} ({l.type})
+
+ ))}
+
+
+ {running ? : }
+ 整理整个库
+
+
+
+
+
+ setQuery(e.target.value)}
+ />
+
+ {searching ? '搜索中…' : '搜索'}
+
+
+
+ {results.length > 0 && (
+
+ {results.map((m) => (
+
+
+ onOrganizeOne(m)}
+ disabled={busyID === m.id}
+ className="neon-button !px-3 !py-1 text-xs"
+ >
+ {busyID === m.id ? (
+
+ ) : (
+
+ )}
+ 整理
+
+
+ ))}
+
+ )}
+
+ {!searching && query && results.length === 0 && (
+
未找到匹配的媒体。
+ )}
+
+
+ )
+}
+
+function NotifyPanel() {
+ const [title, setTitle] = useState('MediaStation 测试通知')
+ const [body, setBody] = useState('如果你收到这条消息,说明通知渠道工作正常。')
+ const [sending, setSending] = useState(false)
+
+ const onSend = async (e: FormEvent) => {
+ e.preventDefault()
+ if (!title.trim() || !body.trim()) return
+ setSending(true)
+ try {
+ await toolsAPI.notifyTest(title.trim(), body.trim())
+ toast.success('已派发到所有已启用通道')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '发送失败'
+ toast.error(msg)
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index 6ffc371..4b6a623 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -271,6 +271,27 @@ export interface NotifyProviderInfo {
description: string
}
+// ─── Play Profiles ──────────────────────────────────────────────────────────
+
+export interface PlayProfile {
+ id: string
+ user_id: string
+ name: string
+ is_default: boolean
+ content_rating_limit?: string
+ allow_adult: boolean
+ require_pin: boolean
+ preferred_subtitle_lang?: string
+ preferred_audio_lang?: string
+ autoplay_next: boolean
+ skip_intro: boolean
+ allowed_library_ids: string[]
+ total_watch_time: number
+ last_active_at?: string
+ created_at: string
+ updated_at: string
+}
+
// Scheduler task config
export interface SchedulerTaskConfig {
id: string
@@ -322,3 +343,50 @@ export interface AuthTypeInfo {
name: string
description: string
}
+
+// ─── History helpers ────────────────────────────────────────────────────────
+
+export interface HistoryItem {
+ id: string
+ user_id: string
+ media_id: string
+ position_ms: number
+ duration_ms: number
+ watched_at: string
+ completed: boolean
+ media?: Media
+}
+
+export interface HistoryStats {
+ total: number
+ completed: number
+ watched_ms: number
+ watched_hours: number
+ last_watched?: string
+}
+
+// ─── Discover ───────────────────────────────────────────────────────────────
+
+export interface DiscoverSection {
+ key: string
+ label: string
+}
+
+export interface DiscoverItem {
+ TMDbID?: number
+ Title?: string
+ Overview?: string
+ Rating?: number
+ Year?: number
+ PosterURL?: string
+ BackdropURL?: string
+ // Match struct (Go) is exported with capitalised JSON keys; the API
+ // returns lower-cased aliases below for convenience.
+ tmdb_id?: number
+ title?: string
+ overview?: string
+ rating?: number
+ year?: number
+ poster_url?: string
+ backdrop_url?: string
+}