diff --git a/internal/handler/discover_extra.go b/internal/handler/discover_extra.go new file mode 100644 index 0000000..5e50df4 --- /dev/null +++ b/internal/handler/discover_extra.go @@ -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 "" + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 0a8a1b8..990d7f6 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) diff --git a/internal/handler/media_extra.go b/internal/handler/media_extra.go new file mode 100644 index 0000000..3e7f943 --- /dev/null +++ b/internal/handler/media_extra.go @@ -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, + }) + } +} diff --git a/internal/handler/notify_channels.go b/internal/handler/notify_channels.go new file mode 100644 index 0000000..b44518f --- /dev/null +++ b/internal/handler/notify_channels.go @@ -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"}) + } +} diff --git a/internal/handler/play_profile.go b/internal/handler/play_profile.go new file mode 100644 index 0000000..d7eff40 --- /dev/null +++ b/internal/handler/play_profile.go @@ -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) + } +} diff --git a/internal/handler/stats_extra.go b/internal/handler/stats_extra.go new file mode 100644 index 0000000..0144cf1 --- /dev/null +++ b/internal/handler/stats_extra.go @@ -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) + } +} diff --git a/internal/handler/system_meta.go b/internal/handler/system_meta.go new file mode 100644 index 0000000..a488b14 --- /dev/null +++ b/internal/handler/system_meta.go @@ -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()}) + } +} diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go new file mode 100644 index 0000000..565a7ec --- /dev/null +++ b/internal/handler/watch_history.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 3eac332..ebe6671 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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{}, } } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 5542fd9..fed1555 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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 +} diff --git a/internal/service/discover.go b/internal/service/discover.go index fce08a4..92d00e1 100644 --- a/internal/service/discover.go +++ b/internal/service/discover.go @@ -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 } diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go new file mode 100644 index 0000000..31dd924 --- /dev/null +++ b/internal/service/notify_channels.go @@ -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("%s\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)) +} diff --git a/internal/service/play_profile.go b/internal/service/play_profile.go new file mode 100644 index 0000000..299f18f --- /dev/null +++ b/internal/service/play_profile.go @@ -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[:]) +} diff --git a/internal/service/service.go b/internal/service/service.go index edeb851..5271e3d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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, diff --git a/web/src/App.tsx b/web/src/App.tsx index b812c63..c4fa41d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () =>
加载中…
@@ -118,6 +127,7 @@ export default function App() {+ 配置 Telegram / Bark / 企业微信 / Webhook 多通道推送 +
++ 为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则 +
+暂无 Profile
+点击右上角"创建 Profile"开始
++ 按分组编辑 TMDb / 整理 / 刮削 / 下载器等关键配置 +
+