Fix local adult metadata and watch history removal

This commit is contained in:
ShukeBta
2026-05-28 14:52:20 +08:00
parent 352edd86ed
commit 32ccb33fed
95 changed files with 6589 additions and 1307 deletions
+1
View File
@@ -57,6 +57,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
c.Status(http.StatusNoContent)
}
}
+13 -3
View File
@@ -28,11 +28,21 @@ func smartSearchHandler(svc *service.Container) gin.HandlerFunc {
return
}
// Run the actual library search using the cleaned query so the
// caller can render results in one round-trip.
// caller can render local + external results in one round-trip.
items, _ := svc.Media.SearchMedia(c.Request.Context(), intent.Query, 60)
external := service.SearchExternalMedia(
c.Request.Context(),
intent.Query,
intent.Year,
intent.Type,
svc.TMDb,
svc.Douban,
svc.Bangumi,
)
c.JSON(http.StatusOK, gin.H{
"intent": intent,
"items": items,
"intent": intent,
"items": items,
"external_items": external,
})
}
}
+31 -31
View File
@@ -7,6 +7,7 @@
package handler
import (
"context"
"net/http"
"strings"
@@ -22,34 +23,33 @@ 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": "即将上映"},
{"key": "tmdb_trending_day", "label": "TMDb 今日趋势", "provider": "tmdb"},
{"key": "tmdb_trending_week", "label": "TMDb 本周热门", "provider": "tmdb"},
{"key": "tmdb_popular_movie", "label": "TMDb 热门电影", "provider": "tmdb"},
{"key": "tmdb_popular_tv", "label": "TMDb 热门剧集", "provider": "tmdb"},
{"key": "tmdb_top_rated_movie", "label": "TMDb 高分电影", "provider": "tmdb"},
{"key": "douban_hot_movie", "label": "豆瓣热门电影", "provider": "douban"},
{"key": "douban_hot_tv", "label": "豆瓣热门剧集", "provider": "douban"},
{"key": "douban_top_movie", "label": "豆瓣高分电影", "provider": "douban"},
{"key": "bangumi_calendar", "label": "Bangumi 每日放送", "provider": "bangumi"},
},
})
}
}
// 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.
// to TMDb / Douban / Bangumi rails 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"), ",")
keys := strings.Split(c.DefaultQuery("sections", "tmdb_trending_day,tmdb_popular_movie,douban_hot_movie,bangumi_calendar"), ",")
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)
items, err := discoverSectionItems(c.Request.Context(), svc, k)
if err != nil {
svc.Log.Debug("discover fetch failed", )
svc.Log.Debug("discover fetch failed")
items = nil
}
out[k] = items
@@ -58,22 +58,22 @@ func discoverFeedHandler(svc *service.Container) gin.HandlerFunc {
}
}
// sectionPath maps the UI-facing key to the TMDb endpoint suffix.
func sectionPath(k string) string {
func discoverSectionItems(ctx context.Context, svc *service.Container, k string) ([]service.ExternalMediaResult, error) {
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"
case "tmdb_trending_day", "tmdb_trending_week", "tmdb_popular_movie", "tmdb_popular_tv", "tmdb_top_rated_movie",
"trending_day", "trending_week", "popular_movie", "popular_tv", "top_rated_movie", "upcoming_movie":
return svc.Discover.TMDbSection(ctx, k)
case "douban_hot_movie", "douban_hot_tv", "douban_top_movie":
if svc.Douban == nil {
return []service.ExternalMediaResult{}, nil
}
return svc.Douban.Discover(ctx, k)
case "bangumi_calendar":
if svc.Bangumi == nil {
return []service.ExternalMediaResult{}, nil
}
return svc.Bangumi.Calendar(ctx)
default:
return ""
return []service.ExternalMediaResult{}, nil
}
}
+2 -67
View File
@@ -4,15 +4,11 @@ package handler
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
@@ -38,72 +34,11 @@ func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw strin
if raw == "" || svc == nil || svc.Site == nil {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
host := strings.ToLower(u.Host)
// 找一个 host 匹配的已配置站点。
sites, err := svc.Site.List(ctx)
if err != nil || len(sites) == 0 {
return raw
}
var matched *model.Site
for i := range sites {
su, err := url.Parse(sites[i].URL)
if err != nil || su.Host == "" {
continue
}
if strings.EqualFold(su.Host, host) || strings.HasSuffix(host, "."+strings.ToLower(su.Host)) {
matched = &sites[i]
break
}
}
if matched == nil {
return raw
}
// 抽取 torrent id:?id=xxx 是 PT 站普遍写法。
id := u.Query().Get("id")
if id == "" {
return raw
}
adapter := service.GetAdapterForType(matched.Type)
if adapter == nil {
return raw
}
cfg := service.SiteConfig{
Name: matched.Name,
Type: matched.Type,
URL: strings.TrimRight(matched.URL, "/"),
AuthType: matched.AuthType,
Cookie: matched.Cookie,
APIKey: matched.APIKey,
AuthHeader: matched.AuthHeader,
UserAgent: matched.UserAgent,
Timeout: time.Duration(matched.Timeout) * time.Second,
UseProxy: matched.UseProxy,
}
if cfg.Timeout <= 0 {
cfg.Timeout = 15 * time.Second
}
resolveCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
if err != nil || resolved == "" {
log.Warn("resolve PT download URL failed (using raw URL)",
zap.String("site", matched.Name),
zap.String("type", matched.Type),
zap.String("raw", raw),
zap.Error(err))
resolved := svc.Site.ResolveDownloadURL(ctx, raw)
if resolved == raw {
return raw
}
log.Info("resolved PT download URL",
zap.String("site", matched.Name),
zap.String("from", raw),
zap.String("to", resolved))
return resolved
+12
View File
@@ -9,6 +9,18 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("library_id")
report, err := svc.Duplicate.Current(c.Request.Context(), libraryID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
}
func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("library_id")
+153 -24
View File
@@ -6,6 +6,8 @@
package handler
import (
"errors"
"io"
"net/http"
"net/url"
"strconv"
@@ -36,20 +38,55 @@ func embyUserID(c *gin.Context) string {
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Emby.SystemInfo())
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfo()))
}
}
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Emby.SystemInfoPublic())
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfoPublic()))
}
}
func embyRequestBaseURL(c *gin.Context) string {
proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if proto == "" {
if c.Request != nil && c.Request.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
if comma := strings.Index(proto, ","); comma >= 0 {
proto = strings.TrimSpace(proto[:comma])
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" && c.Request != nil {
host = strings.TrimSpace(c.Request.Host)
}
if host == "" {
return ""
}
return strings.TrimRight(proto+"://"+host, "/")
}
func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]any {
out := make(map[string]any, len(payload)+2)
for key, value := range payload {
out[key] = value
}
if address := embyRequestBaseURL(c); address != "" {
out["LocalAddress"] = address
out["WanAddress"] = address
}
return out
}
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"IsLocal": true,
"IsLocal": true,
"IsInNetwork": true,
})
}
@@ -70,14 +107,85 @@ type embyAuthByNameReq struct {
Password string `json:"Password"`
}
func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
req := embyAuthByNameReq{}
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "json") {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
return req, err
}
req.Username = firstStringFromMap(body, "Username", "username", "Name", "name")
req.Pw = firstStringFromMap(body, "Pw", "pw")
req.Password = firstStringFromMap(body, "Password", "password")
}
if req.Username == "" || (req.Pw == "" && req.Password == "") {
_ = c.Request.ParseForm()
if req.Username == "" {
req.Username = firstFormValue(c, "Username", "username", "Name", "name")
}
if req.Pw == "" {
req.Pw = firstFormValue(c, "Pw", "pw")
}
if req.Password == "" {
req.Password = firstFormValue(c, "Password", "password")
}
}
if req.Username == "" {
req.Username = firstQueryValue(c, "Username", "username", "Name", "name")
}
if req.Pw == "" {
req.Pw = firstQueryValue(c, "Pw", "pw")
}
if req.Password == "" {
req.Password = firstQueryValue(c, "Password", "password")
}
return req, nil
}
func firstStringFromMap(body map[string]any, keys ...string) string {
if len(body) == 0 {
return ""
}
for _, key := range keys {
if value, ok := body[key]; ok {
if s, ok := value.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
func firstFormValue(c *gin.Context, keys ...string) string {
for _, key := range keys {
if values, ok := c.Request.PostForm[key]; ok && len(values) > 0 {
if value := strings.TrimSpace(values[0]); value != "" {
return value
}
}
}
return ""
}
func firstQueryValue(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
//
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req embyAuthByNameReq
if err := c.ShouldBindJSON(&req); err != nil {
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
embyError(c, http.StatusBadRequest, "invalid body")
return
}
@@ -85,6 +193,10 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
if password == "" {
password = req.Password
}
if strings.TrimSpace(req.Username) == "" || password == "" {
embyError(c, http.StatusBadRequest, "missing username or password")
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
@@ -96,11 +208,11 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
"ServerId": "mediastation-go-001",
"User": userPayload,
"SessionInfo": gin.H{
"Id": resp.User.ID,
"UserId": resp.User.ID,
"UserName": resp.User.Username,
"Client": c.GetHeader("X-Emby-Client"),
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
"Id": resp.User.ID,
"UserId": resp.User.ID,
"UserName": resp.User.Username,
"Client": c.GetHeader("X-Emby-Client"),
"DeviceId": c.GetHeader("X-Emby-Device-Id"),
"DeviceName": c.GetHeader("X-Emby-Device-Name"),
},
})
@@ -438,8 +550,8 @@ func embySessionsHandler(_ *service.Container) gin.HandlerFunc {
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"LoginDisclaimer": "",
"CustomCss": "",
"LoginDisclaimer": "",
"CustomCss": "",
"SplashscreenEnabled": false,
})
}
@@ -460,14 +572,34 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
grp := r.Group(prefix)
// 公开端点
grp.GET("/System/Info/Public", embySystemInfoPublicHandler(svc))
grp.GET("/System/Ping", embyPingHandler(svc))
grp.HEAD("/System/Ping", embyPingHandler(svc))
grp.POST("/System/Ping", embyPingHandler(svc))
grp.POST("/Users/AuthenticateByName", embyAuthByNameHandler(svc))
grp.GET("/Users/Public", embyPublicUsersHandler(svc))
grp.GET("/Branding/Configuration", embyBrandingConfigHandler(svc))
grp.GET("/Localization/Options", embyLocalizationOptionsHandler(svc))
for _, path := range []string{"/System/Info/Public", "/system/info/public"} {
grp.GET(path, embySystemInfoPublicHandler(svc))
grp.HEAD(path, embySystemInfoPublicHandler(svc))
}
for _, path := range []string{"/System/Info", "/system/info"} {
grp.GET(path, embySystemInfoHandler(svc))
grp.HEAD(path, embySystemInfoHandler(svc))
}
for _, path := range []string{"/System/Endpoint", "/system/endpoint"} {
grp.GET(path, embySystemEndpointHandler(svc))
}
for _, path := range []string{"/System/Ping", "/system/ping"} {
grp.GET(path, embyPingHandler(svc))
grp.HEAD(path, embyPingHandler(svc))
grp.POST(path, embyPingHandler(svc))
}
for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} {
grp.POST(path, embyAuthByNameHandler(svc))
}
for _, path := range []string{"/Users/Public", "/users/public"} {
grp.GET(path, embyPublicUsersHandler(svc))
}
for _, path := range []string{"/Branding/Configuration", "/branding/configuration"} {
grp.GET(path, embyBrandingConfigHandler(svc))
}
for _, path := range []string{"/Localization/Options", "/localization/options"} {
grp.GET(path, embyLocalizationOptionsHandler(svc))
}
// 图片公开(Infuse 缓存 URL 时会丢 token)
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
@@ -475,10 +607,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
// 鉴权后端点
auth := grp.Group("", middleware.AuthRequired(jwtSecret))
auth.GET("/System/Info", embySystemInfoHandler(svc))
auth.GET("/System/Endpoint", embySystemEndpointHandler(svc))
auth := grp.Group("", middleware.EmbyAuthRequired(jwtSecret))
auth.GET("/Users/Me", embyMeHandler(svc))
auth.GET("/Users", embyListUsersHandler(svc))
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
+73
View File
@@ -0,0 +1,73 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader(`{"username":"alice","password":"secret"}`))
c.Request.Header.Set("Content-Type", "application/json")
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "alice" || req.Password != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader("Username=bob&Pw=secret"))
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "bob" || req.Pw != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
payload := embyWithRequestAddress(c, map[string]any{"Id": "mediastation-go-001"})
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
}
if payload["WanAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected WanAddress: %#v", payload["WanAddress"])
}
}
func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://127.0.0.1/System/Info/Public", nil)
c.Request.Header.Set("X-Forwarded-Proto", "https")
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
payload := embyWithRequestAddress(c, map[string]any{"Id": "mediastation-go-001"})
if payload["LocalAddress"] != "https://media.example.test" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
}
}
+106 -105
View File
@@ -134,135 +134,136 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
// Duplicate finder.
authed.GET("/duplicates", middleware.AdminRequired(), listDuplicatesHandler(svc))
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
// Site management + cross-site torrent search (via SiteHandler).
siteHandler := NewSiteHandler(svc)
authed.GET("/sites", siteHandler.ListSites)
authed.GET("/sites/types", siteHandler.GetSiteTypes)
authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
authed.GET("/sites/:id", siteHandler.GetSite)
authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
authed.GET("/sites/search", siteSearchHandler(svc))
// Site management + cross-site torrent search (via SiteHandler).
siteHandler := NewSiteHandler(svc)
authed.GET("/sites", siteHandler.ListSites)
authed.GET("/sites/types", siteHandler.GetSiteTypes)
authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
authed.GET("/sites/:id", siteHandler.GetSite)
authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
authed.GET("/sites/search", siteSearchHandler(svc))
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
authed.GET("/ws", wsHandler(svc))
authed.GET("/ws", wsHandler(svc))
// SSE event stream.
authed.GET("/events", sseHandler(svc))
// SSE event stream.
authed.GET("/events", sseHandler(svc))
// Scheduler.
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
// Scheduler.
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
// ── Auxiliary endpoints used by the React UI rails ──
authed.GET("/media/recent", recentMediaHandler(svc))
authed.GET("/media/stats", mediaStatsHandler(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))
// 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))
// 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))
// 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))
// 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))
// 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))
// ── Search aliases ──
authed.GET("/search", searchUnifiedHandler(svc))
authed.GET("/search/advanced", searchAdvancedHandler(svc))
authed.GET("/search/tmdb", searchTMDbHandler(svc))
authed.GET("/search/sites", searchSitesHandler(svc))
// ── Search aliases ──
authed.GET("/search", searchUnifiedHandler(svc))
authed.GET("/search/advanced", searchAdvancedHandler(svc))
authed.GET("/search/tmdb", searchTMDbHandler(svc))
authed.GET("/search/sites", searchSitesHandler(svc))
// ── System extras ──
authed.GET("/system/config", listSystemConfigHandler(svc))
authed.GET("/settings/schema", schemaHandler(svc))
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
// ── System extras ──
authed.GET("/system/config", listSystemConfigHandler(svc))
authed.GET("/settings/schema", schemaHandler(svc))
authed.GET("/system/events/ticket", systemEventsTicketHandler(svc))
// ── Per-user stats ──
authed.GET("/stats/user/:id", statsUserHandler(svc))
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
authed.POST("/stats/play", statsPlayHandler(svc))
// ── Per-user stats ──
authed.GET("/stats/user/:id", statsUserHandler(svc))
authed.GET("/stats/top-users", statsTopUsersHandler(svc))
authed.POST("/stats/play", statsPlayHandler(svc))
// ── Sites extras ──
authed.GET("/sites/:id/resource", siteResourceHandler(svc))
authed.GET("/sites/:id/userdata", siteUserdataHandler(svc))
// ── Sites extras ──
authed.GET("/sites/:id/resource", siteResourceHandler(svc))
authed.GET("/sites/:id/userdata", siteUserdataHandler(svc))
// ── Subscription extras ──
authed.PUT("/subscriptions/:id", updateSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/search", searchSubscriptionHandler(svc))
// ── Subscription extras ──
authed.PUT("/subscriptions/:id", updateSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/search", searchSubscriptionHandler(svc))
// ── Playlist extras ──
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
// ── Playlist extras ──
authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc))
authed.DELETE("/playlists/:id/items/by-id/:item_id", deletePlaylistItemByIDHandler(svc))
// ── DLNA per-renderer control ──
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
// ── DLNA per-renderer control ──
authed.POST("/dlna/:uuid/play", dlnaPlayHandler(svc))
authed.POST("/dlna/:uuid/pause", dlnaPauseHandler(svc))
authed.POST("/dlna/:uuid/stop", dlnaStopHandler(svc))
authed.GET("/dlna/:uuid/status", dlnaStatusHandler(svc))
// ── Media favourite alias surface ──
authed.GET("/favorites", listFavoritesAliasHandler(svc))
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
authed.POST("/media/:id/ai-scrape", aiScrapeMediaHandler(svc))
authed.POST("/media/scrape/test", scrapeTestHandler(svc))
authed.POST("/media/organize", middleware.AdminRequired(), organizeBulkHandler(svc))
// ── Media favourite alias surface ──
authed.GET("/favorites", listFavoritesAliasHandler(svc))
authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc))
authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc))
authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc))
authed.POST("/media/:id/ai-scrape", aiScrapeMediaHandler(svc))
authed.POST("/media/scrape/test", scrapeTestHandler(svc))
authed.POST("/media/organize", middleware.AdminRequired(), organizeBulkHandler(svc))
// ── Playback metadata + external player handoff ──
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
// ── Playback metadata + external player handoff ──
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc))
// ── Download task ops + sync triggers ──
authed.POST("/download/:id/pause", downloadPauseHandler(svc))
authed.POST("/download/:id/resume", downloadResumeHandler(svc))
authed.POST("/download/:id/organize", middleware.AdminRequired(), downloadOrganizeOneHandler(svc))
authed.POST("/download/organize", middleware.AdminRequired(), downloadOrganizeAllHandler(svc))
authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
// ── Download task ops + sync triggers ──
authed.POST("/download/:id/pause", downloadPauseHandler(svc))
authed.POST("/download/:id/resume", downloadResumeHandler(svc))
authed.POST("/download/:id/organize", middleware.AdminRequired(), downloadOrganizeOneHandler(svc))
authed.POST("/download/organize", middleware.AdminRequired(), downloadOrganizeAllHandler(svc))
authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
// ── Assistant (multi-turn AI chat) ──
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
// ── Assistant (multi-turn AI chat) ──
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
authed.GET("/admin/assistant/session/:id", getAssistantSessionHandler(svc))
authed.DELETE("/admin/assistant/session/:id", deleteAssistantSessionHandler(svc))
authed.POST("/admin/assistant/chat", assistantChatHandler(svc))
authed.POST("/admin/assistant/execute", assistantExecuteHandler(svc))
authed.POST("/admin/assistant/undo/:op_id", assistantUndoHandler(svc))
authed.GET("/admin/assistant/history", assistantHistoryHandler(svc))
}
// Admin-only endpoints.
+3 -11
View File
@@ -55,7 +55,7 @@ func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
// scrapeOneHandler enriches a single media via TMDb. Admin-only.
// scrapeOneHandler enriches a single media via the configured scraper chain.
func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
@@ -63,10 +63,6 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !svc.TMDb.Enabled() {
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "tmdb api key not configured"})
return
}
if err := svc.Scraper.EnrichOne(c.Request.Context(), m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -76,17 +72,13 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
}
}
// scrapeLibraryHandler enriches every pending media in a library. Admin-only.
// scrapeLibraryHandler retries every pending/no_match media in a library.
func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if !svc.TMDb.Enabled() {
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "tmdb api key not configured"})
return
}
// Run in the background so HTTP returns instantly; the WS hub
// pushes per-item progress on the "scrape" topic.
go func(libID string) {
_, _ = svc.Scraper.EnrichLibrary(context.Background(), libID)
_, _ = svc.Scraper.EnrichLibrary(context.Background(), libID, true)
}(c.Param("id"))
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
}
+7 -4
View File
@@ -22,10 +22,13 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
Model(&model.Subscription{}).
Where("id = ?", c.Param("id")).
Updates(map[string]any{
"name": patch.Name,
"feed_url": patch.FeedURL,
"filter": patch.Filter,
"enabled": patch.Enabled,
"name": patch.Name,
"feed_url": patch.FeedURL,
"filter": patch.Filter,
"media_type": patch.MediaType,
"media_category": patch.MediaCategory,
"save_path": patch.SavePath,
"enabled": patch.Enabled,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+15 -9
View File
@@ -12,10 +12,13 @@ import (
)
type subscriptionReq struct {
Name string `json:"name" binding:"required"`
FeedURL string `json:"feed_url" binding:"required"`
Filter string `json:"filter"`
Enabled *bool `json:"enabled"`
Name string `json:"name" binding:"required"`
FeedURL string `json:"feed_url" binding:"required"`
Filter string `json:"filter"`
MediaType string `json:"media_type"`
MediaCategory string `json:"media_category"`
SavePath string `json:"save_path"`
Enabled *bool `json:"enabled"`
}
func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
@@ -31,11 +34,14 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
enabled = *req.Enabled
}
s := &model.Subscription{
UserID: uid.(string),
Name: req.Name,
FeedURL: req.FeedURL,
Filter: req.Filter,
Enabled: enabled,
UserID: uid.(string),
Name: req.Name,
FeedURL: req.FeedURL,
Filter: req.Filter,
MediaType: req.MediaType,
MediaCategory: req.MediaCategory,
SavePath: req.SavePath,
Enabled: enabled,
}
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+3
View File
@@ -16,6 +16,9 @@ func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if tracks == nil {
tracks = []service.SubtitleTrack{}
}
c.JSON(http.StatusOK, gin.H{"tracks": tracks})
}
}