mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user