Resolve merge conflicts: merge all Go backend and React frontend changes

This commit is contained in:
ShukeBta
2026-05-16 22:48:30 +08:00
66 changed files with 8526 additions and 40 deletions
+147
View File
@@ -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})
}
}
+44
View File
@@ -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)
}
}
+79
View File
@@ -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 ""
}
}
+129
View File
@@ -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", `<Speed>1</Speed>`)
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(
`<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:%s xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>%s
</u:%s>
</s:Body>
</s:Envelope>`, action, extra, action,
)
}
+90
View File
@@ -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)
}
}
+117
View File
@@ -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)
}
+180 -34
View File
@@ -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))
+145
View File
@@ -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)
}
}
+70
View File
@@ -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,
})
}
}
+141
View File
@@ -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})
}
}
+73
View File
@@ -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"})
}
}
+74
View File
@@ -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)
}
}
+86
View File
@@ -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)
}
}
+130
View File
@@ -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{}
+55
View File
@@ -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)
}
}
+106
View File
@@ -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})
}
}
+148
View File
@@ -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)})
}
}
+61
View File
@@ -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",
})
}
}
+155
View File
@@ -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)
}
}
+111
View File
@@ -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})
}
}
+75
View File
@@ -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})
}
}
+61
View File
@@ -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})
}
}
+159
View File
@@ -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))})
}
}
+66
View File
@@ -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()})
}
}
+162
View File
@@ -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)
}
}
+176 -2
View File
@@ -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{},
}
}
+77
View File
@@ -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 后重试。"
}
+209
View File
@@ -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
}
+7
View File
@@ -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
}
+7
View File
@@ -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)))
+195
View File
@@ -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
}
+160
View File
@@ -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()
}
+320
View File
@@ -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("<b>%s</b>\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))
}
+111
View File
@@ -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
}
+217
View File
@@ -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[:])
}
+22 -2
View File
@@ -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,
+210
View File
@@ -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"
}
+209
View File
@@ -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))
}