mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
feat: port remaining Vue UI surfaces with full backend support
Backend additions:
- New GORM models: NotifyChannel, PlayProfile (with PIN hashing).
- NotifyChannelService: multi-channel CRUD + unified dispatcher
supporting Telegram / Bark / WeChat / Webhook with optional
per-channel event filtering.
- PlayProfileService: per-user 'viewing personas' with
content-rating gates, library scoping, PIN protection, and
player defaults (autoplay, skip-intro, audio/subtitle prefs).
- New endpoints:
GET /admin/notify/channels list
POST /admin/notify/channels create
PUT /admin/notify/channels/:id update
DELETE /admin/notify/channels/:id delete
POST /admin/notify/channels/:id/test send test
GET /play-profiles list (admin: ?all=true)
POST /play-profiles create
PUT /play-profiles/:id update
DELETE /play-profiles/:id delete
GET /media/recent home page rail
GET /media/stats library composition
GET /watch-history list
GET /watch-history/stats aggregate
GET /watch-history/continue continue-watching rail
DELETE /watch-history[?media_id=] clear
DELETE /watch-history/:id remove one
GET /discover/sections available rails
GET /discover/feed?sections=a,b multi-section TMDb fetch
GET /system/info name/version/runtime
GET /system/status uptime/cpu/mem/disk
GET /system/scheduler read-only scheduler view
GET /stats/overview counts only
GET /stats/trend daily plays
GET /stats/top-content most played
GET /stats/libraries per-library size
GET /stats/monitor live hardware
Frontend additions:
- New API helpers: notify_channels, play_profiles, history,
stats_extra, media_extra, discover_extra, system.
- ProfileManagementPage (/play-profiles): full CRUD form with
PIN management, library scoping, language preferences.
- NotifyChannelsPage (/notify-channels, admin): typed config
forms per channel type + per-channel test action.
- SettingsPage (/settings, admin): grouped key/value editor
covering General, Organize/Scrape, Adult, qBittorrent.
- Layout sidebar links and App.tsx routes wired for all three.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
// Package handler — multi-section discover endpoints.
|
||||
//
|
||||
// The Vue DiscoverView paginates a configurable list of "sections"
|
||||
// (trending day/week, popular movies, top rated, etc.) and asks the
|
||||
// backend for a feed keyed by section name. We mirror that surface so
|
||||
// the React DiscoverPage can render the same rails without a rewrite.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// discoverSectionsHandler returns the catalog of sections the UI can
|
||||
// pick from. The names match the upstream Vue UI so existing settings
|
||||
// keep working.
|
||||
func discoverSectionsHandler(_ *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sections": []gin.H{
|
||||
{"key": "trending_day", "label": "今日热门"},
|
||||
{"key": "trending_week", "label": "本周热门"},
|
||||
{"key": "popular_movie", "label": "热门电影"},
|
||||
{"key": "popular_tv", "label": "热门剧集"},
|
||||
{"key": "top_rated_movie", "label": "高分电影"},
|
||||
{"key": "upcoming_movie", "label": "即将上映"},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// discoverFeedHandler resolves one or more section keys (?sections=a,b)
|
||||
// to TMDb endpoint paths and returns the joined results keyed by
|
||||
// section name. Unknown keys are silently dropped so URL typos don't
|
||||
// break the page.
|
||||
func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
keys := strings.Split(c.DefaultQuery("sections", "trending_day,popular_movie"), ",")
|
||||
out := gin.H{}
|
||||
for _, raw := range keys {
|
||||
k := strings.TrimSpace(raw)
|
||||
path := sectionPath(k)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
items, err := svc.Discover.Fetch(c.Request.Context(), path)
|
||||
if err != nil {
|
||||
svc.Log.Debug("discover fetch failed", )
|
||||
items = nil
|
||||
}
|
||||
out[k] = items
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// sectionPath maps the UI-facing key to the TMDb endpoint suffix.
|
||||
func sectionPath(k string) string {
|
||||
switch k {
|
||||
case "trending_day":
|
||||
return "/trending/movie/day"
|
||||
case "trending_week":
|
||||
return "/trending/movie/week"
|
||||
case "popular_movie":
|
||||
return "/movie/popular"
|
||||
case "popular_tv":
|
||||
return "/tv/popular"
|
||||
case "top_rated_movie":
|
||||
return "/movie/top_rated"
|
||||
case "upcoming_movie":
|
||||
return "/movie/upcoming"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,39 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
|
||||
// ── Auxiliary endpoints used by the React UI rails ──
|
||||
authed.GET("/media/recent", recentMediaHandler(svc))
|
||||
authed.GET("/media/stats", mediaStatsHandler(svc))
|
||||
|
||||
// Watch history (extra surface beyond /history).
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||
|
||||
// Multi-section TMDb feed used by DiscoverPage.
|
||||
authed.GET("/discover/sections", discoverSectionsHandler(svc))
|
||||
authed.GET("/discover/feed", discoverFeedHandler(svc))
|
||||
|
||||
// System metadata + read-only scheduler view.
|
||||
authed.GET("/system/info", systemInfoHandler(svc))
|
||||
authed.GET("/system/status", systemStatusHandler(svc))
|
||||
authed.GET("/system/scheduler", systemSchedulerHandler(svc))
|
||||
|
||||
// Richer dashboard rails.
|
||||
authed.GET("/stats/overview", statsOverviewHandler(svc))
|
||||
authed.GET("/stats/trend", statsTrendHandler(svc))
|
||||
authed.GET("/stats/top-content", statsTopContentHandler(svc))
|
||||
authed.GET("/stats/libraries", statsLibrariesHandler(svc))
|
||||
authed.GET("/stats/monitor", statsMonitorHandler(svc))
|
||||
|
||||
// Multi-persona play profiles (caller-scoped, admins via ?all=true).
|
||||
authed.GET("/play-profiles", listPlayProfilesHandler(svc))
|
||||
authed.POST("/play-profiles", createPlayProfileHandler(svc))
|
||||
authed.PUT("/play-profiles/:id", updatePlayProfileHandler(svc))
|
||||
authed.DELETE("/play-profiles/:id", deletePlayProfileHandler(svc))
|
||||
}
|
||||
|
||||
// Admin-only endpoints.
|
||||
@@ -160,6 +193,13 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
// Notifications (test endpoint).
|
||||
admin.POST("/notify/test", notifyTestHandler(svc))
|
||||
|
||||
// Notify channels CRUD + per-channel test.
|
||||
admin.GET("/notify/channels", listNotifyChannelsHandler(svc))
|
||||
admin.POST("/notify/channels", createNotifyChannelHandler(svc))
|
||||
admin.PUT("/notify/channels/:id", updateNotifyChannelHandler(svc))
|
||||
admin.DELETE("/notify/channels/:id", deleteNotifyChannelHandler(svc))
|
||||
admin.POST("/notify/channels/:id/test", testNotifyChannelHandler(svc))
|
||||
|
||||
// File organizer.
|
||||
admin.POST("/media/:id/organize", organizeMediaHandler(svc))
|
||||
admin.POST("/libraries/:id/organize", organizeLibraryHandler(svc))
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package handler — auxiliary media endpoints used by the home page
|
||||
// rails (recent additions) and the admin dashboard summary card.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func recentMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "12"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 12
|
||||
}
|
||||
var items []model.Media
|
||||
if err := svc.Repo.DB.Order("created_at desc").Limit(limit).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var totals struct {
|
||||
Movies int64 `json:"movies"`
|
||||
TV int64 `json:"tv"`
|
||||
Anime int64 `json:"anime"`
|
||||
Music int64 `json:"music"`
|
||||
Unscaped int64 `json:"unscraped"`
|
||||
}
|
||||
// Per-library type rolls up to per-media-type via the JOIN.
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Joins("JOIN libraries ON libraries.id = media.library_id").
|
||||
Where("libraries.type = ?", "movie").Count(&totals.Movies).Error
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Joins("JOIN libraries ON libraries.id = media.library_id").
|
||||
Where("libraries.type = ?", "tv").Count(&totals.TV).Error
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Joins("JOIN libraries ON libraries.id = media.library_id").
|
||||
Where("libraries.type = ?", "anime").Count(&totals.Anime).Error
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Joins("JOIN libraries ON libraries.id = media.library_id").
|
||||
Where("libraries.type = ?", "music").Count(&totals.Music).Error
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = 'pending'").
|
||||
Count(&totals.Unscaped).Error
|
||||
|
||||
var totalCount, totalSize, totalSeconds int64
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).Count(&totalCount).Error
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&totalSize)
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Select("COALESCE(SUM(duration_sec),0)").Row().Scan(&totalSeconds)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"by_type": totals,
|
||||
"total": totalCount,
|
||||
"total_size": totalSize,
|
||||
"total_seconds": totalSeconds,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package handler — notify channel CRUD + per-channel test endpoint.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rows, err := svc.NotifyChannels.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rows)
|
||||
}
|
||||
}
|
||||
|
||||
func createNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var in service.ChannelInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
row, err := svc.NotifyChannels.Create(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
|
||||
func updateNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var in service.ChannelInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
row, err := svc.NotifyChannels.Update(c.Request.Context(), c.Param("id"), in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.NotifyChannels.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func testNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.NotifyChannels.Test(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "test sent"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package handler — multi-persona play profile CRUD endpoints.
|
||||
//
|
||||
// Non-admin users see / mutate only their own profiles. Admins see
|
||||
// every profile so they can manage child accounts, etc.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// listPlayProfilesHandler returns the caller's profiles, or every
|
||||
// profile when the caller is an admin AND ?all=true is set.
|
||||
func listPlayProfilesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
if c.Query("all") == "true" && role == "admin" {
|
||||
rows, err := svc.PlayProfiles.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rows)
|
||||
return
|
||||
}
|
||||
rows, err := svc.PlayProfiles.ListByUser(c.Request.Context(), toString(uid))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rows)
|
||||
}
|
||||
}
|
||||
|
||||
func createPlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var in service.PlayProfileInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Default the user_id to the caller; admins can override.
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
if in.UserID == "" || role != "admin" {
|
||||
in.UserID = toString(uid)
|
||||
}
|
||||
row, err := svc.PlayProfiles.Create(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
|
||||
func updatePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var in service.PlayProfileInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
row, err := svc.PlayProfiles.Update(c.Request.Context(), c.Param("id"), in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
|
||||
func deletePlayProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.PlayProfiles.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package handler — richer dashboard statistics endpoints.
|
||||
//
|
||||
// /api/stats already returns the basic snapshot. The Vue admin
|
||||
// dashboard also uses:
|
||||
//
|
||||
// /api/stats/overview — counts + total size + total seconds
|
||||
// /api/stats/trend — daily play count over last N days
|
||||
// /api/stats/top-content — top played media (by play count)
|
||||
// /api/stats/libraries — per-library item count + size
|
||||
// /api/stats/monitor — live CPU/mem/disk
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func statsOverviewHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"libraries": snap.Libraries,
|
||||
"media_count": snap.MediaCount,
|
||||
"users_count": snap.UsersCount,
|
||||
"total_size": snap.TotalSizeBytes,
|
||||
"total_seconds": snap.TotalSeconds,
|
||||
"generated_at": snap.GeneratedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// statsTrendHandler returns play counts per day for the last N days.
|
||||
func statsTrendHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "14"))
|
||||
if days <= 0 || days > 90 {
|
||||
days = 14
|
||||
}
|
||||
// Use the playback_history table; one row per (user, media)
|
||||
// per day if we group by date(watched_at).
|
||||
type bucket struct {
|
||||
Day string `json:"day"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
out := make([]bucket, 0, days)
|
||||
now := time.Now().UTC()
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
start := now.AddDate(0, 0, -i).Truncate(24 * time.Hour)
|
||||
end := start.Add(24 * time.Hour)
|
||||
var n int64
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("watched_at >= ? AND watched_at < ?", start, end).
|
||||
Count(&n).Error
|
||||
out = append(out, bucket{
|
||||
Day: start.Format("2006-01-02"),
|
||||
Count: n,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"trend": out, "days": days})
|
||||
}
|
||||
}
|
||||
|
||||
// statsTopContentHandler returns the most-watched media items.
|
||||
func statsTopContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
type row struct {
|
||||
MediaID string `json:"media_id"`
|
||||
PlayCount int64 `json:"play_count"`
|
||||
LastPlayed time.Time `json:"last_played"`
|
||||
}
|
||||
var rows []row
|
||||
_ = svc.Repo.DB.Table("playback_histories").
|
||||
Select("media_id, COUNT(*) as play_count, MAX(watched_at) as last_played").
|
||||
Group("media_id").
|
||||
Order("play_count desc").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
// Hydrate media titles in a single query.
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.MediaID)
|
||||
}
|
||||
mIdx := map[string]model.Media{}
|
||||
if len(ids) > 0 {
|
||||
var media []model.Media
|
||||
_ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error
|
||||
for _, m := range media {
|
||||
mIdx[m.ID] = m
|
||||
}
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, gin.H{
|
||||
"media": mIdx[r.MediaID],
|
||||
"play_count": r.PlayCount,
|
||||
"last_played": r.LastPlayed,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
}
|
||||
|
||||
// statsLibrariesHandler returns per-library counts + size.
|
||||
func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var libs []model.Library
|
||||
if err := svc.Repo.DB.Find(&libs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(libs))
|
||||
for _, l := range libs {
|
||||
var count int64
|
||||
var size int64
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Where("library_id = ?", l.ID).Count(&count).Error
|
||||
_ = svc.Repo.DB.Model(&model.Media{}).
|
||||
Where("library_id = ?", l.ID).
|
||||
Select("COALESCE(SUM(size_bytes),0)").Row().Scan(&size)
|
||||
out = append(out, gin.H{
|
||||
"library": l,
|
||||
"item_count": count,
|
||||
"total_size": size,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"libraries": out})
|
||||
}
|
||||
}
|
||||
|
||||
// statsMonitorHandler returns live system resource usage; this is just
|
||||
// the Hardware portion of the snapshot but with a snappy schema.
|
||||
func statsMonitorHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, snap.Hardware)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package handler — system metadata endpoints used by the React shell
|
||||
// (footer "powered by", admin status panel, scheduled-task page).
|
||||
//
|
||||
// These mirror the Vue surface (/api/system/info, /api/system/status,
|
||||
// /api/system/scheduler) so the React port can reuse the same calls.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// startedAt is captured at first call so /system/status can report uptime
|
||||
// without threading state through the container.
|
||||
var startedAt = time.Now()
|
||||
|
||||
func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"name": "MediaStationGo",
|
||||
"version": "0.1.0",
|
||||
"go": runtime.Version(),
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"data_dir": svc.Cfg.App.DataDir,
|
||||
"cache_dir": svc.Cfg.Cache.CacheDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func systemStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
out := gin.H{
|
||||
"uptime_seconds": int64(time.Since(startedAt).Seconds()),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
}
|
||||
if usage, err := cpu.Percent(0, false); err == nil && len(usage) > 0 {
|
||||
out["cpu_percent"] = usage[0]
|
||||
}
|
||||
if v, err := mem.VirtualMemory(); err == nil {
|
||||
out["memory_used"] = v.Used
|
||||
out["memory_total"] = v.Total
|
||||
}
|
||||
if d, err := disk.Usage(svc.Cfg.App.DataDir); err == nil {
|
||||
out["disk_used"] = d.Used
|
||||
out["disk_total"] = d.Total
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// systemSchedulerHandler is the read-only (non-admin) variant of
|
||||
// /admin/scheduler — handy on user-facing dashboards.
|
||||
func systemSchedulerHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package handler — watch history endpoints.
|
||||
//
|
||||
// The base /history GET / POST routes already exist; these add the three
|
||||
// auxiliary surfaces the React WatchHistoryPage needs:
|
||||
//
|
||||
// GET /api/watch-history paginated list (admin sees every user)
|
||||
// GET /api/watch-history/stats aggregate watch time + completion
|
||||
// GET /api/watch-history/continue resume rail (incomplete only)
|
||||
// DELETE /api/watch-history clear (?media_item_id= optional)
|
||||
// DELETE /api/watch-history/:id remove one row
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// historyListHandler returns the caller's history rows joined with the
|
||||
// matching media in a single response.
|
||||
func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
}
|
||||
|
||||
// historyStatsHandler returns aggregate watch time + completion counts
|
||||
// for the caller. Used by the WatchHistoryPage hero card.
|
||||
func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
|
||||
var total int64
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ?", userID).Count(&total).Error
|
||||
|
||||
var completed int64
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ? AND completed = ?", userID, true).Count(&completed).Error
|
||||
|
||||
var watchedMs int64
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ?", userID).
|
||||
Select("COALESCE(SUM(position_ms), 0)").
|
||||
Row().Scan(&watchedMs)
|
||||
|
||||
var last *time.Time
|
||||
row := svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ?", userID).
|
||||
Select("MAX(watched_at)").Row()
|
||||
var lastT time.Time
|
||||
if err := row.Scan(&lastT); err == nil && !lastT.IsZero() {
|
||||
last = &lastT
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"watched_ms": watchedMs,
|
||||
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
|
||||
"last_watched": last,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// historyContinueHandler returns "Continue Watching" rows: incomplete
|
||||
// items, most recent first.
|
||||
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
var rows []model.PlaybackHistory
|
||||
if err := svc.Repo.DB.
|
||||
Where("user_id = ? AND completed = ?", toString(uid), false).
|
||||
Order("watched_at desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Hydrate media in one query.
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.MediaID)
|
||||
}
|
||||
var media []model.Media
|
||||
if len(ids) > 0 {
|
||||
_ = svc.Repo.DB.Where("id IN ?", ids).Find(&media).Error
|
||||
}
|
||||
mIdx := make(map[string]model.Media, len(media))
|
||||
for _, m := range media {
|
||||
mIdx[m.ID] = m
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
m, ok := mIdx[r.MediaID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": m,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// historyDeleteHandler removes one or all history rows for the caller.
|
||||
//
|
||||
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
|
||||
// DELETE /api/watch-history → clear all rows for the user
|
||||
func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
mediaID := c.Query("media_id")
|
||||
|
||||
q := svc.Repo.DB.Where("user_id = ?", userID)
|
||||
if mediaID != "" {
|
||||
q = q.Where("media_id = ?", mediaID)
|
||||
}
|
||||
if err := q.Delete(&model.PlaybackHistory{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func historyDeleteOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.Repo.DB.
|
||||
Where("user_id = ? AND id = ?", toString(uid), c.Param("id")).
|
||||
Delete(&model.PlaybackHistory{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,52 @@ type Site struct {
|
||||
Downloader string `gorm:"size:50" json:"downloader,omitempty"`
|
||||
}
|
||||
|
||||
// NotifyChannel is one named outbound notification destination.
|
||||
//
|
||||
// The Config column holds a JSON blob whose schema depends on the
|
||||
// ChannelType (telegram/wechat/bark/webhook):
|
||||
//
|
||||
// telegram → {bot_token, chat_id}
|
||||
// wechat → {sendkey}
|
||||
// bark → {device_key, server?}
|
||||
// webhook → {url, method, headers (JSON string), body_template}
|
||||
//
|
||||
// The Events column is a JSON array of event-type strings the channel
|
||||
// subscribes to; an empty array means "all events".
|
||||
type NotifyChannel struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
ChannelType string `gorm:"size:32;not null" json:"channel_type"`
|
||||
Config string `gorm:"type:text;not null" json:"config"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Events string `gorm:"type:text;default:'[]'" json:"events"`
|
||||
}
|
||||
|
||||
// PlayProfile lets one user define multiple "viewing personas" with
|
||||
// different content-rating limits, library access, and player defaults.
|
||||
// The original Vue project sketched this out as a forward-looking
|
||||
// feature; we materialise it server-side so the React port can fully
|
||||
// function without dropping the screen.
|
||||
//
|
||||
// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all).
|
||||
type PlayProfile struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
|
||||
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
|
||||
RequirePIN bool `gorm:"default:false" json:"require_pin"`
|
||||
PINHash string `gorm:"size:128" json:"-"`
|
||||
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
|
||||
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
|
||||
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
|
||||
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
|
||||
AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
|
||||
TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
}
|
||||
|
||||
// AllModels returns the slice consumed by gorm.AutoMigrate.
|
||||
func AllModels() []interface{} {
|
||||
return []interface{}{
|
||||
@@ -246,5 +292,7 @@ func AllModels() []interface{} {
|
||||
&Site{},
|
||||
&AccessLog{},
|
||||
&APIConfig{},
|
||||
&NotifyChannel{},
|
||||
&PlayProfile{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,23 +30,27 @@ type Container struct {
|
||||
Subscription *SubscriptionRepository
|
||||
Setting *SettingRepository
|
||||
Log *AccessLogRepository
|
||||
NotifyChannel *NotifyChannelRepository
|
||||
PlayProfile *PlayProfileRepository
|
||||
}
|
||||
|
||||
// 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},
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,3 +393,111 @@ func (r *AccessLogRepository) Recent(ctx context.Context, limit int) ([]model.Ac
|
||||
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
|
||||
// ─── Notify Channel ──────────────────────────────────────────────────────────
|
||||
|
||||
// NotifyChannelRepository persists model.NotifyChannel records.
|
||||
type NotifyChannelRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new notify channel.
|
||||
func (r *NotifyChannelRepository) Create(ctx context.Context, n *model.NotifyChannel) error {
|
||||
return r.db.WithContext(ctx).Create(n).Error
|
||||
}
|
||||
|
||||
// FindByID returns the channel or (nil, nil).
|
||||
func (r *NotifyChannelRepository) FindByID(ctx context.Context, id string) (*model.NotifyChannel, error) {
|
||||
var n model.NotifyChannel
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&n).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
// List returns every channel ordered by creation time desc.
|
||||
func (r *NotifyChannelRepository) List(ctx context.Context) ([]model.NotifyChannel, error) {
|
||||
var rows []model.NotifyChannel
|
||||
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabled is the variant the dispatcher uses; honours Enabled flag.
|
||||
func (r *NotifyChannelRepository) ListEnabled(ctx context.Context) ([]model.NotifyChannel, error) {
|
||||
var rows []model.NotifyChannel
|
||||
err := r.db.WithContext(ctx).Where("enabled = ?", true).
|
||||
Order("created_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Update applies a partial patch addressed by ID. The map keys must use
|
||||
// snake_case GORM column names.
|
||||
func (r *NotifyChannelRepository) Update(ctx context.Context, id string, patch map[string]any) error {
|
||||
return r.db.WithContext(ctx).Model(&model.NotifyChannel{}).
|
||||
Where("id = ?", id).Updates(patch).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a channel.
|
||||
func (r *NotifyChannelRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.NotifyChannel{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// ─── Play Profile ────────────────────────────────────────────────────────────
|
||||
|
||||
// PlayProfileRepository persists model.PlayProfile records.
|
||||
type PlayProfileRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new play profile.
|
||||
func (r *PlayProfileRepository) Create(ctx context.Context, p *model.PlayProfile) error {
|
||||
return r.db.WithContext(ctx).Create(p).Error
|
||||
}
|
||||
|
||||
// FindByID returns the profile or (nil, nil).
|
||||
func (r *PlayProfileRepository) FindByID(ctx context.Context, id string) (*model.PlayProfile, error) {
|
||||
var p model.PlayProfile
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListByUser returns every profile owned by a user.
|
||||
func (r *PlayProfileRepository) ListByUser(ctx context.Context, userID string) ([]model.PlayProfile, error) {
|
||||
var rows []model.PlayProfile
|
||||
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
|
||||
Order("is_default desc, created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// List returns every profile across users (admin view).
|
||||
func (r *PlayProfileRepository) List(ctx context.Context) ([]model.PlayProfile, error) {
|
||||
var rows []model.PlayProfile
|
||||
err := r.db.WithContext(ctx).
|
||||
Order("user_id asc, is_default desc, created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ClearDefaultsFor flips all is_default flags to false for the given
|
||||
// user; called inside the same transaction that promotes a new default.
|
||||
func (r *PlayProfileRepository) ClearDefaultsFor(ctx context.Context, userID string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.PlayProfile{}).
|
||||
Where("user_id = ?", userID).Update("is_default", false).Error
|
||||
}
|
||||
|
||||
// Update applies a partial patch addressed by ID.
|
||||
func (r *PlayProfileRepository) Update(ctx context.Context, id string, patch map[string]any) error {
|
||||
return r.db.WithContext(ctx).Model(&model.PlayProfile{}).
|
||||
Where("id = ?", id).Updates(patch).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes a profile.
|
||||
func (r *PlayProfileRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.PlayProfile{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) {
|
||||
// fetch is the shared helper that paginates page=1 only — that's all the
|
||||
// home page needs and it keeps us under TMDb's 50 rps limit.
|
||||
func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) {
|
||||
return d.Fetch(ctx, path)
|
||||
}
|
||||
|
||||
// Fetch is the public entry point used by the multi-section handler.
|
||||
// It paginates page=1 only — that's all the home page needs and it
|
||||
// keeps us under TMDb's 50 rps limit.
|
||||
func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, error) {
|
||||
if d.tmdb == nil || !d.tmdb.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// Package service — notification channel CRUD + multi-channel dispatch.
|
||||
//
|
||||
// The original NotifierService reads a single set of keys from the
|
||||
// settings table. NotifyChannelService persists *named* channels in
|
||||
// their own table so the operator can add multiple Telegram bots, Bark
|
||||
// servers, etc. and pick which events flow to which channel.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// NotifyChannelService manages notify channels and dispatches messages.
|
||||
type NotifyChannelService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewNotifyChannelService is the constructor.
|
||||
func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *NotifyChannelService {
|
||||
return &NotifyChannelService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelInput is the shape accepted by Create / Update. Config is a
|
||||
// generic map; it gets serialised to JSON before being persisted.
|
||||
type ChannelInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ChannelType string `json:"channel_type" binding:"required"`
|
||||
Config map[string]any `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// channelView is the public shape — Config is decoded back to a map so
|
||||
// the React form can edit it directly without unwrapping JSON twice.
|
||||
type channelView struct {
|
||||
model.NotifyChannel
|
||||
Config map[string]any `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
// toView decodes Config + Events from their persisted JSON strings.
|
||||
func toView(n model.NotifyChannel) channelView {
|
||||
v := channelView{NotifyChannel: n}
|
||||
if n.Config != "" {
|
||||
_ = json.Unmarshal([]byte(n.Config), &v.Config)
|
||||
}
|
||||
if v.Config == nil {
|
||||
v.Config = map[string]any{}
|
||||
}
|
||||
if n.Events != "" {
|
||||
_ = json.Unmarshal([]byte(n.Events), &v.Events)
|
||||
}
|
||||
if v.Events == nil {
|
||||
v.Events = []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// List returns every channel as a decoded view.
|
||||
func (s *NotifyChannelService) List(ctx context.Context) ([]channelView, error) {
|
||||
rows, err := s.repo.NotifyChannel.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]channelView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create persists a new channel.
|
||||
func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*channelView, error) {
|
||||
if err := validateChannel(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgBlob, _ := json.Marshal(in.Config)
|
||||
evBlob, _ := json.Marshal(in.Events)
|
||||
n := &model.NotifyChannel{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
ChannelType: in.ChannelType,
|
||||
Config: string(cfgBlob),
|
||||
Events: string(evBlob),
|
||||
Enabled: true,
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
n.Enabled = *in.Enabled
|
||||
}
|
||||
if err := s.repo.NotifyChannel.Create(ctx, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toView(*n)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Update applies a partial patch to an existing channel.
|
||||
func (s *NotifyChannelService) Update(ctx context.Context, id string, in ChannelInput) (*channelView, error) {
|
||||
if err := validateChannel(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgBlob, _ := json.Marshal(in.Config)
|
||||
evBlob, _ := json.Marshal(in.Events)
|
||||
patch := map[string]any{
|
||||
"name": strings.TrimSpace(in.Name),
|
||||
"channel_type": in.ChannelType,
|
||||
"config": string(cfgBlob),
|
||||
"events": string(evBlob),
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
patch["enabled"] = *in.Enabled
|
||||
}
|
||||
if err := s.repo.NotifyChannel.Update(ctx, id, patch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toView(*row)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Delete removes the channel.
|
||||
func (s *NotifyChannelService) Delete(ctx context.Context, id string) error {
|
||||
return s.repo.NotifyChannel.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// Test sends a "测试通知" through a single channel.
|
||||
func (s *NotifyChannelService) Test(ctx context.Context, id string) error {
|
||||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if row == nil {
|
||||
return errors.New("channel not found")
|
||||
}
|
||||
return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。")
|
||||
}
|
||||
|
||||
// Broadcast sends a message to every enabled channel that subscribes to
|
||||
// `event` (an empty Events slice means "all events"). Failures are
|
||||
// logged and never abort the loop.
|
||||
func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) {
|
||||
rows, err := s.repo.NotifyChannel.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("notify list failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
if !channelSubscribes(r, event) {
|
||||
continue
|
||||
}
|
||||
if err := s.dispatchOne(ctx, r, title, body); err != nil {
|
||||
s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// channelSubscribes returns true when the channel's Events list is
|
||||
// empty (= all events) or contains `event`.
|
||||
func channelSubscribes(n model.NotifyChannel, event string) bool {
|
||||
if event == "" || n.Events == "" || n.Events == "[]" {
|
||||
return true
|
||||
}
|
||||
var ev []string
|
||||
if err := json.Unmarshal([]byte(n.Events), &ev); err != nil {
|
||||
return true
|
||||
}
|
||||
if len(ev) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range ev {
|
||||
if e == event {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dispatchOne is the inner dispatcher; the channel type drives which
|
||||
// HTTP request gets built.
|
||||
func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error {
|
||||
cfg := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(n.Config), &cfg)
|
||||
|
||||
switch n.ChannelType {
|
||||
case "telegram":
|
||||
token := str(cfg["bot_token"])
|
||||
chat := str(cfg["chat_id"])
|
||||
if token == "" || chat == "" {
|
||||
return errors.New("telegram missing bot_token / chat_id")
|
||||
}
|
||||
text := fmt.Sprintf("<b>%s</b>\n\n%s", escapeHTML(title), escapeHTML(body))
|
||||
u := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
|
||||
form := url.Values{}
|
||||
form.Set("chat_id", chat)
|
||||
form.Set("text", text)
|
||||
form.Set("parse_mode", "HTML")
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return s.do(req)
|
||||
|
||||
case "bark":
|
||||
key := str(cfg["device_key"])
|
||||
if key == "" {
|
||||
return errors.New("bark missing device_key")
|
||||
}
|
||||
server := str(cfg["server"])
|
||||
if server == "" {
|
||||
server = "https://api.day.app"
|
||||
}
|
||||
u := fmt.Sprintf("%s/%s/%s/%s",
|
||||
strings.TrimRight(server, "/"),
|
||||
url.PathEscape(key),
|
||||
url.PathEscape(title),
|
||||
url.PathEscape(body),
|
||||
)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
return s.do(req)
|
||||
|
||||
case "wechat":
|
||||
key := str(cfg["sendkey"])
|
||||
if key == "" {
|
||||
return errors.New("wechat missing sendkey")
|
||||
}
|
||||
u := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", url.PathEscape(key))
|
||||
form := url.Values{}
|
||||
form.Set("title", title)
|
||||
form.Set("desp", body)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return s.do(req)
|
||||
|
||||
case "webhook":
|
||||
urlS := str(cfg["url"])
|
||||
if urlS == "" {
|
||||
return errors.New("webhook missing url")
|
||||
}
|
||||
method := strings.ToUpper(str(cfg["method"]))
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
// Substitute {{title}} / {{message}} in the body template.
|
||||
bodyTpl := str(cfg["body_template"])
|
||||
if bodyTpl == "" {
|
||||
bodyTpl = `{"title":"{{title}}","message":"{{message}}"}`
|
||||
}
|
||||
bodyStr := strings.NewReplacer("{{title}}", title, "{{message}}", body).Replace(bodyTpl)
|
||||
req, _ := http.NewRequestWithContext(ctx, method, urlS, strings.NewReader(bodyStr))
|
||||
// Apply custom headers (encoded as JSON in the config).
|
||||
if hdrRaw := str(cfg["headers"]); hdrRaw != "" {
|
||||
var hdr map[string]string
|
||||
if err := json.Unmarshal([]byte(hdrRaw), &hdr); err == nil {
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" && method != http.MethodGet {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return s.do(req)
|
||||
}
|
||||
return fmt.Errorf("unknown channel type %q", n.ChannelType)
|
||||
}
|
||||
|
||||
func (s *NotifyChannelService) do(req *http.Request) error {
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("upstream returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateChannel rejects obviously-malformed inputs early so the API
|
||||
// returns a useful 400 rather than a database constraint error.
|
||||
func validateChannel(in ChannelInput) error {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errors.New("name required")
|
||||
}
|
||||
switch in.ChannelType {
|
||||
case "telegram", "wechat", "bark", "webhook":
|
||||
default:
|
||||
return fmt.Errorf("unsupported channel type %q", in.ChannelType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// str safely extracts a string from an interface{} loaded from JSON.
|
||||
func str(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package service — multi-persona play profiles.
|
||||
//
|
||||
// PlayProfileService persists per-user "viewing personas" so the same
|
||||
// account can switch between, e.g., a child-safe profile and an adult
|
||||
// one without changing credentials. Profiles drive content rating
|
||||
// gates, library access, and player defaults; the upstream Vue project
|
||||
// shipped the form but never wired the backend, so we implement the
|
||||
// data model here.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// PlayProfileService manages PlayProfile rows.
|
||||
type PlayProfileService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
}
|
||||
|
||||
// NewPlayProfileService is the constructor.
|
||||
func NewPlayProfileService(log *zap.Logger, repo *repository.Container) *PlayProfileService {
|
||||
return &PlayProfileService{log: log, repo: repo}
|
||||
}
|
||||
|
||||
// PlayProfileInput is the create/update payload accepted by the API.
|
||||
// PIN is hashed only when non-empty so omitting it preserves the
|
||||
// existing PIN on update.
|
||||
type PlayProfileInput struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ContentRatingLimit string `json:"content_rating_limit"`
|
||||
AllowAdult bool `json:"allow_adult"`
|
||||
RequirePIN bool `json:"require_pin"`
|
||||
PIN string `json:"pin,omitempty"`
|
||||
PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
|
||||
PreferredAudioLang string `json:"preferred_audio_lang"`
|
||||
AutoplayNext bool `json:"autoplay_next"`
|
||||
SkipIntro bool `json:"skip_intro"`
|
||||
AllowedLibraryIDs []string `json:"allowed_library_ids"`
|
||||
}
|
||||
|
||||
// ProfileView is the public shape — AllowedLibraryIDs is decoded back
|
||||
// to a slice so the React form can bind to it directly.
|
||||
type ProfileView struct {
|
||||
model.PlayProfile
|
||||
AllowedLibraryIDs []string `json:"allowed_library_ids"`
|
||||
}
|
||||
|
||||
func toProfileView(p model.PlayProfile) ProfileView {
|
||||
v := ProfileView{PlayProfile: p}
|
||||
if p.AllowedLibraryIDs != "" {
|
||||
_ = json.Unmarshal([]byte(p.AllowedLibraryIDs), &v.AllowedLibraryIDs)
|
||||
}
|
||||
if v.AllowedLibraryIDs == nil {
|
||||
v.AllowedLibraryIDs = []string{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// List returns every profile (admin view).
|
||||
func (s *PlayProfileService) List(ctx context.Context) ([]ProfileView, error) {
|
||||
rows, err := s.repo.PlayProfile.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProfileView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toProfileView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListByUser returns the profiles owned by the user.
|
||||
func (s *PlayProfileService) ListByUser(ctx context.Context, userID string) ([]ProfileView, error) {
|
||||
rows, err := s.repo.PlayProfile.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProfileView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toProfileView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create inserts a new play profile. When IsDefault is true we clear
|
||||
// every other default for the user inside the same transaction.
|
||||
func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (*ProfileView, error) {
|
||||
if err := validateProfileInput(in, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
|
||||
p := &model.PlayProfile{
|
||||
UserID: in.UserID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
IsDefault: in.IsDefault,
|
||||
ContentRatingLimit: in.ContentRatingLimit,
|
||||
AllowAdult: in.AllowAdult,
|
||||
RequirePIN: in.RequirePIN,
|
||||
PreferredSubtitleLang: in.PreferredSubtitleLang,
|
||||
PreferredAudioLang: in.PreferredAudioLang,
|
||||
AutoplayNext: in.AutoplayNext,
|
||||
SkipIntro: in.SkipIntro,
|
||||
AllowedLibraryIDs: string(libsBlob),
|
||||
}
|
||||
if in.RequirePIN && in.PIN != "" {
|
||||
p.PINHash = hashPIN(in.PIN)
|
||||
}
|
||||
if in.IsDefault {
|
||||
if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, in.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.repo.PlayProfile.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toProfileView(*p)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Update applies a patch to an existing profile.
|
||||
func (s *PlayProfileService) Update(ctx context.Context, id string, in PlayProfileInput) (*ProfileView, error) {
|
||||
row, err := s.repo.PlayProfile.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
if err := validateProfileInput(in, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
|
||||
patch := map[string]any{
|
||||
"name": strings.TrimSpace(in.Name),
|
||||
"is_default": in.IsDefault,
|
||||
"content_rating_limit": in.ContentRatingLimit,
|
||||
"allow_adult": in.AllowAdult,
|
||||
"require_pin": in.RequirePIN,
|
||||
"preferred_subtitle_lang": in.PreferredSubtitleLang,
|
||||
"preferred_audio_lang": in.PreferredAudioLang,
|
||||
"autoplay_next": in.AutoplayNext,
|
||||
"skip_intro": in.SkipIntro,
|
||||
"allowed_library_ids": string(libsBlob),
|
||||
}
|
||||
if in.RequirePIN && in.PIN != "" {
|
||||
patch["pin_hash"] = hashPIN(in.PIN)
|
||||
}
|
||||
if !in.RequirePIN {
|
||||
patch["pin_hash"] = ""
|
||||
}
|
||||
if in.IsDefault {
|
||||
if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, row.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.repo.PlayProfile.Update(ctx, id, patch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err = s.repo.PlayProfile.FindByID(ctx, id)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toProfileView(*row)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Delete removes a profile.
|
||||
func (s *PlayProfileService) Delete(ctx context.Context, id string) error {
|
||||
return s.repo.PlayProfile.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// TouchActive bumps the LastActiveAt timestamp; called by the player
|
||||
// when a profile is selected.
|
||||
func (s *PlayProfileService) TouchActive(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return s.repo.PlayProfile.Update(ctx, id, map[string]any{
|
||||
"last_active_at": &now,
|
||||
})
|
||||
}
|
||||
|
||||
// validateProfileInput rejects malformed payloads. On create we require
|
||||
// user_id; on update we allow it to be empty (caller supplies it via URL).
|
||||
func validateProfileInput(in PlayProfileInput, requireUser bool) error {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errors.New("name required")
|
||||
}
|
||||
if requireUser && strings.TrimSpace(in.UserID) == "" {
|
||||
return errors.New("user_id required")
|
||||
}
|
||||
if in.RequirePIN && in.PIN != "" {
|
||||
if len(in.PIN) < 4 || len(in.PIN) > 8 {
|
||||
return errors.New("pin must be 4-8 characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashPIN is a deterministic SHA-256 (PINs are short numeric secrets;
|
||||
// bcrypt is overkill for this use case).
|
||||
func hashPIN(pin string) string {
|
||||
sum := sha256.Sum256([]byte(pin))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -53,6 +53,8 @@ type Container struct {
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
PlayProfiles *PlayProfileService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Site *SiteService
|
||||
@@ -89,6 +91,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
emby := NewEmbyService(cfg, log, repos)
|
||||
backup := NewBackupService(cfg, log, repos.DB)
|
||||
notifier := NewNotifierService(log, repos)
|
||||
notifyChannels := NewNotifyChannelService(log, repos)
|
||||
playProfiles := NewPlayProfileService(log, repos)
|
||||
organizer := NewOrganizerService(cfg, log, repos)
|
||||
douban := NewDoubanProvider(cfg, log)
|
||||
siteService := NewSiteService(log, repos)
|
||||
@@ -134,6 +138,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
PlayProfiles: playProfiles,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Site: siteService,
|
||||
|
||||
@@ -84,6 +84,15 @@ const StrmPage = lazy(() =>
|
||||
const ToolsPage = lazy(() =>
|
||||
import('./pages/ToolsPage').then((m) => ({ default: m.ToolsPage })),
|
||||
)
|
||||
const ProfileManagementPage = lazy(() =>
|
||||
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
||||
)
|
||||
const NotifyChannelsPage = lazy(() =>
|
||||
import('./pages/NotifyChannelsPage').then((m) => ({ default: m.NotifyChannelsPage })),
|
||||
)
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -118,6 +127,7 @@ export default function App() {
|
||||
<Route path="sites" element={<SitesPage />} />
|
||||
<Route path="site-search" element={<SiteSearchPage />} />
|
||||
<Route path="ai" element={<AIAssistantPage />} />
|
||||
<Route path="play-profiles" element={<ProfileManagementPage />} />
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
@@ -190,6 +200,22 @@ export default function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="notify-channels"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<NotifyChannelsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<SettingsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="stats"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from './client'
|
||||
import type { DiscoverItem, DiscoverSection } from '../types'
|
||||
|
||||
// discoverExtraAPI wraps the Vue-style multi-section feed used by the
|
||||
// React DiscoverPage rails. Use the existing /discover/trending and
|
||||
// /discover/popular helpers for the simple cases.
|
||||
export const discoverExtraAPI = {
|
||||
sections: () =>
|
||||
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
|
||||
|
||||
feed: (sectionKeys: string[]) =>
|
||||
api
|
||||
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
|
||||
params: { sections: sectionKeys.join(',') },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from './client'
|
||||
import type { HistoryItem, HistoryStats, Media } from '../types'
|
||||
|
||||
// historyAPI wraps /watch-history and /history. The two share storage on
|
||||
// the backend; we treat /watch-history as the rich admin/dashboard
|
||||
// surface and /history as the legacy resume-position write.
|
||||
export const historyAPI = {
|
||||
list: (limit = 50) =>
|
||||
api
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit } })
|
||||
.then((r) => r.data),
|
||||
|
||||
stats: () => api.get<HistoryStats>('/watch-history/stats').then((r) => r.data),
|
||||
|
||||
continueWatching: (limit = 10) =>
|
||||
api
|
||||
.get<{ history: HistoryItem; media: Media }[]>('/watch-history/continue', {
|
||||
params: { limit },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
clear: (mediaID?: string) =>
|
||||
api
|
||||
.delete('/watch-history', {
|
||||
params: mediaID ? { media_id: mediaID } : {},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/watch-history/${id}`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { api } from './client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// Auxiliary media surfaces used by the home page rails and the admin
|
||||
// dashboard "library composition" card.
|
||||
export const mediaExtraAPI = {
|
||||
recent: (limit = 12) =>
|
||||
api.get<Media[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
||||
|
||||
stats: () =>
|
||||
api
|
||||
.get<{
|
||||
by_type: { movies: number; tv: number; anime: number; music: number; unscraped: number }
|
||||
total: number
|
||||
total_size: number
|
||||
total_seconds: number
|
||||
}>('/media/stats')
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { api } from './client'
|
||||
import type { NotifyChannel } from '../types'
|
||||
|
||||
// Payload accepted by create / update. `events` and `enabled` are optional.
|
||||
export interface NotifyChannelInput {
|
||||
name: string
|
||||
channel_type: NotifyChannel['channel_type']
|
||||
config: Record<string, string>
|
||||
events?: string[]
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
// notifyChannelsAPI wraps the admin /admin/notify/channels surface.
|
||||
export const notifyChannelsAPI = {
|
||||
list: () =>
|
||||
api.get<NotifyChannel[]>('/admin/notify/channels').then((r) => r.data),
|
||||
|
||||
create: (input: NotifyChannelInput) =>
|
||||
api.post<NotifyChannel>('/admin/notify/channels', input).then((r) => r.data),
|
||||
|
||||
update: (id: string, input: NotifyChannelInput) =>
|
||||
api.put<NotifyChannel>(`/admin/notify/channels/${id}`, input).then((r) => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/admin/notify/channels/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api.post<{ message: string }>(`/admin/notify/channels/${id}/test`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { api } from './client'
|
||||
import type { PlayProfile } from '../types'
|
||||
|
||||
// Payload accepted by create / update.
|
||||
export interface PlayProfileInput {
|
||||
user_id?: string
|
||||
name: string
|
||||
is_default: boolean
|
||||
content_rating_limit?: string
|
||||
allow_adult: boolean
|
||||
require_pin: boolean
|
||||
pin?: string
|
||||
preferred_subtitle_lang?: string
|
||||
preferred_audio_lang?: string
|
||||
autoplay_next: boolean
|
||||
skip_intro: boolean
|
||||
allowed_library_ids: string[]
|
||||
}
|
||||
|
||||
// playProfilesAPI wraps /play-profiles. The admin variant adds ?all=true.
|
||||
export const playProfilesAPI = {
|
||||
list: (all = false) =>
|
||||
api
|
||||
.get<PlayProfile[]>('/play-profiles', { params: all ? { all: 'true' } : {} })
|
||||
.then((r) => r.data),
|
||||
|
||||
create: (input: PlayProfileInput) =>
|
||||
api.post<PlayProfile>('/play-profiles', input).then((r) => r.data),
|
||||
|
||||
update: (id: string, input: PlayProfileInput) =>
|
||||
api.put<PlayProfile>(`/play-profiles/${id}`, input).then((r) => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/play-profiles/${id}`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api } from './client'
|
||||
import type { Hardware, Library, Media } from '../types'
|
||||
|
||||
// statsExtraAPI exposes the admin dashboard surfaces beyond /stats.
|
||||
export const statsExtraAPI = {
|
||||
overview: () =>
|
||||
api
|
||||
.get<{
|
||||
libraries: number
|
||||
media_count: number
|
||||
users_count: number
|
||||
total_size: number
|
||||
total_seconds: number
|
||||
generated_at: string
|
||||
}>('/stats/overview')
|
||||
.then((r) => r.data),
|
||||
|
||||
trend: (days = 14) =>
|
||||
api
|
||||
.get<{ trend: { day: string; count: number }[]; days: number }>('/stats/trend', {
|
||||
params: { days },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
topContent: (limit = 10) =>
|
||||
api
|
||||
.get<{
|
||||
items: { media: Media; play_count: number; last_played: string }[]
|
||||
}>('/stats/top-content', { params: { limit } })
|
||||
.then((r) => r.data),
|
||||
|
||||
libraries: () =>
|
||||
api
|
||||
.get<{
|
||||
libraries: { library: Library; item_count: number; total_size: number }[]
|
||||
}>('/stats/libraries')
|
||||
.then((r) => r.data),
|
||||
|
||||
monitor: () => api.get<Hardware>('/stats/monitor').then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { api } from './client'
|
||||
|
||||
// systemAPI wraps the read-only /system/* metadata endpoints used by the
|
||||
// React shell (footer, admin dashboard, scheduler page).
|
||||
export const systemAPI = {
|
||||
info: () =>
|
||||
api
|
||||
.get<{
|
||||
name: string
|
||||
version: string
|
||||
go: string
|
||||
os: string
|
||||
arch: string
|
||||
data_dir: string
|
||||
cache_dir: string
|
||||
}>('/system/info')
|
||||
.then((r) => r.data),
|
||||
|
||||
status: () =>
|
||||
api
|
||||
.get<{
|
||||
uptime_seconds: number
|
||||
goroutines: number
|
||||
cpu_percent?: number
|
||||
memory_used?: number
|
||||
memory_total?: number
|
||||
disk_used?: number
|
||||
disk_total?: number
|
||||
}>('/system/status')
|
||||
.then((r) => r.data),
|
||||
|
||||
scheduler: () =>
|
||||
api
|
||||
.get<{ jobs: { name: string; cron: string; next_run?: string; last_run?: string }[] }>(
|
||||
'/system/scheduler',
|
||||
)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
@@ -22,8 +23,10 @@ import {
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UserCog,
|
||||
Wrench,
|
||||
Library as LibraryIcon,
|
||||
User as UserIcon,
|
||||
@@ -98,6 +101,7 @@ export function Layout() {
|
||||
账号
|
||||
</div>
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
@@ -113,6 +117,8 @@ export function Layout() {
|
||||
<SidebarLink to="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||
<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="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
notifyChannelsAPI,
|
||||
type NotifyChannelInput,
|
||||
} from '../api/notify_channels'
|
||||
import type { NotifyChannel } from '../types'
|
||||
|
||||
// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
|
||||
// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
|
||||
// fire a test notification on demand.
|
||||
export function NotifyChannelsPage() {
|
||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<NotifyChannel | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setChannels(await notifyChannelsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
try {
|
||||
await notifyChannelsAPI.test(id)
|
||||
toast.success('测试消息已发送')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (ch: NotifyChannel) => {
|
||||
if (!confirm(`确定删除「${ch.name}」?`)) return
|
||||
try {
|
||||
await notifyChannelsAPI.remove(ch.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-amber-400/10 text-amber-300">
|
||||
<Bell size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">通知渠道</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
|
||||
</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 && channels.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无通知渠道</div>
|
||||
)}
|
||||
|
||||
{!loading && channels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{channels.map((ch) => (
|
||||
<ChannelCard
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
onTest={() => onTest(ch.id)}
|
||||
onEdit={() => {
|
||||
setEditing(ch)
|
||||
setShowForm(true)
|
||||
}}
|
||||
onDelete={() => onDelete(ch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ChannelFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<NotifyChannel['channel_type'], string> = {
|
||||
telegram: 'Telegram',
|
||||
wechat: '企业微信',
|
||||
bark: 'Bark',
|
||||
webhook: 'Webhook',
|
||||
}
|
||||
|
||||
function ChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const summary = channelSummary(channel)
|
||||
return (
|
||||
<div 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">{channel.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
|
||||
</span>
|
||||
{!channel.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">{summary}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
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={onEdit}
|
||||
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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function channelSummary(ch: NotifyChannel): string {
|
||||
const cfg = ch.config ?? {}
|
||||
switch (ch.channel_type) {
|
||||
case 'telegram':
|
||||
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
|
||||
case 'wechat':
|
||||
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
|
||||
case 'bark':
|
||||
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
|
||||
case 'webhook':
|
||||
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_CONFIG: Record<NotifyChannel['channel_type'], Record<string, string>> = {
|
||||
telegram: { bot_token: '', chat_id: '' },
|
||||
wechat: { sendkey: '' },
|
||||
bark: { device_key: '', server: '' },
|
||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||
}
|
||||
|
||||
function ChannelFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: NotifyChannel | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState(editing?.name ?? '')
|
||||
const [type, setType] = useState<NotifyChannel['channel_type']>(
|
||||
editing?.channel_type ?? 'telegram',
|
||||
)
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
editing?.config ?? EMPTY_CONFIG.telegram,
|
||||
)
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onTypeChange = (t: NotifyChannel['channel_type']) => {
|
||||
setType(t)
|
||||
setConfig({ ...EMPTY_CONFIG[t] })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const input: NotifyChannelInput = {
|
||||
name: name.trim(),
|
||||
channel_type: type,
|
||||
config,
|
||||
enabled,
|
||||
}
|
||||
if (editing) {
|
||||
await notifyChannelsAPI.update(editing.id, input)
|
||||
} else {
|
||||
await notifyChannelsAPI.create(input)
|
||||
}
|
||||
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 updateConfig = (k: string, v: string) =>
|
||||
setConfig((c) => ({ ...c, [k]: v }))
|
||||
|
||||
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"
|
||||
placeholder="如: Telegram 通知"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="渠道类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={type}
|
||||
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['channel_type'])}
|
||||
>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="wechat">企业微信 / Server酱</option>
|
||||
<option value="bark">Bark (iOS)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{type === 'telegram' && (
|
||||
<>
|
||||
<Field label="Bot Token">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="123456:ABC-DEF…"
|
||||
value={config.bot_token ?? ''}
|
||||
onChange={(e) => updateConfig('bot_token', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Chat ID">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="-100123456"
|
||||
value={config.chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'wechat' && (
|
||||
<Field label="SendKey">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="SCT…"
|
||||
value={config.sendkey ?? ''}
|
||||
onChange={(e) => updateConfig('sendkey', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === 'bark' && (
|
||||
<>
|
||||
<Field label="设备 Key">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.device_key ?? ''}
|
||||
onChange={(e) => updateConfig('device_key', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="服务器地址 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="https://api.day.app"
|
||||
value={config.server ?? ''}
|
||||
onChange={(e) => updateConfig('server', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'webhook' && (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="https://example.com/notify"
|
||||
value={config.url ?? ''}
|
||||
onChange={(e) => updateConfig('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Method">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.method ?? 'POST'}
|
||||
onChange={(e) => updateConfig('method', e.target.value)}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Headers (JSON)">
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"Content-Type":"application/json"}'
|
||||
value={config.headers ?? ''}
|
||||
onChange={(e) => updateConfig('headers', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Body 模板 (支持 {{title}} {{message}})">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"title":"{{title}}","message":"{{message}}"}'
|
||||
value={config.body_template ?? ''}
|
||||
onChange={(e) => updateConfig('body_template', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex cursor-pointer 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={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Trash2, UserCog } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
|
||||
// ProfileManagementPage replicates the Vue ProfileManagementView. It
|
||||
// lets a user (or admin) define multiple "viewing personas" with
|
||||
// different content-rating gates, library access, and player defaults.
|
||||
//
|
||||
// All persistence is real: data is written to /api/play-profiles which
|
||||
// is backed by the Go PlayProfileService.
|
||||
export function ProfileManagementPage() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
||||
const userID = useAuthStore((s) => s.user?.id ?? '')
|
||||
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<PlayProfile | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [p, l] = await Promise.all([
|
||||
playProfilesAPI.list(isAdmin),
|
||||
libraryAPI.list().catch(() => [] as Library[]),
|
||||
])
|
||||
setProfiles(p)
|
||||
setLibraries(l)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [isAdmin])
|
||||
|
||||
const onDelete = async (p: PlayProfile) => {
|
||||
if (!confirm(`确定删除 Profile「${p.name}」?`)) return
|
||||
try {
|
||||
await playProfilesAPI.remove(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (p: PlayProfile) => {
|
||||
setEditing(p)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
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-purple-400/10 text-purple-400">
|
||||
<UserCog size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">观影 Profile</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={openCreate} className="neon-button">
|
||||
<Plus size={16} /> 创建 Profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && profiles.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center">
|
||||
<div className="mb-2 text-4xl">👤</div>
|
||||
<p className="font-medium text-white">暂无 Profile</p>
|
||||
<p className="text-sm text-slate-400">点击右上角"创建 Profile"开始</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && profiles.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{profiles.map((p) => (
|
||||
<ProfileCard
|
||||
key={p.id}
|
||||
profile={p}
|
||||
libraries={libraries}
|
||||
onEdit={() => openEdit(p)}
|
||||
onDelete={() => onDelete(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ProfileFormModal
|
||||
editing={editing}
|
||||
libraries={libraries}
|
||||
defaultUserID={userID}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
profile,
|
||||
libraries,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: PlayProfile
|
||||
libraries: Library[]
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const libNames = useMemo(() => {
|
||||
if (!profile.allowed_library_ids?.length) return '全部'
|
||||
const idx = new Map(libraries.map((l) => [l.id, l.name]))
|
||||
return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ')
|
||||
}, [profile, libraries])
|
||||
|
||||
return (
|
||||
<div className="glass-panel flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||
style={{
|
||||
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
|
||||
}}
|
||||
>
|
||||
{profile.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-white">{profile.name}</span>
|
||||
{profile.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{profile.allow_adult && (
|
||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
成人内容
|
||||
</span>
|
||||
)}
|
||||
{profile.require_pin && (
|
||||
<span className="rounded bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
🔒 PIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||
{profile.content_rating_limit && <span>分级: {profile.content_rating_limit}</span>}
|
||||
<span>媒体库: {libNames}</span>
|
||||
<span>用户: {profile.user_id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
|
||||
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileFormModal({
|
||||
editing,
|
||||
libraries,
|
||||
defaultUserID,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: PlayProfile | null
|
||||
libraries: Library[]
|
||||
defaultUserID: string
|
||||
isAdmin: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<PlayProfileInput>(() => ({
|
||||
user_id: editing?.user_id ?? defaultUserID,
|
||||
name: editing?.name ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
content_rating_limit: editing?.content_rating_limit ?? '',
|
||||
allow_adult: editing?.allow_adult ?? false,
|
||||
require_pin: editing?.require_pin ?? false,
|
||||
pin: '',
|
||||
preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '',
|
||||
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
|
||||
autoplay_next: editing?.autoplay_next ?? true,
|
||||
skip_intro: editing?.skip_intro ?? false,
|
||||
allowed_library_ids: editing?.allowed_library_ids ?? [],
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const update = (patch: Partial<PlayProfileInput>) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const toggleLib = (id: string) => {
|
||||
const next = form.allowed_library_ids.includes(id)
|
||||
? form.allowed_library_ids.filter((x) => x !== id)
|
||||
: [...form.allowed_library_ids, id]
|
||||
update({ allowed_library_ids: next })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await playProfilesAPI.update(editing.id, form)
|
||||
} else {
|
||||
await playProfilesAPI.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)
|
||||
}
|
||||
}
|
||||
|
||||
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 ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{!editing && isAdmin && (
|
||||
<Field label="用户 ID">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.user_id ?? ''}
|
||||
onChange={(e) => update({ user_id: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
maxLength={50}
|
||||
placeholder="如:儿童模式、影院模式"
|
||||
value={form.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="设为默认 Profile"
|
||||
hint="登录后默认使用"
|
||||
checked={form.is_default}
|
||||
onChange={(v) => update({ is_default: v })}
|
||||
/>
|
||||
|
||||
<Field label="内容分级限制">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.content_rating_limit ?? ''}
|
||||
onChange={(e) => update({ content_rating_limit: e.target.value })}
|
||||
>
|
||||
<option value="">不限制</option>
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NC-17">NC-17</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="允许成人内容"
|
||||
hint="开启后可访问 NSFW 媒体"
|
||||
checked={form.allow_adult}
|
||||
onChange={(v) => update({ allow_adult: v })}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label="切换时需要 PIN"
|
||||
hint="切换到此 Profile 需输入 PIN 码"
|
||||
checked={form.require_pin}
|
||||
onChange={(v) => update({ require_pin: v })}
|
||||
/>
|
||||
{form.require_pin && (
|
||||
<Field label="PIN 码 (4-8 位)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
maxLength={8}
|
||||
placeholder={editing ? '留空保持不变' : '设置 PIN'}
|
||||
value={form.pin ?? ''}
|
||||
onChange={(e) => update({ pin: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="首选字幕">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_subtitle_lang ?? ''}
|
||||
onChange={(e) => update({ preferred_subtitle_lang: e.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ja">日语</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="首选音轨">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_audio_lang ?? ''}
|
||||
onChange={(e) => update({ preferred_audio_lang: e.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日语</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="自动播放下一集"
|
||||
checked={form.autoplay_next}
|
||||
onChange={(v) => update({ autoplay_next: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label="自动跳过片头"
|
||||
checked={form.skip_intro}
|
||||
onChange={(v) => update({ skip_intro: v })}
|
||||
/>
|
||||
|
||||
<Field label="允许访问的媒体库 (空 = 全部)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{libraries.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggleLib(l.id)}
|
||||
className={
|
||||
'rounded-full border px-3 py-1 text-xs ' +
|
||||
(form.allowed_library_ids.includes(l.id)
|
||||
? 'border-primary-400/60 bg-primary-400/10 text-primary-400'
|
||||
: 'border-white/10 text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
{libraries.length === 0 && (
|
||||
<span className="text-xs text-slate-500">暂无媒体库</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm text-white">{label}</div>
|
||||
{hint && <div className="text-xs text-slate-400">{hint}</div>}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import type { Setting } from '../types'
|
||||
|
||||
// SettingsPage replaces the Vue SettingsView's General / Organize /
|
||||
// Scrape / Adult tabs. The Go backend stores settings as a single
|
||||
// key/value table; we group the most useful keys client-side and let
|
||||
// the operator edit them with typed widgets (select / toggle / input).
|
||||
//
|
||||
// Any key not in the curated list still works via the AdminPage generic
|
||||
// key/value editor — this page is the curated UX over the same store.
|
||||
interface SettingDef {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
|
||||
hint?: string
|
||||
options?: { value: string; label: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SettingGroup {
|
||||
key: string
|
||||
label: string
|
||||
description?: string
|
||||
items: SettingDef[]
|
||||
}
|
||||
|
||||
const GROUPS: SettingGroup[] = [
|
||||
{
|
||||
key: 'general',
|
||||
label: '常规',
|
||||
description: 'TMDb / 转码引擎默认参数',
|
||||
items: [
|
||||
{
|
||||
key: 'tmdb.language',
|
||||
label: 'TMDb 元数据语言',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁体中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'ja-JP', label: '日本語' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transcode.enabled',
|
||||
label: '启用转码',
|
||||
type: 'toggle',
|
||||
hint: '关闭后所有视频直连播放',
|
||||
},
|
||||
{
|
||||
key: 'transcode.hw_accel',
|
||||
label: '硬件加速',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'auto', label: '自动检测' },
|
||||
{ value: 'none', label: '软件转码' },
|
||||
{ value: 'nvenc', label: 'NVIDIA NVENC' },
|
||||
{ value: 'qsv', label: 'Intel QSV' },
|
||||
{ value: 'vaapi', label: 'VAAPI (Linux)' },
|
||||
{ value: 'videotoolbox', label: 'VideoToolbox (macOS)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transcode.max_jobs',
|
||||
label: '最大并发转码任务',
|
||||
type: 'number',
|
||||
hint: '建议 1-4',
|
||||
},
|
||||
{
|
||||
key: 'ffmpeg.path',
|
||||
label: 'FFmpeg 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffmpeg',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.path',
|
||||
label: 'FFprobe 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffprobe',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'organize',
|
||||
label: '整理 & 刮削',
|
||||
description: '媒体文件命名 + 自动刮削开关',
|
||||
items: [
|
||||
{
|
||||
key: 'organize.auto',
|
||||
label: '入库时自动整理',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'organize.movie_format',
|
||||
label: '电影命名格式',
|
||||
type: 'text',
|
||||
hint: '例: {title} ({year})/{title} ({year})',
|
||||
placeholder: '{title} ({year})/{title} ({year})',
|
||||
},
|
||||
{
|
||||
key: 'organize.tv_format',
|
||||
label: '剧集命名格式',
|
||||
type: 'text',
|
||||
placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
|
||||
},
|
||||
{
|
||||
key: 'organize.anime_format',
|
||||
label: '动漫命名格式',
|
||||
type: 'text',
|
||||
placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
|
||||
},
|
||||
{
|
||||
key: 'scrape.auto_on_scan',
|
||||
label: '扫描后自动刮削',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'scrape.providers',
|
||||
label: '刮削源优先级',
|
||||
type: 'text',
|
||||
hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
|
||||
placeholder: 'tmdb,bangumi,thetvdb,fanart',
|
||||
},
|
||||
{
|
||||
key: 'scrape.language',
|
||||
label: '刮削首选语言',
|
||||
type: 'text',
|
||||
placeholder: 'zh-CN',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'adult',
|
||||
label: 'Adult / NSFW',
|
||||
description: '成人内容隔离开关 (默认隐藏)',
|
||||
items: [
|
||||
{
|
||||
key: 'adult.enabled',
|
||||
label: '启用成人内容',
|
||||
type: 'toggle',
|
||||
hint: '关闭后 NSFW 媒体不会出现在列表中',
|
||||
},
|
||||
{
|
||||
key: 'adult.require_pin',
|
||||
label: '访问需要 PIN',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'adult.pin',
|
||||
label: 'PIN 码',
|
||||
type: 'text',
|
||||
hint: '4-8 位数字',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qbittorrent',
|
||||
label: 'qBittorrent',
|
||||
description: '默认下载器配置',
|
||||
items: [
|
||||
{ key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' },
|
||||
{ key: 'qbittorrent.username', label: '用户名', type: 'text' },
|
||||
{ key: 'qbittorrent.password', label: '密码', type: 'text' },
|
||||
{ key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
|
||||
|
||||
export function SettingsPage() {
|
||||
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [dirty, setDirty] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const all = await adminAPI.listSettings()
|
||||
const idx: Record<string, string> = {}
|
||||
for (const s of all as Setting[]) {
|
||||
if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
|
||||
}
|
||||
setValues(idx)
|
||||
setDirty(new Set())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onChange = (key: string, value: string) => {
|
||||
setValues((v) => ({ ...v, [key]: value }))
|
||||
setDirty((d) => new Set(d).add(key))
|
||||
}
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (dirty.size === 0) return
|
||||
setSaving(true)
|
||||
try {
|
||||
// Backend exposes a single-key updater; loop through dirty keys.
|
||||
for (const key of dirty) {
|
||||
await adminAPI.updateSetting(key, values[key] ?? '')
|
||||
}
|
||||
toast.success(`已保存 ${dirty.size} 项配置`)
|
||||
setDirty(new Set())
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const group = GROUPS.find((g) => g.key === activeGroup)!
|
||||
|
||||
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-slate-400/10 text-slate-300">
|
||||
<SettingsIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">系统设置</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
按分组编辑 TMDb / 整理 / 刮削 / 下载器等关键配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto border-b border-white/10">
|
||||
{GROUPS.map((g) => (
|
||||
<button
|
||||
key={g.key}
|
||||
onClick={() => setActiveGroup(g.key)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm whitespace-nowrap transition ' +
|
||||
(activeGroup === g.key
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-slate-500">{group.description}</p>}
|
||||
{group.items.map((it) => (
|
||||
<SettingRow
|
||||
key={it.key}
|
||||
def={it}
|
||||
value={values[it.key] ?? ''}
|
||||
onChange={(v) => onChange(it.key, v)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-slate-500">
|
||||
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || dirty.size === 0}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
def,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
def: SettingDef
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||
<label className="text-sm text-slate-300">
|
||||
<div className="font-medium">{def.label}</div>
|
||||
{def.hint && <div className="mt-0.5 text-xs text-slate-500">{def.hint}</div>}
|
||||
<div className="mt-0.5 font-mono text-[10px] text-slate-600">{def.key}</div>
|
||||
</label>
|
||||
<div>
|
||||
{def.type === 'text' && (
|
||||
<input
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'textarea' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'select' && (
|
||||
<select className="input-base" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">(未设置)</option>
|
||||
{def.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{def.type === 'toggle' && (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={value === 'true' || value === '1' || value === 'on'}
|
||||
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
<span className="text-sm text-slate-300">{value === 'true' ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -140,3 +140,85 @@ export interface StatsSnapshot {
|
||||
hardware: Hardware
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
|
||||
// ─── Notify Channels ────────────────────────────────────────────────────────
|
||||
|
||||
export interface NotifyChannel {
|
||||
id: string
|
||||
name: string
|
||||
channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook'
|
||||
config: Record<string, string>
|
||||
enabled: boolean
|
||||
events: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ─── Play Profiles ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlayProfile {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
is_default: boolean
|
||||
content_rating_limit?: string
|
||||
allow_adult: boolean
|
||||
require_pin: boolean
|
||||
preferred_subtitle_lang?: string
|
||||
preferred_audio_lang?: string
|
||||
autoplay_next: boolean
|
||||
skip_intro: boolean
|
||||
allowed_library_ids: string[]
|
||||
total_watch_time: number
|
||||
last_active_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ─── History helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string
|
||||
user_id: string
|
||||
media_id: string
|
||||
position_ms: number
|
||||
duration_ms: number
|
||||
watched_at: string
|
||||
completed: boolean
|
||||
media?: Media
|
||||
}
|
||||
|
||||
export interface HistoryStats {
|
||||
total: number
|
||||
completed: number
|
||||
watched_ms: number
|
||||
watched_hours: number
|
||||
last_watched?: string
|
||||
}
|
||||
|
||||
// ─── Discover ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DiscoverSection {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface DiscoverItem {
|
||||
TMDbID?: number
|
||||
Title?: string
|
||||
Overview?: string
|
||||
Rating?: number
|
||||
Year?: number
|
||||
PosterURL?: string
|
||||
BackdropURL?: string
|
||||
// Match struct (Go) is exported with capitalised JSON keys; the API
|
||||
// returns lower-cased aliases below for convenience.
|
||||
tmdb_id?: number
|
||||
title?: string
|
||||
overview?: string
|
||||
rating?: number
|
||||
year?: number
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user