mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
feat: complete Vue UI parity (full backend + frontend)
== New domain models (7) ==
- UserPermission: per-user feature toggles (13 booleans)
- StorageConfig: encrypted Alist/S3/WebDAV adapters
- LicenseKey + LicenseActivation: offline license issuance
- DownloadClient: multi-client downloader configs
- AssistantSession + AssistantMessage: multi-turn AI chat persistence
== New services (5) ==
- PermissionService: admin grants always-true; user defaults seeded
- StorageConfigService: AES-GCM encryption + per-type connection probe
- LicenseService: 24-char hyphenated key generation, activation/heartbeat
- DownloadClientService: qB/Aria2/Transmission CRUD + WebUI test
- AssistantService: chat history + execute/undo stubs (op_id tracking)
== Extended AIService.Chat ==
- Multi-turn LLM call with chat history; offline fallback reply
== New endpoints (60+) ==
Auth:
POST /auth/refresh, /auth/logout, /auth/change-password
PATCH /auth/profile
GET /auth/permissions, /auth/me
Permissions admin:
GET/PUT /admin/users/:id/permissions
POST /admin/users/:id/permissions/reset
Search:
GET /search, /search/advanced, /search/tmdb, /search/sites
System:
GET /system/config, /settings/schema, /system/events/ticket
POST /admin/system/scheduler/:name/trigger
Stats:
GET /stats/user/:id, /stats/top-users
POST /stats/play
Sites:
GET /sites/:id/resource, /sites/:id/userdata
Subscriptions:
PUT /subscriptions/:id, POST /subscriptions/:id/search
Playlists:
POST /playlists/:id/reorder
DELETE /playlists/:id/items/by-id/:item_id
DLNA per-renderer:
POST /dlna/:uuid/{play,pause,stop}, GET /dlna/:uuid/status
Media:
POST/DELETE /media/:id/favorite, GET /media/:id/favorite/status
POST /media/:id/ai-scrape, /media/scrape/test, /media/organize
GET /favorites (alias)
Playback:
GET /playback/:id/info, /playback/:id/external-players, /playback/:id/external-url
POST /playback/:id/progress
GET /playback/transcode/:job_id/status
Downloads:
POST /download/:id/{pause,resume,organize}
POST /download/{organize,sync,start-auto-sync}
GET /download/tasks
Admin: full CRUD on /admin/download/clients + /admin/download/aria2/stats
License:
POST /license/{activate,heartbeat}
GET /license/{status,heartbeat-status}
Admin: /admin/license/{generate,list,:id/activations,:id/revoke,activation/:id/unbind}
Storage:
GET /admin/storage/{status,:type}
PUT /admin/storage/:type, POST /admin/storage/:type/test
Assistant (multi-turn AI):
GET/POST /admin/assistant/sessions
GET/DELETE /admin/assistant/session/:id
POST /admin/assistant/{chat,execute}
POST /admin/assistant/undo/:op_id
GET /admin/assistant/history
== New React pages (4) ==
- AssistantChatPage (/assistant): full multi-turn chat UI with sessions
sidebar, optimistic user-turn append, live AI response.
- DownloadClientsPage (/download-clients): typed CRUD form for
qBittorrent / Aria2 / Transmission + per-row Test action.
- LicensePage (/license): generate keys, list activations, revoke,
unbind individual devices.
- StorageConfigPage (/storage-config): tabbed Alist/WebDAV/S3 form
with secret-aware redaction + connection probe.
== New API helpers (5) ==
- assistant, download_clients, license, permissions, storage_config
== Layout ==
- Sidebar gains 4 new admin links (AI 对话, 下载器, 外部存储, 许可证).
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,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)
|
||||
}
|
||||
@@ -171,6 +171,90 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
|
||||
// ── Auth extras ──
|
||||
authed.POST("/auth/refresh", refreshHandler(svc))
|
||||
authed.POST("/auth/logout", logoutHandler(svc))
|
||||
authed.GET("/auth/me", meHandler(svc))
|
||||
authed.PATCH("/auth/profile", updateProfileHandler(svc))
|
||||
authed.POST("/auth/change-password", changePasswordHandler(svc))
|
||||
authed.GET("/auth/permissions", myPermissionsHandler(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.
|
||||
@@ -184,6 +268,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))
|
||||
|
||||
@@ -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,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,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,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,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,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))})
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,104 @@ type PlayProfile struct {
|
||||
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{}{
|
||||
@@ -294,5 +392,12 @@ func AllModels() []interface{} {
|
||||
&APIConfig{},
|
||||
&NotifyChannel{},
|
||||
&PlayProfile{},
|
||||
&UserPermission{},
|
||||
&StorageConfig{},
|
||||
&LicenseKey{},
|
||||
&LicenseActivation{},
|
||||
&DownloadClient{},
|
||||
&AssistantSession{},
|
||||
&AssistantMessage{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,25 +32,35 @@ type Container struct {
|
||||
Log *AccessLogRepository
|
||||
NotifyChannel *NotifyChannelRepository
|
||||
PlayProfile *PlayProfileRepository
|
||||
Permission *PermissionRepository
|
||||
StorageConfig *StorageConfigRepository
|
||||
License *LicenseRepository
|
||||
DownloadClient *DownloadClientRepository
|
||||
Assistant *AssistantRepository
|
||||
}
|
||||
|
||||
// New wires every repository to a single *gorm.DB.
|
||||
func New(db *gorm.DB) *Container {
|
||||
return &Container{
|
||||
DB: db,
|
||||
User: &UserRepository{db: db},
|
||||
Library: &LibraryRepository{db: db},
|
||||
Media: &MediaRepository{db: db},
|
||||
Series: &SeriesRepository{db: db},
|
||||
History: &HistoryRepository{db: db},
|
||||
Favorite: &FavoriteRepository{db: db},
|
||||
Playlist: &PlaylistRepository{db: db},
|
||||
Download: &DownloadRepository{db: db},
|
||||
Subscription: &SubscriptionRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
Log: &AccessLogRepository{db: db},
|
||||
NotifyChannel: &NotifyChannelRepository{db: db},
|
||||
PlayProfile: &PlayProfileRepository{db: db},
|
||||
DB: db,
|
||||
User: &UserRepository{db: db},
|
||||
Library: &LibraryRepository{db: db},
|
||||
Media: &MediaRepository{db: db},
|
||||
Series: &SeriesRepository{db: db},
|
||||
History: &HistoryRepository{db: db},
|
||||
Favorite: &FavoriteRepository{db: db},
|
||||
Playlist: &PlaylistRepository{db: db},
|
||||
Download: &DownloadRepository{db: db},
|
||||
Subscription: &SubscriptionRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
Log: &AccessLogRepository{db: db},
|
||||
NotifyChannel: &NotifyChannelRepository{db: db},
|
||||
PlayProfile: &PlayProfileRepository{db: db},
|
||||
Permission: &PermissionRepository{db: db},
|
||||
StorageConfig: &StorageConfigRepository{db: db},
|
||||
License: &LicenseRepository{db: db},
|
||||
DownloadClient: &DownloadClientRepository{db: db},
|
||||
Assistant: &AssistantRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,3 +511,253 @@ func (r *PlayProfileRepository) Update(ctx context.Context, id string, patch map
|
||||
func (r *PlayProfileRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.PlayProfile{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
|
||||
// ─── Permissions ─────────────────────────────────────────────────────────────
|
||||
|
||||
// PermissionRepository persists model.UserPermission.
|
||||
type PermissionRepository struct{ db *gorm.DB }
|
||||
|
||||
// Get returns the row, or (nil, nil) when not yet seeded.
|
||||
func (r *PermissionRepository) Get(ctx context.Context, userID string) (*model.UserPermission, error) {
|
||||
var p model.UserPermission
|
||||
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Save upserts the row keyed by UserID.
|
||||
func (r *PermissionRepository) Save(ctx context.Context, p *model.UserPermission) error {
|
||||
return r.db.WithContext(ctx).Save(p).Error
|
||||
}
|
||||
|
||||
// Delete removes the row (used when a user is deleted).
|
||||
func (r *PermissionRepository) Delete(ctx context.Context, userID string) error {
|
||||
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
|
||||
}
|
||||
|
||||
// ─── Storage Config ──────────────────────────────────────────────────────────
|
||||
|
||||
// StorageConfigRepository persists model.StorageConfig records (Alist / S3 / WebDAV).
|
||||
type StorageConfigRepository struct{ db *gorm.DB }
|
||||
|
||||
// Get returns the config for the given type, or (nil, nil).
|
||||
func (r *StorageConfigRepository) Get(ctx context.Context, kind string) (*model.StorageConfig, error) {
|
||||
var s model.StorageConfig
|
||||
err := r.db.WithContext(ctx).Where("type = ?", kind).First(&s).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Upsert inserts or updates the row keyed by Type.
|
||||
func (r *StorageConfigRepository) Upsert(ctx context.Context, s *model.StorageConfig) error {
|
||||
return r.db.WithContext(ctx).Where("type = ?", s.Type).
|
||||
Assign(map[string]any{
|
||||
"config": s.Config,
|
||||
"enabled": s.Enabled,
|
||||
"last_error": s.LastError,
|
||||
}).
|
||||
FirstOrCreate(s).Error
|
||||
}
|
||||
|
||||
// List returns every storage config (admin overview).
|
||||
func (r *StorageConfigRepository) List(ctx context.Context) ([]model.StorageConfig, error) {
|
||||
var rows []model.StorageConfig
|
||||
err := r.db.WithContext(ctx).Order("type asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ─── License ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// LicenseRepository persists model.LicenseKey and model.LicenseActivation.
|
||||
type LicenseRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new license key.
|
||||
func (r *LicenseRepository) Create(ctx context.Context, k *model.LicenseKey) error {
|
||||
return r.db.WithContext(ctx).Create(k).Error
|
||||
}
|
||||
|
||||
// FindByKey returns the license key matching the value, or (nil, nil).
|
||||
func (r *LicenseRepository) FindByKey(ctx context.Context, key string) (*model.LicenseKey, error) {
|
||||
var k model.LicenseKey
|
||||
err := r.db.WithContext(ctx).Where("key = ?", key).First(&k).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// FindByID returns the license key by primary key.
|
||||
func (r *LicenseRepository) FindByID(ctx context.Context, id string) (*model.LicenseKey, error) {
|
||||
var k model.LicenseKey
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&k).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// List returns every license key (admin view).
|
||||
func (r *LicenseRepository) List(ctx context.Context) ([]model.LicenseKey, error) {
|
||||
var rows []model.LicenseKey
|
||||
err := r.db.WithContext(ctx).Order("issued_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Update applies a partial patch (revoke / extend expiry, etc.).
|
||||
func (r *LicenseRepository) Update(ctx context.Context, id string, patch map[string]any) error {
|
||||
return r.db.WithContext(ctx).Model(&model.LicenseKey{}).
|
||||
Where("id = ?", id).Updates(patch).Error
|
||||
}
|
||||
|
||||
// AddActivation creates an activation entry for a key.
|
||||
func (r *LicenseRepository) AddActivation(ctx context.Context, a *model.LicenseActivation) error {
|
||||
return r.db.WithContext(ctx).Create(a).Error
|
||||
}
|
||||
|
||||
// CountActiveActivations counts non-unbound activations for a key.
|
||||
func (r *LicenseRepository) CountActiveActivations(ctx context.Context, keyID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&model.LicenseActivation{}).
|
||||
Where("key_id = ? AND unbound_at IS NULL", keyID).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListActivations returns all activations for a key.
|
||||
func (r *LicenseRepository) ListActivations(ctx context.Context, keyID string) ([]model.LicenseActivation, error) {
|
||||
var rows []model.LicenseActivation
|
||||
err := r.db.WithContext(ctx).Where("key_id = ?", keyID).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// UnbindActivation marks an activation as unbound (soft).
|
||||
func (r *LicenseRepository) UnbindActivation(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.LicenseActivation{}).
|
||||
Where("id = ?", id).Update("unbound_at", &now).Error
|
||||
}
|
||||
|
||||
// TouchHeartbeat bumps the heartbeat_at column.
|
||||
func (r *LicenseRepository) TouchHeartbeat(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.LicenseActivation{}).
|
||||
Where("id = ?", id).Update("heartbeat_at", &now).Error
|
||||
}
|
||||
|
||||
// ─── Download Clients ────────────────────────────────────────────────────────
|
||||
|
||||
// DownloadClientRepository persists model.DownloadClient records.
|
||||
type DownloadClientRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new client config.
|
||||
func (r *DownloadClientRepository) Create(ctx context.Context, c *model.DownloadClient) error {
|
||||
return r.db.WithContext(ctx).Create(c).Error
|
||||
}
|
||||
|
||||
// FindByID returns one client by ID.
|
||||
func (r *DownloadClientRepository) FindByID(ctx context.Context, id string) (*model.DownloadClient, error) {
|
||||
var c model.DownloadClient
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns every configured client.
|
||||
func (r *DownloadClientRepository) List(ctx context.Context) ([]model.DownloadClient, error) {
|
||||
var rows []model.DownloadClient
|
||||
err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Update applies a partial patch.
|
||||
func (r *DownloadClientRepository) Update(ctx context.Context, id string, patch map[string]any) error {
|
||||
return r.db.WithContext(ctx).Model(&model.DownloadClient{}).
|
||||
Where("id = ?", id).Updates(patch).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes one client.
|
||||
func (r *DownloadClientRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.DownloadClient{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// ─── Assistant ───────────────────────────────────────────────────────────────
|
||||
|
||||
// AssistantRepository persists AssistantSession + AssistantMessage rows.
|
||||
type AssistantRepository struct{ db *gorm.DB }
|
||||
|
||||
// CreateSession inserts a new session.
|
||||
func (r *AssistantRepository) CreateSession(ctx context.Context, s *model.AssistantSession) error {
|
||||
return r.db.WithContext(ctx).Create(s).Error
|
||||
}
|
||||
|
||||
// FindSession returns the session row + ownership.
|
||||
func (r *AssistantRepository) FindSession(ctx context.Context, id string) (*model.AssistantSession, error) {
|
||||
var s model.AssistantSession
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ListSessions returns sessions for the user (or all for admins).
|
||||
func (r *AssistantRepository) ListSessions(ctx context.Context, userID string) ([]model.AssistantSession, error) {
|
||||
var rows []model.AssistantSession
|
||||
q := r.db.WithContext(ctx).Order("updated_at desc")
|
||||
if userID != "" {
|
||||
q = q.Where("user_id = ?", userID)
|
||||
}
|
||||
err := q.Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// DeleteSession removes one session and cascades to its messages.
|
||||
func (r *AssistantRepository) DeleteSession(ctx context.Context, id string) error {
|
||||
if err := r.db.WithContext(ctx).Where("session_id = ?", id).Delete(&model.AssistantMessage{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.db.WithContext(ctx).Delete(&model.AssistantSession{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// AppendMessage inserts a message in the given session.
|
||||
func (r *AssistantRepository) AppendMessage(ctx context.Context, m *model.AssistantMessage) error {
|
||||
if err := r.db.WithContext(ctx).Create(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Bump the parent session's updated_at so list ordering reflects activity.
|
||||
return r.db.WithContext(ctx).Model(&model.AssistantSession{}).
|
||||
Where("id = ?", m.SessionID).Update("updated_at", time.Now()).Error
|
||||
}
|
||||
|
||||
// ListMessages returns the transcript ordered by creation time.
|
||||
func (r *AssistantRepository) ListMessages(ctx context.Context, sessionID string) ([]model.AssistantMessage, error) {
|
||||
var rows []model.AssistantMessage
|
||||
err := r.db.WithContext(ctx).Where("session_id = ?", sessionID).
|
||||
Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -55,6 +55,11 @@ type Container struct {
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
License *LicenseService
|
||||
DownloadClients *DownloadClientService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Site *SiteService
|
||||
@@ -93,6 +98,11 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
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)
|
||||
@@ -140,6 +150,11 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
License: licenseSvc,
|
||||
DownloadClients: downloadClients,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Site: siteService,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -93,6 +93,18 @@ const NotifyChannelsPage = lazy(() =>
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
)
|
||||
const AssistantChatPage = lazy(() =>
|
||||
import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
|
||||
)
|
||||
const DownloadClientsPage = lazy(() =>
|
||||
import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
|
||||
)
|
||||
const LicensePage = lazy(() =>
|
||||
import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })),
|
||||
)
|
||||
const StorageConfigPage = lazy(() =>
|
||||
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -216,6 +228,38 @@ export default function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="assistant"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AssistantChatPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="download-clients"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<DownloadClientsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="license"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<LicensePage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="storage-config"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<StorageConfigPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="stats"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface AssistantSession {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
id: string
|
||||
session_id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
operation_id?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
session: AssistantSession
|
||||
messages: AssistantMessage[]
|
||||
}
|
||||
|
||||
export const assistantAPI = {
|
||||
listSessions: () =>
|
||||
api.get<AssistantSession[]>('/admin/assistant/sessions').then((r) => r.data),
|
||||
|
||||
createSession: (title?: string) =>
|
||||
api
|
||||
.post<AssistantSession>('/admin/assistant/sessions', { title })
|
||||
.then((r) => r.data),
|
||||
|
||||
getSession: (id: string) =>
|
||||
api.get<SessionView>(`/admin/assistant/session/${id}`).then((r) => r.data),
|
||||
|
||||
deleteSession: (id: string) =>
|
||||
api.delete(`/admin/assistant/session/${id}`).then((r) => r.data),
|
||||
|
||||
chat: (sessionID: string, message: string) =>
|
||||
api
|
||||
.post<SessionView>('/admin/assistant/chat', {
|
||||
session_id: sessionID,
|
||||
message,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
execute: (sessionID: string, action: Record<string, unknown>) =>
|
||||
api
|
||||
.post<{ op_id: string }>('/admin/assistant/execute', {
|
||||
session_id: sessionID,
|
||||
action,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
undo: (opID: string) =>
|
||||
api.post(`/admin/assistant/undo/${opID}`).then((r) => r.data),
|
||||
|
||||
history: () =>
|
||||
api
|
||||
.get<{ items: { op_id: string; session: string; created_at: string; content: string }[] }>(
|
||||
'/admin/assistant/history',
|
||||
)
|
||||
.then((r) => r.data.items),
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
|
||||
|
||||
export interface DownloadClient {
|
||||
id: string
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
url: string
|
||||
username?: string
|
||||
save_path?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DownloadClientInput {
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
save_path?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export const downloadClientsAPI = {
|
||||
list: () =>
|
||||
api.get<DownloadClient[]>('/admin/download/clients').then((r) => r.data),
|
||||
|
||||
create: (input: DownloadClientInput) =>
|
||||
api.post<DownloadClient>('/admin/download/clients', input).then((r) => r.data),
|
||||
|
||||
update: (id: string, input: DownloadClientInput) =>
|
||||
api
|
||||
.put<DownloadClient>(`/admin/download/clients/${id}`, input)
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/admin/download/clients/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/admin/download/clients/${id}/test`)
|
||||
.then((r) => r.data),
|
||||
|
||||
aria2Stats: (clientID: string) =>
|
||||
api
|
||||
.get('/admin/download/aria2/stats', { params: { client_id: clientID } })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { api } from './client'
|
||||
|
||||
// LicenseKey + LicenseActivation mirror the Go model structs.
|
||||
export interface LicenseKey {
|
||||
id: string
|
||||
key: string
|
||||
customer?: string
|
||||
plan: string
|
||||
max_activations: number
|
||||
issued_at: string
|
||||
expires_at?: string | null
|
||||
revoked: boolean
|
||||
notes?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LicenseActivation {
|
||||
id: string
|
||||
key_id: string
|
||||
device_id: string
|
||||
device_name?: string
|
||||
ip?: string
|
||||
unbound_at?: string | null
|
||||
heartbeat_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GenerateKeyInput {
|
||||
customer?: string
|
||||
plan?: string
|
||||
max_activations?: number
|
||||
expires_at?: string // RFC3339; "" or omit for perpetual
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export const licenseAPI = {
|
||||
generate: (input: GenerateKeyInput) =>
|
||||
api.post<LicenseKey>('/admin/license/generate', input).then((r) => r.data),
|
||||
|
||||
list: () => api.get<LicenseKey[]>('/admin/license/list').then((r) => r.data),
|
||||
|
||||
listActivations: (keyID: string) =>
|
||||
api
|
||||
.get<LicenseActivation[]>(`/admin/license/${keyID}/activations`)
|
||||
.then((r) => r.data),
|
||||
|
||||
revoke: (keyID: string) =>
|
||||
api.post(`/admin/license/${keyID}/revoke`).then((r) => r.data),
|
||||
|
||||
unbind: (activationID: string) =>
|
||||
api.post(`/admin/license/activation/${activationID}/unbind`).then((r) => r.data),
|
||||
|
||||
// Self-service
|
||||
activate: (key: string, deviceID: string, deviceName?: string) =>
|
||||
api
|
||||
.post<LicenseActivation>('/license/activate', {
|
||||
key,
|
||||
device_id: deviceID,
|
||||
device_name: deviceName,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
status: (keyID: string) =>
|
||||
api
|
||||
.get<{ key: LicenseKey; active_activations: number; valid: boolean }>(
|
||||
'/license/status',
|
||||
{ params: { key_id: keyID } },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface UserPermission {
|
||||
user_id: string
|
||||
can_play_media: boolean
|
||||
can_favorite: boolean
|
||||
can_view_history: boolean
|
||||
can_view_dashboard: boolean
|
||||
can_view_discover: boolean
|
||||
can_manage_downloads: boolean
|
||||
can_manage_subscriptions: boolean
|
||||
can_manage_sites: boolean
|
||||
can_manage_files: boolean
|
||||
can_manage_strm: boolean
|
||||
can_cast: boolean
|
||||
can_use_ai_assistant: boolean
|
||||
can_access_settings: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const permissionsAPI = {
|
||||
// Caller's effective permissions; admins always get the all-true set.
|
||||
mine: () => api.get<UserPermission>('/auth/permissions').then((r) => r.data),
|
||||
|
||||
// Admin endpoints
|
||||
get: (userID: string) =>
|
||||
api.get<UserPermission>(`/admin/users/${userID}/permissions`).then((r) => r.data),
|
||||
|
||||
save: (userID: string, p: UserPermission) =>
|
||||
api
|
||||
.put<UserPermission>(`/admin/users/${userID}/permissions`, p)
|
||||
.then((r) => r.data),
|
||||
|
||||
reset: (userID: string) =>
|
||||
api
|
||||
.post<UserPermission>(`/admin/users/${userID}/permissions/reset`)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type StorageType = 'alist' | 's3' | 'webdav'
|
||||
|
||||
export interface StorageConfig {
|
||||
id: string
|
||||
type: StorageType
|
||||
config: Record<string, string>
|
||||
enabled: boolean
|
||||
last_error?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const storageAPI = {
|
||||
status: () =>
|
||||
api
|
||||
.get<{ items: StorageConfig[] }>('/admin/storage/status')
|
||||
.then((r) => r.data.items),
|
||||
|
||||
get: (type: StorageType) =>
|
||||
api.get<StorageConfig>(`/admin/storage/${type}`).then((r) => r.data),
|
||||
|
||||
save: (type: StorageType, config: Record<string, string>, enabled = true) =>
|
||||
api
|
||||
.put<StorageConfig>(`/admin/storage/${type}`, { type, config, enabled })
|
||||
.then((r) => r.data),
|
||||
|
||||
test: (type: StorageType, config: Record<string, string>) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
|
||||
type,
|
||||
config,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -16,15 +16,19 @@ import {
|
||||
Heart,
|
||||
Home,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Rss,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Cloud,
|
||||
Trash2,
|
||||
UserCog,
|
||||
Wrench,
|
||||
@@ -118,6 +122,10 @@ export function Layout() {
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
||||
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="许可证" />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
assistantAPI,
|
||||
type AssistantMessage,
|
||||
type AssistantSession,
|
||||
type SessionView,
|
||||
} from '../api/assistant'
|
||||
|
||||
// AssistantChatPage is the multi-turn chat surface backed by the Go
|
||||
// AssistantService. It complements the older AIAssistantPage which is
|
||||
// limited to single-turn smart search + recommendations.
|
||||
export function AssistantChatPage() {
|
||||
const [sessions, setSessions] = useState<AssistantSession[]>([])
|
||||
const [active, setActive] = useState<SessionView | null>(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
const list = await assistantAPI.listSessions()
|
||||
setSessions(list)
|
||||
// Auto-select the most recent if nothing is open.
|
||||
if (list.length > 0 && !active) {
|
||||
const view = await assistantAPI.getSession(list[0].id)
|
||||
setActive(view)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [active?.messages.length])
|
||||
|
||||
const onNew = async () => {
|
||||
try {
|
||||
const sess = await assistantAPI.createSession('New chat')
|
||||
const view = await assistantAPI.getSession(sess.id)
|
||||
setActive(view)
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
toast.error('创建会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSelect = async (id: string) => {
|
||||
try {
|
||||
setActive(await assistantAPI.getSession(id))
|
||||
} catch {
|
||||
toast.error('加载会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!confirm('删除此会话?')) return
|
||||
try {
|
||||
await assistantAPI.deleteSession(id)
|
||||
if (active?.session.id === id) setActive(null)
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSend = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!draft.trim() || !active) return
|
||||
setSending(true)
|
||||
const text = draft.trim()
|
||||
setDraft('')
|
||||
// Optimistic append so the user's turn shows immediately.
|
||||
setActive((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: 'pending-' + Date.now(),
|
||||
session_id: s.session.id,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
} as AssistantMessage,
|
||||
],
|
||||
}
|
||||
: s,
|
||||
)
|
||||
try {
|
||||
const view = await assistantAPI.chat(active.session.id, text)
|
||||
setActive(view)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'发送失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-100px)] grid-cols-[260px_1fr] gap-4">
|
||||
{/* Sessions sidebar */}
|
||||
<aside className="glass-panel flex flex-col overflow-hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-display text-sm font-semibold text-white">会话</h2>
|
||||
<button onClick={onNew} className="neon-button !px-2 !py-1 text-xs">
|
||||
<Plus size={12} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-6 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 space-y-1 overflow-y-auto pr-1">
|
||||
{sessions.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className={
|
||||
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
||||
(active?.session.id === s.id
|
||||
? 'bg-primary-400/10 text-primary-400'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white')
|
||||
}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="flex-1 truncate">{s.title || '未命名'}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(s.id)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} className="text-red-400" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!loading && sessions.length === 0 && (
|
||||
<li className="px-2 py-2 text-xs text-slate-500">暂无会话</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Conversation pane */}
|
||||
<section className="glass-panel flex flex-col overflow-hidden">
|
||||
{!active && (
|
||||
<div className="m-auto text-center text-slate-400">
|
||||
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>选择或创建一个会话开始对话</p>
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<>
|
||||
<div className="mb-3 border-b border-white/5 pb-2">
|
||||
<h2 className="font-display text-base font-semibold text-white">
|
||||
{active.session.title || '未命名'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto pr-2">
|
||||
{active.messages.length === 0 && (
|
||||
<p className="text-sm text-slate-500">说点什么开始对话…</p>
|
||||
)}
|
||||
{active.messages.map((m) => (
|
||||
<Bubble key={m.id} message={m} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-white/5 pt-3">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="发送消息…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button type="submit" disabled={sending || !draft.trim()} className="neon-button">
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({ message }: { message: AssistantMessage }) {
|
||||
const mine = message.role === 'user'
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={
|
||||
'max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2 text-sm ' +
|
||||
(mine
|
||||
? 'bg-primary-400/20 text-primary-100'
|
||||
: message.role === 'system'
|
||||
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
|
||||
: 'bg-white/5 text-slate-200')
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
type DownloadClientInput,
|
||||
type DownloadClientType,
|
||||
} from '../api/download_clients'
|
||||
|
||||
// DownloadClientsPage manages multiple downloader integrations.
|
||||
// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
|
||||
// surface and a per-client Test button.
|
||||
export function DownloadClientsPage() {
|
||||
const [clients, setClients] = useState<DownloadClient[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<DownloadClient | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setClients(await downloadClientsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
try {
|
||||
const r = await downloadClientsAPI.test(id)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (c: DownloadClient) => {
|
||||
if (!confirm(`确定删除「${c.name}」?`)) return
|
||||
try {
|
||||
await downloadClientsAPI.remove(c.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'删除失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-400/10 text-cyan-300">
|
||||
<Server size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">下载器管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加下载器
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无下载器</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="glass-panel flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{c.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{c.type}
|
||||
</span>
|
||||
{c.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!c.enabled && (
|
||||
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
{c.url}
|
||||
{c.username && ` · ${c.username}`}
|
||||
{c.save_path && ` · ${c.save_path}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(c.id)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(c)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(c)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ClientFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: DownloadClient | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<DownloadClientInput>(() => ({
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'qbittorrent',
|
||||
url: editing?.url ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
save_path: editing?.save_path ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const update = <K extends keyof DownloadClientInput>(k: K, v: DownloadClientInput[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const placeholder = (
|
||||
{
|
||||
qbittorrent: 'http://127.0.0.1:8080',
|
||||
aria2: 'http://127.0.0.1:6800/jsonrpc',
|
||||
transmission: 'http://127.0.0.1:9091/transmission/rpc',
|
||||
} as Record<DownloadClientType, string>
|
||||
)[form.type]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">
|
||||
{editing ? '编辑下载器' : '添加下载器'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) => update('type', e.target.value as DownloadClientType)}
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="aria2">Aria2 (JSON-RPC)</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={form.url}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{form.type !== 'aria2' && (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.username ?? ''}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'aria2' && (
|
||||
<Field label="RPC Token (作为密码字段保存)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="默认保存路径">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.save_path ?? ''}
|
||||
onChange={(e) => update('save_path', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => update('is_default', e.target.checked)}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
KeySquare,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
licenseAPI,
|
||||
type GenerateKeyInput,
|
||||
type LicenseActivation,
|
||||
type LicenseKey,
|
||||
} from '../api/license'
|
||||
|
||||
// LicensePage is the admin UI for issuing / revoking license keys and
|
||||
// inspecting activations. Mirrors the Vue LicenseTab inside Settings.
|
||||
export function LicensePage() {
|
||||
const [keys, setKeys] = useState<LicenseKey[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showGen, setShowGen] = useState(false)
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
const [activations, setActivations] = useState<Record<string, LicenseActivation[]>>({})
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setKeys(await licenseAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const toggleOpen = async (k: LicenseKey) => {
|
||||
if (openKey === k.id) {
|
||||
setOpenKey(null)
|
||||
return
|
||||
}
|
||||
setOpenKey(k.id)
|
||||
if (!activations[k.id]) {
|
||||
try {
|
||||
const acts = await licenseAPI.listActivations(k.id)
|
||||
setActivations((a) => ({ ...a, [k.id]: acts }))
|
||||
} catch {
|
||||
toast.error('加载激活记录失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onRevoke = async (k: LicenseKey) => {
|
||||
if (!confirm(`确定吊销 ${k.key.slice(0, 14)}…?`)) return
|
||||
try {
|
||||
await licenseAPI.revoke(k.id)
|
||||
toast.success('已吊销')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'吊销失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onUnbind = async (a: LicenseActivation) => {
|
||||
if (!confirm(`解绑设备 ${a.device_name || a.device_id}?`)) return
|
||||
try {
|
||||
await licenseAPI.unbind(a.id)
|
||||
toast.success('已解绑')
|
||||
const acts = await licenseAPI.listActivations(a.key_id)
|
||||
setActivations((all) => ({ ...all, [a.key_id]: acts }))
|
||||
} catch {
|
||||
toast.error('解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
|
||||
<KeySquare size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">许可证管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
生成密钥 · 绑定设备 · 心跳监控 · 吊销
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowGen(true)} className="neon-button">
|
||||
<Plus size={16} /> 生成新密钥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && keys.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无密钥</div>
|
||||
)}
|
||||
|
||||
{!loading && keys.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="glass-panel space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-white/5 px-2 py-0.5 text-xs text-primary-400">
|
||||
{k.key}
|
||||
</code>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{k.plan}
|
||||
</span>
|
||||
{k.revoked && (
|
||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
已吊销
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{k.customer && `客户: ${k.customer} · `}
|
||||
最多 {k.max_activations} 设备 ·
|
||||
{k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => toggleOpen(k)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
{openKey === k.id ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{' '}激活记录
|
||||
</button>
|
||||
{!k.revoked && (
|
||||
<button
|
||||
onClick={() => onRevoke(k)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<ShieldOff size={12} className="inline" /> 吊销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{openKey === k.id && (
|
||||
<div className="space-y-2 border-t border-white/5 pt-3">
|
||||
{(activations[k.id] ?? []).length === 0 && (
|
||||
<p className="text-sm text-slate-500">暂无激活记录</p>
|
||||
)}
|
||||
{(activations[k.id] ?? []).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded border border-white/5 bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-white">
|
||||
{a.device_name || a.device_id}
|
||||
{a.unbound_at && (
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
(已解绑 {new Date(a.unbound_at).toLocaleDateString()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{a.ip} · 心跳{' '}
|
||||
{a.heartbeat_at
|
||||
? new Date(a.heartbeat_at).toLocaleString()
|
||||
: '未上报'}
|
||||
</div>
|
||||
</div>
|
||||
{!a.unbound_at && (
|
||||
<button
|
||||
onClick={() => onUnbind(a)}
|
||||
className="shrink-0 rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-red-400/40 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 解绑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGen && (
|
||||
<GenerateModal
|
||||
onClose={() => setShowGen(false)}
|
||||
onCreated={async () => {
|
||||
setShowGen(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GenerateModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<GenerateKeyInput>({
|
||||
customer: '',
|
||||
plan: 'basic',
|
||||
max_activations: 1,
|
||||
expires_at: '',
|
||||
notes: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const k = await licenseAPI.generate({
|
||||
...form,
|
||||
expires_at: form.expires_at ? new Date(form.expires_at).toISOString() : undefined,
|
||||
})
|
||||
toast.success(`已生成: ${k.key}`)
|
||||
await onCreated()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'生成失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-md">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">生成密钥</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">客户</span>
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.customer ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customer: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">套餐</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.plan ?? 'basic'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, plan: e.target.value }))}
|
||||
>
|
||||
<option value="basic">basic</option>
|
||||
<option value="pro">pro</option>
|
||||
<option value="enterprise">enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">最多设备数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-base"
|
||||
value={form.max_activations ?? 1}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, max_activations: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">到期日期 (空 = 永久)</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input-base"
|
||||
value={form.expires_at ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expires_at: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">备注</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base"
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 生成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Cloud, Loader2, Save, Send } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { storageAPI, type StorageType } from '../api/storage_config'
|
||||
|
||||
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
|
||||
// the import / playback / STRM subsystems. Mirrors the Vue UI's
|
||||
// `admin/storage/*` tabs in a tabbed React surface.
|
||||
export function StorageConfigPage() {
|
||||
const [active, setActive] = useState<StorageType>('alist')
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-400/10 text-blue-300">
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">外部存储</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActive(t)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm uppercase ' +
|
||||
(active === t
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StorageForm key={active} type={active} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boolean; placeholder?: string }[]> = {
|
||||
alist: [
|
||||
{ key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
|
||||
{ key: 'token', label: 'Token', secret: true },
|
||||
],
|
||||
webdav: [
|
||||
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'password', label: '密码', secret: true },
|
||||
],
|
||||
s3: [
|
||||
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
|
||||
{ key: 'region', label: 'Region', placeholder: 'us-east-1' },
|
||||
{ key: 'bucket', label: 'Bucket' },
|
||||
{ key: 'access_key', label: 'Access Key', secret: true },
|
||||
{ key: 'secret_key', label: 'Secret Key', secret: true },
|
||||
{ key: 'force_path_style', label: 'force_path_style (true/false)' },
|
||||
],
|
||||
}
|
||||
|
||||
function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
const v = r.config?.[f.key]
|
||||
// List() redacts secrets to "********"; show empty so the user
|
||||
// doesn't accidentally save the placeholder.
|
||||
next[f.key] = v === '********' ? '' : v ?? ''
|
||||
}
|
||||
setConfig(next)
|
||||
setEnabled(r.enabled)
|
||||
} catch {
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) next[f.key] = ''
|
||||
setConfig(next)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await storageAPI.save(type, config, enabled)
|
||||
toast.success('已保存')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await storageAPI.test(type, config)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-slate-600">{f.key}</span>
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
className="input-base"
|
||||
placeholder={f.placeholder}
|
||||
value={config[f.key] ?? ''}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||
{' '}测试
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user