mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 12:26:36 +08:00
Resolve merge conflicts: merge all Go backend and React frontend changes
This commit is contained in:
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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))})
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 后重试。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user