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 */} +
+

智能搜索

+
+ setQuery(e.target.value)} + /> + +
+ +
+ {quickHints.map((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 */} +
+
+

为你推荐

+ +
+

+ 推荐基于你的最近观看历史。点击标题在媒体库中查找。 +

+ + {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 */} + + + {/* Conversation pane */} +
+ {!active && ( +
+ +

选择或创建一个会话开始对话

+
+ )} + {active && ( + <> +
+

+ {active.session.title || '未命名'} +

+
+
+ {active.messages.length === 0 && ( +

说点什么开始对话…

+ )} + {active.messages.map((m) => ( + + ))} +
+
+
+ setDraft(e.target.value)} + disabled={sending} + /> + +
+ + )} +
+
+ ) +} + +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 · 多客户端 + 连接测试 +

+
+
+ +
+ + {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}`} +
+
+
+ + + +
+
+ ))} +
+ )} + + {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 ? '编辑下载器' : '添加下载器'} +

+
+ + update('name', e.target.value)} + /> + + + + + + update('url', e.target.value)} + /> + + {form.type !== 'aria2' && ( + <> + + update('username', e.target.value)} + /> + + + update('password', e.target.value)} + /> + + + )} + {form.type === 'aria2' && ( + + update('password', e.target.value)} + /> + + )} + + update('save_path', e.target.value)} + /> + +
+ + +
+
+ + +
+
+
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ) +} 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 ( +
+
+
+
+ +
+
+

许可证管理

+

+ 生成密钥 · 绑定设备 · 心跳监控 · 吊销 +

+
+
+ +
+ + {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()} 到期` : ' 永久'} +
+
+
+ + {!k.revoked && ( + + )} +
+
+ {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 && ( + + )} +
+ ))} +
+ )} +
+ ))} +
+ )} + + {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 ( +
+
+

生成密钥

+
+ + + + +