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))})
}
}