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:
Kiro Agent
2026-05-16 09:01:09 +00:00
parent 261d342fa1
commit 7095d9ebec
27 changed files with 3015 additions and 12 deletions
+79
View File
@@ -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 ""
}
}
+40
View File
@@ -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))
+70
View File
@@ -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,
})
}
}
+73
View File
@@ -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"})
}
}
+86
View File
@@ -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)
}
}
+155
View File
@@ -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)
}
}
+66
View File
@@ -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()})
}
}
+162
View File
@@ -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)
}
}