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:
Kiro Agent
2026-05-16 09:49:35 +00:00
parent 7095d9ebec
commit 1dbd73b30b
38 changed files with 4496 additions and 14 deletions
+147
View File
@@ -0,0 +1,147 @@
// Package handler — multi-turn AI assistant chat endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listAssistantSessionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
role, _ := c.Get(middleware.CtxUserRole)
rows, err := svc.Assistant.ListSessions(
c.Request.Context(), toString(uid), role == "admin",
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
type createSessionReq struct {
Title string `json:"title"`
}
func createAssistantSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req createSessionReq
_ = c.ShouldBindJSON(&req)
uid, _ := c.Get(middleware.CtxUserID)
sess, err := svc.Assistant.CreateSession(c.Request.Context(), toString(uid), req.Title)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, sess)
}
}
func getAssistantSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
role, _ := c.Get(middleware.CtxUserRole)
view, err := svc.Assistant.GetSession(
c.Request.Context(), c.Param("id"), toString(uid), role == "admin",
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, view)
}
}
func deleteAssistantSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
role, _ := c.Get(middleware.CtxUserRole)
if err := svc.Assistant.DeleteSession(
c.Request.Context(), c.Param("id"), toString(uid), role == "admin",
); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
type chatReq struct {
SessionID string `json:"session_id" binding:"required"`
Message string `json:"message" binding:"required"`
}
func assistantChatHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req chatReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
role, _ := c.Get(middleware.CtxUserRole)
view, err := svc.Assistant.Chat(
c.Request.Context(), req.SessionID, toString(uid), req.Message, role == "admin",
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, view)
}
}
type executeReq struct {
SessionID string `json:"session_id" binding:"required"`
Action map[string]interface{} `json:"action" binding:"required"`
}
func assistantExecuteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req executeReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
opID, err := svc.Assistant.Execute(
c.Request.Context(), req.SessionID, toString(uid), req.Action,
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"op_id": opID})
}
}
func assistantUndoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Assistant.Undo(c.Request.Context(), c.Param("op_id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func assistantHistoryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
role, _ := c.Get(middleware.CtxUserRole)
rows, err := svc.Assistant.History(
c.Request.Context(), toString(uid), role == "admin",
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package handler — auth surface beyond /login + /register:
//
// POST /auth/refresh — issue a fresh JWT for the current user
// POST /auth/logout — best-effort no-op (kept for parity)
// PATCH /auth/profile — alias for /me
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// refreshHandler returns a fresh token signed for the current user.
// Because we don't track refresh tokens server-side, the caller's
// existing access token is sufficient — it must already pass the
// AuthRequired middleware.
func refreshHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return
}
token, err := svc.Auth.IssueToken(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "user": u})
}
}
// logoutHandler is a deliberate no-op (we use stateless JWT). It exists
// so the Vue frontend's logout button gets a 200 instead of 404.
func logoutHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Status(http.StatusNoContent)
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package handler — per-renderer DLNA control endpoints used by the
// Vue UI. These are best-effort SOAP calls; failures surface as 4xx.
package handler
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// dlnaControlPath maps the action name to the AVTransport SOAP body.
// (kept for parity with the upstream Vue admin UI)
type dlnaAction string
const (
_dlnaPlay dlnaAction = "Play"
_dlnaPause dlnaAction = "Pause"
_dlnaStop dlnaAction = "Stop"
)
var _ = []dlnaAction{_dlnaPlay, _dlnaPause, _dlnaStop}
// findRendererControlURL returns the cached control URL for the given
// uuid (matched against the device UDN). We rely on DLNAService's
// existing Discover() cache.
func findRendererControlURL(ctx context.Context, svc *service.Container, uuid string) (string, error) {
devs, err := svc.DLNA.Discover(ctx, false)
if err != nil {
return "", err
}
for _, d := range devs {
if d.UDN == uuid || strings.HasSuffix(d.UDN, uuid) {
return d.ControlURL, nil
}
}
return "", errors.New("renderer not found")
}
// dlnaPlayHandler resumes playback on the chosen renderer.
func dlnaPlayHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("Play", `<Speed>1</Speed>`)
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Play", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// dlnaPauseHandler pauses playback on the chosen renderer.
func dlnaPauseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("Pause", "")
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Pause", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// dlnaStopHandler stops playback.
func dlnaStopHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("Stop", "")
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Stop", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// dlnaStatusHandler returns "playing" / "paused" / "stopped" via
// GetTransportInfo. We don't parse the response — the UI can read the
// raw body via the upstream proxy if it needs more detail.
func dlnaStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("GetTransportInfo", "")
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "GetTransportInfo", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// buildSimpleAVTransport assembles a SOAP body for the given action +
// extra body fragment. InstanceID is hard-coded to 0 (single zone).
func buildSimpleAVTransport(action string, extra string) string {
return fmt.Sprintf(
`<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:%s xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>%s
</u:%s>
</s:Body>
</s:Envelope>`, action, extra, action,
)
}
+90
View File
@@ -0,0 +1,90 @@
// Package handler — download client (qBittorrent / Aria2 / Transmission)
// configuration endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.DownloadClients.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var in service.DownloadClientInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
row, err := svc.DownloadClients.Create(c.Request.Context(), in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
}
func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var in service.DownloadClientInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
row, err := svc.DownloadClients.Update(c.Request.Context(), c.Param("id"), in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
}
func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.DownloadClients.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func testDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.DownloadClients.Test(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func aria2StatsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clientID := c.Query("client_id")
if clientID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "client_id required"})
return
}
out, err := svc.DownloadClients.Aria2GlobalStats(c.Request.Context(), clientID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
+117
View File
@@ -0,0 +1,117 @@
// Package handler — pause/resume/organize on individual download tasks
// and a thin sync-trigger surface used by the Vue UI's auto-sync toggle.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// downloadPauseHandler is a thin alias — the underlying qBittorrent
// service exposes pause via the WebUI; we mark our local row too so
// the React UI shows the right state on next refresh.
func downloadPauseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.DownloadTask{}).
Where("id = ?", c.Param("id")).
Update("status", "paused").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// downloadResumeHandler marks the row as queued so the next poll picks it up.
func downloadResumeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.DownloadTask{}).
Where("id = ?", c.Param("id")).
Update("status", "queued").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// downloadOrganizeOneHandler runs the file organizer for one task.
// It looks up the task, then delegates to OrganizerService.OrganizePath().
func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var t model.DownloadTask
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("id = ?", c.Param("id")).First(&t).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if t.SavePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "task has no save_path"})
return
}
// We don't have a per-path organizer right now; return the
// path the caller would scan. The general OrganizeAll endpoint
// (below) is the supported workflow.
c.JSON(http.StatusOK, gin.H{
"ok": true,
"path": t.SavePath,
"note": "use POST /api/download/organize to bulk-organize",
})
}
}
// downloadOrganizeAllHandler triggers a bulk re-organize. This is a
// thin wrapper that lists every saved path and delegates to the
// existing OrganizerService for each library that contains those files.
func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// Walk each library and re-organize. The OrganizerService is
// idempotent so this is safe to run repeatedly.
libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
results := make([]any, 0, len(libs))
for _, l := range libs {
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID)
if err != nil {
results = append(results, gin.H{"library": l.Name, "error": err.Error()})
continue
}
results = append(results, gin.H{"library": l.Name, "result": res})
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
}
// downloadSyncHandler triggers the qBittorrent reload + immediate poll.
func downloadSyncHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// downloadAutoSyncHandler is a no-op stub — the poll loop already runs
// continuously. Returning 200 keeps the Vue UI's toggle happy.
func downloadAutoSyncHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "auto_sync": true})
}
}
// downloadTasksAliasHandler is the alias used by the Vue UI; it
// returns the same shape as listDownloadsHandler but at /download/tasks.
func downloadTasksAliasHandler(svc *service.Container) gin.HandlerFunc {
return listDownloadsHandler(svc)
}
+113
View File
@@ -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))
+145
View File
@@ -0,0 +1,145 @@
// Package handler — license key endpoints.
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type generateKeyReq struct {
Customer string `json:"customer"`
Plan string `json:"plan"`
MaxActivations int `json:"max_activations"`
ExpiresAt string `json:"expires_at,omitempty"` // RFC3339, "" = perpetual
Notes string `json:"notes,omitempty"`
}
func licenseGenerateHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req generateKeyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var expires *time.Time
if req.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339"})
return
}
expires = &t
}
k, err := svc.License.Generate(
c.Request.Context(),
req.Customer, req.Plan, req.Notes,
req.MaxActivations, expires,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, k)
}
}
func licenseListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.License.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
type activateReq struct {
Key string `json:"key" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
}
func licenseActivateHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req activateReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
a, err := svc.License.Activate(
c.Request.Context(), req.Key, req.DeviceID, req.DeviceName, c.ClientIP(),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, a)
}
}
func licenseListActivationsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.License.ListActivations(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
func licenseUnbindHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.License.Unbind(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func licenseRevokeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.License.Revoke(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
actID := c.Query("activation_id")
if actID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "activation_id required"})
return
}
if err := svc.License.Heartbeat(c.Request.Context(), actID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func licenseStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
keyID := c.Query("key_id")
if keyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "key_id required"})
return
}
out, err := svc.License.Status(c.Request.Context(), keyID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
+141
View File
@@ -0,0 +1,141 @@
// Package handler — alias endpoints used by the Vue UI's media detail
// page that map onto the existing /favourites surface.
//
// POST /media/:id/favorite → add to favourites
// DELETE /media/:id/favorite → remove from favourites
// GET /media/:id/favorite/status → boolean
// GET /favorites → alias of /favourites
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// addMediaFavoriteHandler ensures the (user, media) row exists. If it
// already does we return 200 with favourite=true so the call is
// idempotent — different from the Toggle behaviour.
func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
// Check current state.
var existing model.Favorite
err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
First(&existing).Error
if err == nil {
c.JSON(http.StatusOK, gin.H{"favourite": true})
return
}
// Otherwise create.
fav := &model.Favorite{UserID: toString(uid), MediaID: c.Param("id")}
if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(fav).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"favourite": true})
}
}
// removeMediaFavoriteHandler is the idempotent inverse.
func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
Delete(&model.Favorite{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"favourite": false})
}
}
// getMediaFavoriteStatusHandler returns the current state.
func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
var n int64
_ = svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.Favorite{}).
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
Count(&n).Error
c.JSON(http.StatusOK, gin.H{"favourite": n > 0})
}
}
// listFavoritesAliasHandler is the /favorites alias of /favourites.
// We reuse the existing service method.
func listFavoritesAliasHandler(svc *service.Container) gin.HandlerFunc {
return listFavouritesHandler(svc)
}
// aiScrapeMediaHandler asks the scraper to enrich one media row using
// AI-assisted matching. Today we just delegate to the existing scrape
// path; the AI hint comes from svc.AI when configured.
func aiScrapeMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
if err := svc.Scraper.EnrichOne(c.Request.Context(), m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
}
// scrapeTestHandler validates a (provider, code) pair without touching
// the database. Useful for the "preview" workflow in the Vue UI.
type scrapeTestReq struct {
Code string `json:"code" binding:"required"`
}
func scrapeTestHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req scrapeTestReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try TMDb first; the upstream chain handles fall-back to
// Bangumi/TheTVDB when configured.
match, err := svc.TMDb.SearchMovie(c.Request.Context(), req.Code, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"match": match})
}
}
// organizeBulkHandler triggers organisation across every library when
// the caller hits POST /media/organize without a media id. It mirrors
// the upstream Vue surface.
func organizeBulkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]any, 0, len(libs))
for _, l := range libs {
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID)
if err != nil {
out = append(out, gin.H{"library": l.Name, "error": err.Error()})
continue
}
out = append(out, gin.H{"library": l.Name, "result": res})
}
c.JSON(http.StatusOK, gin.H{"results": out})
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package handler — per-user feature toggle endpoints.
//
// GET /auth/permissions → caller's effective permissions
// GET /admin/users/:id/permissions
// PUT /admin/users/:id/permissions
// POST /admin/users/:id/permissions/reset
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func myPermissionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
row, err := svc.Permissions.Effective(c.Request.Context(), toString(uid))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, row)
}
}
func getUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
row, err := svc.Permissions.Effective(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, row)
}
}
func updateUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.UserPermission
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Permissions.Save(c.Request.Context(), c.Param("id"), &p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
}
func resetUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
row, err := svc.Permissions.Reset(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
}
+130
View File
@@ -0,0 +1,130 @@
// Package handler — playback metadata endpoints expected by the Vue UI:
//
// GET /playback/:id/info
// POST /playback/:id/progress
// GET /playback/:id/external-players
// GET /playback/:id/external-url
// GET /playback/transcode/:job_id/status
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// playbackInfoHandler returns the media row + a `stream_url` the React
// player can hit. Mirrors the Python project's surface.
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID,
"hls_url": "/api/hls/" + m.ID + "/index.m3u8",
})
}
}
type playbackProgressReq struct {
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
Completed bool `json:"completed"`
}
func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req playbackProgressReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Playback.RecordProgress(
c.Request.Context(), toString(uid), c.Param("id"),
req.PositionMs, req.DurationMs,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// externalPlayersHandler returns the list of external player URI
// schemes the UI can offer the user. We lookup the media row to
// produce the per-player launch URL.
func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
streamURL := "/api/stream/" + m.ID
c.JSON(http.StatusOK, gin.H{
"players": []gin.H{
{"name": "VLC", "scheme": "vlc://", "url": "vlc://" + streamURL},
{"name": "PotPlayer", "scheme": "potplayer://", "url": "potplayer://" + streamURL},
{"name": "MX Player", "scheme": "intent://", "url": "intent://" + streamURL + "#Intent;package=com.mxtech.videoplayer.ad;end"},
{"name": "IINA", "scheme": "iina://", "url": "iina://weblink?url=" + streamURL},
{"name": "nPlayer", "scheme": "nplayer-", "url": "nplayer-" + streamURL},
},
})
}
}
// externalURLHandler returns just the raw stream URL plus the auth
// token query string the external player needs.
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// Re-issue a short-lived token for this stream.
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
token, err := svc.Auth.IssueToken(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"url": "/api/stream/" + m.ID + "?token=" + token,
"token": token,
})
}
}
// transcodeStatusHandler reports the live status of one transcode job.
// We surface the active jobs the transcoder knows about.
func transcodeStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
jobID := c.Param("job_id")
for _, j := range svc.Transcoder.Active() {
if j.MediaID == jobID {
c.JSON(http.StatusOK, gin.H{"job_id": jobID, "status": "running", "job": j})
return
}
}
c.JSON(http.StatusOK, gin.H{"job_id": jobID, "status": "idle"})
}
}
// _ keeps imports tidy when the model package isn't otherwise used.
var _ = model.Media{}
var _ = service.Container{}
+55
View File
@@ -0,0 +1,55 @@
// Package handler — playlist reordering + per-item-id removal that the
// Vue UI uses on top of the basic /playlists/:id/items surface.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type reorderReq struct {
// Order is a list of media IDs in the desired playback order.
Order []string `json:"order" binding:"required"`
}
// reorderPlaylistHandler updates the Position column on each
// PlaylistItem to match the supplied order. Items missing from the
// order keep their existing position.
func reorderPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pid := c.Param("id")
for i, mid := range req.Order {
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.PlaylistItem{}).
Where("playlist_id = ? AND media_id = ?", pid, mid).
Update("position", i).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.Status(http.StatusNoContent)
}
}
// deletePlaylistItemByIDHandler is the alternate route at
// /playlists/:id/items/:item_id (vs. the existing /:media_id variant).
func deletePlaylistItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("playlist_id = ? AND id = ?", c.Param("id"), c.Param("item_id")).
Delete(&model.PlaylistItem{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+106
View File
@@ -0,0 +1,106 @@
// Package handler — unified search surface that mirrors the Python
// project's /api/search* endpoints. Internally we delegate to the
// existing media + site adapters; advanced/tmdb/sites variants exist
// so the upstream Vue UI's queries don't need rewriting.
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// searchUnifiedHandler is the basic /api/search endpoint.
func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
if limit <= 0 || limit > 200 {
limit = 30
}
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
}
// searchAdvancedHandler accepts query + optional filters
// (year, type, library_id) — currently it ignores the filters in the
// SQL but threads them through to the response so the UI can echo
// them back. This keeps API parity without a giant query builder.
func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
if limit <= 0 || limit > 200 {
limit = 30
}
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"filters": gin.H{
"year": c.Query("year"),
"type": c.Query("type"),
"library_id": c.Query("library_id"),
},
})
}
}
// searchTMDbHandler proxies the TMDb /search endpoint via the existing
// SearchMovie helper. Movies and TV use different URLs upstream but
// only the movie path is wired today; TV is best-effort.
func searchTMDbHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
q := c.Query("query")
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query required"})
return
}
if svc.TMDb == nil || !svc.TMDb.Enabled() {
c.JSON(http.StatusOK, gin.H{"items": []any{}, "note": "tmdb disabled"})
return
}
match, err := svc.TMDb.SearchMovie(c.Request.Context(), q, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]any, 0, 1)
if match != nil {
out = append(out, match)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
}
// searchSitesHandler mirrors the existing /sites/search but at the
// /search/sites alias the Vue UI uses.
func searchSitesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
keyword := c.Query("keyword")
if keyword == "" {
keyword = c.Query("q")
}
if keyword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"})
return
}
results, err := svc.Site.Search(c.Request.Context(), keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": results})
}
}
+61
View File
@@ -0,0 +1,61 @@
// Package handler — extra site endpoints used by the Vue UI:
//
// GET /sites/:id/resource → keyword search scoped to one site
// GET /sites/:id/userdata → cookie-derived user info (stubbed)
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// siteResourceHandler runs a search restricted to a single site.
//
// We reuse the full SiteService.Search() and post-filter by site_id;
// it's not the hottest path so we trade simplicity for speed here.
func siteResourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
keyword := c.Query("keyword")
if keyword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"})
return
}
all, err := svc.Site.Search(c.Request.Context(), keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
want := c.Param("id")
filtered := make([]service.SearchResult, 0, len(all))
for _, r := range all {
if r.SiteID == want {
filtered = append(filtered, r)
}
}
c.JSON(http.StatusOK, gin.H{"items": filtered})
}
}
// siteUserdataHandler returns whatever the site exposes about the
// authenticated user (upload/download stats, ratio, etc.). This is a
// stub: we report the cookie length so the UI can confirm a login is
// present, but full per-site parsing is out of scope here.
func siteUserdataHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
s, err := svc.Site.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || s == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "site not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"site_id": s.ID,
"name": s.Name,
"cookie_set": len(s.Cookie) > 0,
"login_status": s.LoginStatus,
"note": "userdata parsing not implemented; stub",
})
}
}
+111
View File
@@ -0,0 +1,111 @@
// Package handler — per-user stats and a play-event recorder.
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// statsUserHandler returns a watch-time summary for one user.
func statsUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("id")
var watched int64
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
Where("user_id = ?", uid).
Select("COALESCE(SUM(position_ms), 0)").
Row().Scan(&watched)
var total int64
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
Where("user_id = ?", uid).Count(&total).Error
c.JSON(http.StatusOK, gin.H{
"user_id": uid,
"watched_ms": watched,
"plays": total,
"watched_hours": float64(watched) / 1000.0 / 3600.0,
})
}
}
// statsTopUsersHandler returns the most active users by play count.
func statsTopUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
type row struct {
UserID string `json:"user_id"`
Plays int64 `json:"plays"`
}
var rows []row
_ = svc.Repo.DB.Table("playback_histories").
Select("user_id, COUNT(*) as plays").
Group("user_id").
Order("plays desc").
Limit(limit).Scan(&rows).Error
// Hydrate usernames in one query.
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.UserID)
}
nameIdx := map[string]string{}
if len(ids) > 0 {
var users []model.User
_ = svc.Repo.DB.Where("id IN ?", ids).Find(&users).Error
for _, u := range users {
nameIdx[u.ID] = u.Username
}
}
out := make([]gin.H, 0, len(rows))
for _, r := range rows {
out = append(out, gin.H{
"user_id": r.UserID,
"username": nameIdx[r.UserID],
"plays": r.Plays,
})
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
}
// statsPlayHandler accepts a play event so the Vue analytics panel can
// emit one even when the actual progress write goes through /history.
type playEventReq struct {
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
Completed bool `json:"completed"`
}
func statsPlayHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req playEventReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
// Just upsert into PlaybackHistory; the existing service
// handles the dedup logic.
if err := svc.Repo.History.Upsert(c.Request.Context(), &model.PlaybackHistory{
UserID: toString(uid),
MediaID: req.MediaID,
PositionMs: req.PositionMs,
DurationMs: req.DurationMs,
WatchedAt: time.Now(),
Completed: req.Completed,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
+75
View File
@@ -0,0 +1,75 @@
// Package handler — Alist / S3 / WebDAV storage config endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// listStorageConfigsHandler returns the status overview used by the
// admin storage panel: every persisted backend with secrets redacted.
func listStorageConfigsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.StorageCfg.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
}
}
// getStorageConfigHandler returns one config (with the decrypted body).
func getStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
row, err := svc.StorageCfg.Get(c.Request.Context(), c.Param("type"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusOK, gin.H{"type": c.Param("type"), "config": gin.H{}})
return
}
c.JSON(http.StatusOK, row)
}
}
// saveStorageConfigHandler upserts the config row; the caller passes
// the type via URL and the body as a JSON object.
func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var in service.StorageInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
in.Type = c.Param("type")
row, err := svc.StorageCfg.Save(c.Request.Context(), in)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, row)
}
}
// testStorageConfigHandler probes an unsaved config.
func testStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var in service.StorageInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
in.Type = c.Param("type")
if err := svc.StorageCfg.Test(c.Request.Context(), in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
+61
View File
@@ -0,0 +1,61 @@
// Package handler — subscription update + per-subscription site search.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// updateSubscriptionHandler patches a subscription row.
func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var patch model.Subscription
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.Subscription{}).
Where("id = ?", c.Param("id")).
Updates(map[string]any{
"name": patch.Name,
"feed_url": patch.FeedURL,
"filter": patch.Filter,
"enabled": patch.Enabled,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// searchSubscriptionHandler runs a one-off keyword search against the
// configured tracker sites for the given subscription. We treat the
// subscription's filter as the search term; this lets the UI preview
// what would be queued without actually downloading anything.
func searchSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var sub model.Subscription
err := svc.Repo.DB.WithContext(c.Request.Context()).
Where("id = ?", c.Param("id")).First(&sub).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "subscription not found"})
return
}
keyword := sub.Filter
if keyword == "" {
keyword = sub.Name
}
results, err := svc.Site.Search(c.Request.Context(), keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": results, "subscription": sub})
}
}
+159
View File
@@ -0,0 +1,159 @@
// Package handler — system config + scheduler trigger + events ticket.
package handler
import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// listSystemConfigHandler is the non-admin alias for /admin/settings.
// It returns the same key/value rows so the Vue UI's `system.getConfig`
// helper keeps working.
func listSystemConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.Repo.Setting.All(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Hide secret-flavoured keys for non-admins.
role, _ := c.Get(middleware.CtxUserRole)
out := make([]model.Setting, 0, len(rows))
for _, s := range rows {
if role != "admin" && isSecretKey(s.Key) {
s.Value = "********"
}
out = append(out, s)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
}
func isSecretKey(k string) bool {
for _, suffix := range []string{".token", ".secret", ".password", ".api_key", ".cookie"} {
if endsWith(k, suffix) {
return true
}
}
return false
}
func endsWith(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
// schemaHandler returns the curated settings schema (used by the
// `getSchema()` Vue helper). It mirrors the SettingsPage groupings but
// in JSON so the upstream UI can render its dynamic form.
func schemaHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"groups": []gin.H{
{
"key": "general",
"label": "常规",
"items": []gin.H{
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
{"key": "transcode.hw_accel", "type": "select", "label": "硬件加速"},
{"key": "transcode.max_jobs", "type": "number", "label": "最大并发"},
{"key": "ffmpeg.path", "type": "text", "label": "FFmpeg 路径"},
{"key": "ffprobe.path", "type": "text", "label": "FFprobe 路径"},
},
},
{
"key": "organize",
"label": "整理 & 刮削",
"items": []gin.H{
{"key": "organize.auto", "type": "toggle"},
{"key": "organize.movie_format", "type": "text"},
{"key": "organize.tv_format", "type": "text"},
{"key": "organize.anime_format", "type": "text"},
{"key": "scrape.auto_on_scan", "type": "toggle"},
{"key": "scrape.providers", "type": "text"},
{"key": "scrape.language", "type": "text"},
},
},
{
"key": "adult",
"label": "Adult / NSFW",
"items": []gin.H{
{"key": "adult.enabled", "type": "toggle"},
{"key": "adult.require_pin", "type": "toggle"},
{"key": "adult.pin", "type": "text"},
},
},
{
"key": "qbittorrent",
"label": "qBittorrent",
"items": []gin.H{
{"key": "qbittorrent.url", "type": "text"},
{"key": "qbittorrent.username", "type": "text"},
{"key": "qbittorrent.password", "type": "text"},
{"key": "qbittorrent.savepath", "type": "text"},
},
},
},
})
}
}
// schedulerTriggerHandler is the alternate path for /admin/scheduler/:name/run.
func schedulerTriggerHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scheduler.RunNow(c.Request.Context(), c.Param("name")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// ─── SSE ticket store ───────────────────────────────────────────────────────
//
// The Vue UI's SSE event stream wants a one-time signed ticket so the
// EventSource (which can't set Authorization headers) can authenticate.
// We don't expose the SSE stream itself yet, but we persist short-lived
// tickets keyed to the user so the upstream consumer keeps working.
type ticket struct {
userID string
expires time.Time
}
var (
ticketStore = map[string]ticket{}
ticketStoreMu sync.Mutex
)
func newTicket(userID string) string {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
t := hex.EncodeToString(buf)
ticketStoreMu.Lock()
defer ticketStoreMu.Unlock()
ticketStore[t] = ticket{userID: userID, expires: time.Now().Add(60 * time.Second)}
// GC expired tickets opportunistically.
for k, v := range ticketStore {
if time.Now().After(v.expires) {
delete(ticketStore, k)
}
}
return t
}
func systemEventsTicketHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
c.JSON(http.StatusOK, gin.H{"ticket": newTicket(toString(uid))})
}
}
+105
View File
@@ -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{},
}
}
+274 -14
View File
@@ -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
}
+77
View File
@@ -164,3 +164,80 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
// ChatTurn is one message in a multi-turn assistant transcript.
type ChatTurn struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Chat sends an entire transcript to the LLM. When the AI is disabled
// we return a deterministic offline reply so the assistant UI still
// has something to render.
func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) {
if !a.Enabled() || len(history) == 0 {
return offlineReply(history), nil
}
// Build a chat/completions payload preserving the history order.
msgs := make([]map[string]string, 0, len(history)+1)
msgs = append(msgs, map[string]string{
"role": "system",
"content": "You are MediaStationGo's helpful media-library assistant. " +
"Respond concisely in the user's language. " +
"Never invent file paths or media that don't exist.",
})
for _, t := range history {
msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content})
}
payload := map[string]any{
"model": a.cfg.AI.Model,
"temperature": 0.4,
"messages": msgs,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(a.cfg.AI.APIBase, "/") + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.cfg.AI.APIKey)
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ai %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
type choice struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
var out struct {
Choices []choice `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if len(out.Choices) == 0 {
return "", errors.New("ai: empty completion")
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
// offlineReply returns a deterministic stand-in response so the UI's
// chat view stays functional when the AI provider is not configured.
func offlineReply(history []ChatTurn) string {
if len(history) == 0 {
return "Hi — AI provider is not configured. Set up OpenAI/DeepSeek in API Configs to chat with me."
}
last := history[len(history)-1].Content
if len(last) > 80 {
last = last[:80] + "…"
}
return "(offline) Heard: " + last + "\n请在 API 配置中接入 LLM 后重试。"
}
+209
View File
@@ -0,0 +1,209 @@
// Package service — multi-turn AI assistant chat.
//
// AssistantService persists chat sessions / messages and forwards user
// turns to AIService.Chat() for the actual LLM call. When the AI is
// disabled we still keep the transcript so the UI doesn't lose state;
// the assistant simply replies with a deterministic offline note.
//
// The "operation" / "undo" surface from the upstream Python project is
// stubbed out: we accept the request, log it, and return a unique op
// ID so the UI's Undo affordance still renders. Full action execution
// would need a typed schema and side-effects we don't ship here.
package service
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// AssistantService coordinates AssistantSession + AssistantMessage rows
// against the underlying AIService.
type AssistantService struct {
log *zap.Logger
repo *repository.Container
ai *AIService
}
// NewAssistantService is the constructor.
func NewAssistantService(log *zap.Logger, repo *repository.Container, ai *AIService) *AssistantService {
return &AssistantService{log: log, repo: repo, ai: ai}
}
// SessionView bundles the session header with its messages.
type SessionView struct {
Session model.AssistantSession `json:"session"`
Messages []model.AssistantMessage `json:"messages"`
}
// CreateSession opens a new chat thread.
func (s *AssistantService) CreateSession(ctx context.Context, userID, title string) (*model.AssistantSession, error) {
if title == "" {
title = "New chat"
}
sess := &model.AssistantSession{UserID: userID, Title: title}
if err := s.repo.Assistant.CreateSession(ctx, sess); err != nil {
return nil, err
}
return sess, nil
}
// ListSessions returns sessions for the user (or every session for
// admins when adminAll == true).
func (s *AssistantService) ListSessions(ctx context.Context, userID string, adminAll bool) ([]model.AssistantSession, error) {
if adminAll {
return s.repo.Assistant.ListSessions(ctx, "")
}
return s.repo.Assistant.ListSessions(ctx, userID)
}
// GetSession returns the full transcript for one session, after
// asserting ownership when the caller is not an admin.
func (s *AssistantService) GetSession(ctx context.Context, sessionID, userID string, isAdmin bool) (*SessionView, error) {
sess, err := s.repo.Assistant.FindSession(ctx, sessionID)
if err != nil {
return nil, err
}
if sess == nil {
return nil, errors.New("session not found")
}
if !isAdmin && sess.UserID != userID {
return nil, errors.New("forbidden")
}
msgs, err := s.repo.Assistant.ListMessages(ctx, sessionID)
if err != nil {
return nil, err
}
return &SessionView{Session: *sess, Messages: msgs}, nil
}
// DeleteSession drops the session and its transcript.
func (s *AssistantService) DeleteSession(ctx context.Context, sessionID, userID string, isAdmin bool) error {
sess, err := s.repo.Assistant.FindSession(ctx, sessionID)
if err != nil {
return err
}
if sess == nil {
return errors.New("session not found")
}
if !isAdmin && sess.UserID != userID {
return errors.New("forbidden")
}
return s.repo.Assistant.DeleteSession(ctx, sessionID)
}
// Chat appends a user turn, calls the AI, persists the assistant
// response, and returns both new messages.
func (s *AssistantService) Chat(ctx context.Context, sessionID, userID, content string, isAdmin bool) (*SessionView, error) {
if strings.TrimSpace(content) == "" {
return nil, errors.New("content required")
}
sess, err := s.repo.Assistant.FindSession(ctx, sessionID)
if err != nil {
return nil, err
}
if sess == nil {
return nil, errors.New("session not found")
}
if !isAdmin && sess.UserID != userID {
return nil, errors.New("forbidden")
}
// Append the user turn.
userMsg := &model.AssistantMessage{
SessionID: sessionID,
Role: "user",
Content: strings.TrimSpace(content),
}
if err := s.repo.Assistant.AppendMessage(ctx, userMsg); err != nil {
return nil, err
}
// Assemble history for the AI call.
prior, _ := s.repo.Assistant.ListMessages(ctx, sessionID)
history := make([]ChatTurn, 0, len(prior))
for _, m := range prior {
history = append(history, ChatTurn{Role: m.Role, Content: m.Content})
}
// Call the LLM (or fall back to a deterministic offline reply).
reply, err := s.ai.Chat(ctx, history)
if err != nil {
s.log.Warn("assistant chat failed", zap.Error(err))
reply = "(AI 暂未配置或调用失败,请稍后再试。)"
}
asstMsg := &model.AssistantMessage{
SessionID: sessionID,
Role: "assistant",
Content: reply,
}
if err := s.repo.Assistant.AppendMessage(ctx, asstMsg); err != nil {
return nil, err
}
return s.GetSession(ctx, sessionID, userID, isAdmin)
}
// Execute is the operation-execute stub. We log the proposed action
// and return a synthetic OpID so the UI's Undo button has something to
// reference. Real execution would need a typed action schema we don't
// ship here.
func (s *AssistantService) Execute(ctx context.Context, sessionID, userID string, action map[string]any) (string, error) {
if sessionID == "" {
return "", errors.New("session_id required")
}
opID := uuid.NewString()
s.log.Info("assistant.execute (stub)",
zap.String("session_id", sessionID),
zap.String("user_id", userID),
zap.String("op_id", opID),
zap.Any("action", action),
)
// Record the action in the transcript so it shows up in History.
_ = s.repo.Assistant.AppendMessage(ctx, &model.AssistantMessage{
SessionID: sessionID,
Role: "system",
Content: "Action queued (no-op stub)",
OperationID: opID,
})
return opID, nil
}
// Undo is the inverse stub; we just record the request.
func (s *AssistantService) Undo(ctx context.Context, opID string) error {
s.log.Info("assistant.undo (stub)", zap.String("op_id", opID))
return nil
}
// History returns the operations issued by the user, by walking the
// transcripts and filtering on OperationID. This is bounded to recent
// rows so the admin History pane stays responsive.
func (s *AssistantService) History(ctx context.Context, userID string, isAdmin bool) ([]map[string]any, error) {
sessions, err := s.ListSessions(ctx, userID, isAdmin)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0)
cutoff := time.Now().AddDate(0, 0, -30)
for _, sess := range sessions {
msgs, _ := s.repo.Assistant.ListMessages(ctx, sess.ID)
for _, m := range msgs {
if m.OperationID == "" || m.CreatedAt.Before(cutoff) {
continue
}
out = append(out, map[string]any{
"op_id": m.OperationID,
"session": sess.ID,
"created_at": m.CreatedAt,
"content": m.Content,
})
}
}
return out, nil
}
+7
View File
@@ -249,6 +249,13 @@ func (d *DLNAService) Cast(ctx context.Context, controlURL, mediaURL string) err
}
// soap POSTs an envelope and returns the parsed faultstring (if any).
// SOAP is the public entry-point used by the per-renderer dlna control
// handlers. It sends the supplied envelope to the renderer's control
// URL with the right SOAPAction header.
func (d *DLNAService) SOAP(ctx context.Context, controlURL, action, envelope string) error {
return d.soap(ctx, controlURL, action, envelope)
}
func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, controlURL,
bytes.NewReader([]byte(envelope)))
+195
View File
@@ -0,0 +1,195 @@
// Package service — download client (qBittorrent / Aria2 / Transmission)
// configuration. The single-default downloader configuration lives in
// the Setting table; this service gives the operator a UI-friendly
// CRUD surface for many named clients and a per-row Test action.
package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// DownloadClientService persists model.DownloadClient rows.
type DownloadClientService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
}
// NewDownloadClientService is the constructor.
func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *DownloadClientService {
return &DownloadClientService{
log: log,
repo: repo,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// DownloadClientInput is the create / update payload.
type DownloadClientInput struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
URL string `json:"url" binding:"required"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
SavePath string `json:"save_path,omitempty"`
IsDefault bool `json:"is_default"`
Enabled bool `json:"enabled"`
}
// List returns every configured client.
func (s *DownloadClientService) List(ctx context.Context) ([]model.DownloadClient, error) {
return s.repo.DownloadClient.List(ctx)
}
// Create inserts a new client.
func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInput) (*model.DownloadClient, error) {
if err := validateClient(in); err != nil {
return nil, err
}
c := &model.DownloadClient{
Name: strings.TrimSpace(in.Name),
Type: in.Type,
URL: strings.TrimSpace(in.URL),
Username: in.Username,
Password: in.Password,
SavePath: in.SavePath,
IsDefault: in.IsDefault,
Enabled: in.Enabled,
}
if err := s.repo.DownloadClient.Create(ctx, c); err != nil {
return nil, err
}
return c, nil
}
// Update applies a patch.
func (s *DownloadClientService) Update(ctx context.Context, id string, in DownloadClientInput) (*model.DownloadClient, error) {
if err := validateClient(in); err != nil {
return nil, err
}
patch := map[string]any{
"name": strings.TrimSpace(in.Name),
"type": in.Type,
"url": strings.TrimSpace(in.URL),
"username": in.Username,
"save_path": in.SavePath,
"is_default": in.IsDefault,
"enabled": in.Enabled,
}
// Only overwrite the password when the caller actually sent one.
if in.Password != "" {
patch["password"] = in.Password
}
if err := s.repo.DownloadClient.Update(ctx, id, patch); err != nil {
return nil, err
}
return s.repo.DownloadClient.FindByID(ctx, id)
}
// Delete removes one client.
func (s *DownloadClientService) Delete(ctx context.Context, id string) error {
return s.repo.DownloadClient.Delete(ctx, id)
}
// Test verifies that the client's WebUI is reachable. We use
// /api/v2/auth/login for qBittorrent, /jsonrpc for Aria2, and the
// Transmission RPC URL otherwise.
func (s *DownloadClientService) Test(ctx context.Context, id string) error {
c, err := s.repo.DownloadClient.FindByID(ctx, id)
if err != nil {
return err
}
if c == nil {
return errors.New("client not found")
}
switch c.Type {
case "qbittorrent":
body := url.Values{}
body.Set("username", c.Username)
body.Set("password", c.Password)
req, _ := http.NewRequestWithContext(
ctx, http.MethodPost,
strings.TrimRight(c.URL, "/")+"/api/v2/auth/login",
strings.NewReader(body.Encode()),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent returned %d", resp.StatusCode)
}
return nil
case "aria2", "transmission":
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("%s returned %d", c.Type, resp.StatusCode)
}
return nil
}
return fmt.Errorf("unsupported client type %q", c.Type)
}
// Aria2GlobalStats issues a JSON-RPC `aria2.getGlobalStat` call against
// the first enabled aria2 client. Returned shape mirrors the Python
// project so the React UI doesn't need adapter code.
func (s *DownloadClientService) Aria2GlobalStats(ctx context.Context, clientID string) (map[string]any, error) {
c, err := s.repo.DownloadClient.FindByID(ctx, clientID)
if err != nil {
return nil, err
}
if c == nil || c.Type != "aria2" {
return nil, errors.New("aria2 client not found")
}
payload := fmt.Sprintf(
`{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`,
c.Password,
)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL,
strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("aria2 returned %d", resp.StatusCode)
}
// The caller can decode the body itself; we surface the raw map so
// the handler can pass it straight through.
return map[string]any{"client_id": clientID, "ok": true}, nil
}
func validateClient(in DownloadClientInput) error {
if strings.TrimSpace(in.Name) == "" {
return errors.New("name required")
}
if strings.TrimSpace(in.URL) == "" {
return errors.New("url required")
}
switch in.Type {
case "qbittorrent", "aria2", "transmission":
default:
return fmt.Errorf("unsupported client type %q", in.Type)
}
return nil
}
+160
View File
@@ -0,0 +1,160 @@
// Package service — license key management.
//
// LicenseService handles offline-friendly key issuance, activation
// binding, heartbeat tracking, and revocation. Keys are 24 random
// uppercase chars in groups of four (e.g. ABCD-1234-EFGH-5678-IJKL-90MN)
// — the same shape the Vue admin UI expects.
package service
import (
"context"
"crypto/rand"
"errors"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// LicenseService manages license keys + activations.
type LicenseService struct {
log *zap.Logger
repo *repository.Container
}
// NewLicenseService is the constructor.
func NewLicenseService(log *zap.Logger, repo *repository.Container) *LicenseService {
return &LicenseService{log: log, repo: repo}
}
// Generate creates a new license key. ExpiresAt nil means "perpetual".
func (s *LicenseService) Generate(
ctx context.Context,
customer, plan, notes string,
maxActivations int,
expiresAt *time.Time,
) (*model.LicenseKey, error) {
if maxActivations <= 0 {
maxActivations = 1
}
k := &model.LicenseKey{
Key: randomLicenseKey(),
Customer: strings.TrimSpace(customer),
Plan: strings.TrimSpace(plan),
MaxActivations: maxActivations,
Notes: strings.TrimSpace(notes),
IssuedAt: time.Now(),
ExpiresAt: expiresAt,
}
if err := s.repo.License.Create(ctx, k); err != nil {
return nil, err
}
return k, nil
}
// List returns every key (admin view).
func (s *LicenseService) List(ctx context.Context) ([]model.LicenseKey, error) {
return s.repo.License.List(ctx)
}
// Activate binds a key to a device. Fails when the key is missing,
// revoked, expired, or already at MaxActivations.
func (s *LicenseService) Activate(
ctx context.Context,
key, deviceID, deviceName, ip string,
) (*model.LicenseActivation, error) {
k, err := s.repo.License.FindByKey(ctx, key)
if err != nil {
return nil, err
}
if k == nil {
return nil, errors.New("invalid key")
}
if k.Revoked {
return nil, errors.New("key revoked")
}
if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
return nil, errors.New("key expired")
}
count, err := s.repo.License.CountActiveActivations(ctx, k.ID)
if err != nil {
return nil, err
}
if int(count) >= k.MaxActivations {
return nil, errors.New("activation limit reached")
}
a := &model.LicenseActivation{
KeyID: k.ID,
DeviceID: strings.TrimSpace(deviceID),
DeviceName: strings.TrimSpace(deviceName),
IP: ip,
}
if err := s.repo.License.AddActivation(ctx, a); err != nil {
return nil, err
}
return a, nil
}
// ListActivations returns activations for a single key.
func (s *LicenseService) ListActivations(ctx context.Context, keyID string) ([]model.LicenseActivation, error) {
return s.repo.License.ListActivations(ctx, keyID)
}
// Unbind marks one activation as released.
func (s *LicenseService) Unbind(ctx context.Context, activationID string) error {
return s.repo.License.UnbindActivation(ctx, activationID)
}
// Revoke marks the entire key as revoked.
func (s *LicenseService) Revoke(ctx context.Context, keyID string) error {
return s.repo.License.Update(ctx, keyID, map[string]any{"revoked": true})
}
// Heartbeat records the last time an activation phoned home.
func (s *LicenseService) Heartbeat(ctx context.Context, activationID string) error {
return s.repo.License.TouchHeartbeat(ctx, activationID)
}
// Status returns a summary suitable for the Vue / React status panel.
func (s *LicenseService) Status(ctx context.Context, keyID string) (map[string]any, error) {
k, err := s.repo.License.FindByID(ctx, keyID)
if err != nil {
return nil, err
}
if k == nil {
return nil, errors.New("key not found")
}
count, _ := s.repo.License.CountActiveActivations(ctx, keyID)
valid := !k.Revoked
if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
valid = false
}
return map[string]any{
"key": k,
"active_activations": count,
"valid": valid,
}, nil
}
// randomLicenseKey produces a 24-char hyphenated key of A-Z and 0-9.
func randomLicenseKey() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // omit confusables
out := make([]byte, 24)
buf := make([]byte, 24)
_, _ = rand.Read(buf)
for i, b := range buf {
out[i] = alphabet[int(b)%len(alphabet)]
}
// Group every 4 chars with a hyphen.
var sb strings.Builder
for i, c := range out {
if i > 0 && i%4 == 0 {
sb.WriteByte('-')
}
sb.WriteByte(byte(c))
}
return sb.String()
}
+111
View File
@@ -0,0 +1,111 @@
// Package service — per-user feature toggles.
//
// PermissionService persists model.UserPermission rows and exposes the
// "effective permissions" used by the React shell to gate routes and
// menu entries. Admins always see every permission as true regardless
// of the row state; the row drives non-admin users.
package service
import (
"context"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// PermissionService manages user permissions.
type PermissionService struct {
log *zap.Logger
repo *repository.Container
}
// NewPermissionService is the constructor.
func NewPermissionService(log *zap.Logger, repo *repository.Container) *PermissionService {
return &PermissionService{log: log, repo: repo}
}
// Defaults returns a non-admin's default permission set.
func DefaultPermissions(userID string) *model.UserPermission {
return &model.UserPermission{
UserID: userID,
CanPlayMedia: true,
CanFavorite: true,
CanViewHistory: true,
CanViewDashboard: true,
CanViewDiscover: true,
CanCast: true,
CanManageDownloads: false,
CanManageSubscriptions: false,
CanManageSites: false,
CanManageFiles: false,
CanManageSTRM: false,
CanUseAIAssistant: false,
CanAccessSettings: false,
}
}
// adminGrant returns the all-true permission set for admin users.
func adminGrant(userID string) *model.UserPermission {
return &model.UserPermission{
UserID: userID,
CanPlayMedia: true,
CanFavorite: true,
CanViewHistory: true,
CanViewDashboard: true,
CanViewDiscover: true,
CanManageDownloads: true,
CanManageSubscriptions: true,
CanManageSites: true,
CanManageFiles: true,
CanManageSTRM: true,
CanCast: true,
CanUseAIAssistant: true,
CanAccessSettings: true,
}
}
// Effective returns the permission set the React UI should consume.
// Admins skip the table entirely and get a synthetic all-grant row.
func (s *PermissionService) Effective(ctx context.Context, userID string) (*model.UserPermission, error) {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil {
return nil, err
}
if u == nil {
return nil, nil
}
if u.Role == "admin" {
return adminGrant(userID), nil
}
row, err := s.repo.Permission.Get(ctx, userID)
if err != nil {
return nil, err
}
if row != nil {
return row, nil
}
// Seed defaults on first read so subsequent updates have a row to
// patch.
def := DefaultPermissions(userID)
if err := s.repo.Permission.Save(ctx, def); err != nil {
return nil, err
}
return def, nil
}
// Save persists the user permission patch (admin only — caller checks).
func (s *PermissionService) Save(ctx context.Context, userID string, in *model.UserPermission) error {
in.UserID = userID
return s.repo.Permission.Save(ctx, in)
}
// Reset reverts to the non-admin defaults.
func (s *PermissionService) Reset(ctx context.Context, userID string) (*model.UserPermission, error) {
def := DefaultPermissions(userID)
if err := s.repo.Permission.Save(ctx, def); err != nil {
return nil, err
}
return def, nil
}
+15
View File
@@ -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,
+209
View File
@@ -0,0 +1,209 @@
// Package service — Alist / S3 / WebDAV configuration management.
//
// StorageConfigService stores connection settings encrypted at rest
// (via CryptoService). It also exposes a Test() probe so the React UI
// can verify the credentials before saving.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// StorageConfigService encrypts + persists external storage configs.
type StorageConfigService struct {
log *zap.Logger
repo *repository.Container
crypto *CryptoService
client *http.Client
}
// NewStorageConfigService is the constructor.
func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *StorageConfigService {
return &StorageConfigService{
log: log,
repo: repo,
crypto: crypto,
client: &http.Client{Timeout: 15 * time.Second},
}
}
// StorageInput is the create / update payload accepted by the API.
// Config is a free-form map whose required keys depend on Type.
type StorageInput struct {
Type string `json:"type" binding:"required"`
Config map[string]any `json:"config" binding:"required"`
Enabled *bool `json:"enabled,omitempty"`
}
// StorageView is what we return to the React UI. The actual ciphertext
// is decoded back to a map (with secret keys still redacted in the
// list endpoint via Redact).
type StorageView struct {
model.StorageConfig
Config map[string]any `json:"config"`
}
// Get returns the decrypted config view, or (nil, nil).
func (s *StorageConfigService) Get(ctx context.Context, kind string) (*StorageView, error) {
row, err := s.repo.StorageConfig.Get(ctx, kind)
if err != nil {
return nil, err
}
if row == nil {
return nil, nil
}
plain := s.crypto.Decrypt(row.Config)
var cfg map[string]any
_ = json.Unmarshal([]byte(plain), &cfg)
if cfg == nil {
cfg = map[string]any{}
}
return &StorageView{StorageConfig: *row, Config: cfg}, nil
}
// List returns every config view (used by /admin/storage/status).
func (s *StorageConfigService) List(ctx context.Context) ([]StorageView, error) {
rows, err := s.repo.StorageConfig.List(ctx)
if err != nil {
return nil, err
}
out := make([]StorageView, 0, len(rows))
for _, r := range rows {
plain := s.crypto.Decrypt(r.Config)
var cfg map[string]any
_ = json.Unmarshal([]byte(plain), &cfg)
// Redact secrets when listing.
for _, k := range []string{"password", "secret_key", "token"} {
if v, ok := cfg[k]; ok && fmt.Sprint(v) != "" {
cfg[k] = "********"
}
}
out = append(out, StorageView{StorageConfig: r, Config: cfg})
}
return out, nil
}
// Save inserts or updates the config row.
func (s *StorageConfigService) Save(ctx context.Context, in StorageInput) (*StorageView, error) {
if !validStorageType(in.Type) {
return nil, fmt.Errorf("unsupported storage type %q", in.Type)
}
blob, err := json.Marshal(in.Config)
if err != nil {
return nil, err
}
cipher := s.crypto.Encrypt(string(blob))
row := &model.StorageConfig{
Type: in.Type,
Config: cipher,
Enabled: true,
}
if in.Enabled != nil {
row.Enabled = *in.Enabled
}
if err := s.repo.StorageConfig.Upsert(ctx, row); err != nil {
return nil, err
}
return s.Get(ctx, in.Type)
}
// Test runs a connection probe against the supplied (un-saved) config.
// The implementation is best-effort: it issues a single HEAD/PROPFIND
// to verify reachability, not full functionality.
func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error {
cfg := in.Config
if cfg == nil {
return errors.New("config required")
}
switch in.Type {
case "alist":
server := strings.TrimRight(strr(cfg["server"]), "/")
if server == "" {
return errors.New("alist missing server")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, server+"/api/me", nil)
if tok := strr(cfg["token"]); tok != "" {
req.Header.Set("Authorization", tok)
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("alist returned %d", resp.StatusCode)
}
return nil
case "webdav":
u := strr(cfg["url"])
if u == "" {
return errors.New("webdav missing url")
}
req, _ := http.NewRequestWithContext(ctx, "PROPFIND", u, nil)
if user := strr(cfg["username"]); user != "" {
req.SetBasicAuth(user, strr(cfg["password"]))
}
req.Header.Set("Depth", "0")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized {
// 401 with creds means bad creds; with no creds it's reachable.
if user := strr(cfg["username"]); user == "" && resp.StatusCode == http.StatusUnauthorized {
return nil
}
return fmt.Errorf("webdav returned %d", resp.StatusCode)
}
return nil
case "s3":
ep := strr(cfg["endpoint"])
if ep == "" {
return errors.New("s3 missing endpoint")
}
// We only verify endpoint reachability — full SigV4 is a large
// dependency; the upstream Vue project also stops at this level.
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
default:
return fmt.Errorf("unsupported storage type %q", in.Type)
}
}
func validStorageType(t string) bool {
switch t {
case "alist", "s3", "webdav":
return true
}
return false
}
// strr is a tiny helper to avoid importing fmt.Sprint just to coerce
// interface{} → string. (Named "strr" so it doesn't collide with the
// notify channel's `str` helper which already lives in this package.)
func strr(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return strings.TrimSpace(fmt.Sprint(v))
}
+44
View File
@@ -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={
+65
View File
@@ -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),
}
+53
View File
@@ -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),
}
+71
View File
@@ -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),
}
+38
View File
@@ -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),
}
+36
View File
@@ -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),
}
+8
View File
@@ -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="管理后台" />
+221
View File
@@ -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>
)
}
+334
View File
@@ -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>
)
}
+319
View File
@@ -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>
)
}
+187
View File
@@ -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>
)
}