diff --git a/.gitignore b/.gitignore index bab536b..d55d2f3 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ config.yaml # Editor backups *~ +.tmp_* diff --git a/internal/handler/discover.go b/internal/handler/discover.go index 75e2810..fe98d36 100644 --- a/internal/handler/discover.go +++ b/internal/handler/discover.go @@ -5,15 +5,22 @@ import ( "net/http" "github.com/gin-gonic/gin" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/service" ) +// trendingHandler 返回 TMDb 当日热门列表。 +// +// 当本机无法连接 TMDb(GFW / 代理未配 / API key 无效)时,TMDb 调用会 +// 在 15 秒后超时;这种情况下不应该让首页显示 500 错误,而是把空列表 +// 直接返回——前端按 items.length === 0 渲染"暂无推荐"即可。 func trendingHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { items, err := svc.Discover.Trending(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + svc.Log.Warn("discover trending failed (returning empty list)", zap.Error(err)) + c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()}) return } if items == nil { @@ -27,7 +34,8 @@ func popularHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { items, err := svc.Discover.Popular(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + svc.Log.Warn("discover popular failed (returning empty list)", zap.Error(err)) + c.JSON(http.StatusOK, gin.H{"items": []service.Match{}, "error": err.Error()}) return } if items == nil { diff --git a/internal/handler/download_clients.go b/internal/handler/download_clients.go index f659c27..00a03f4 100644 --- a/internal/handler/download_clients.go +++ b/internal/handler/download_clients.go @@ -37,6 +37,9 @@ func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // 让真正发起下载的 DownloadService 立刻读到新的 qb 配置, + // 避免保存后还要重启进程才能生效。 + _ = svc.Downloads.ReloadConfig(c.Request.Context()) c.JSON(http.StatusOK, row) } } @@ -53,6 +56,7 @@ func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + _ = svc.Downloads.ReloadConfig(c.Request.Context()) c.JSON(http.StatusOK, row) } } @@ -63,6 +67,7 @@ func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + _ = svc.Downloads.ReloadConfig(c.Request.Context()) c.Status(http.StatusNoContent) } } diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go index 1034abe..0fccc21 100644 --- a/internal/handler/downloads.go +++ b/internal/handler/downloads.go @@ -2,11 +2,17 @@ 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" ) @@ -15,6 +21,94 @@ type addDownloadReq struct { SavePath string `json:"save_path"` } +// resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接 +// 拉到 .torrent 文件的真实下载 URL。 +// +// 链路: +// +// 1. 拿 URL 的 host,到 sites 表里找 base_url 同源的站点。 +// 2. 如果站点的 type 是已知 PT 框架(mteam/nexusphp/unit3d/...), +// 就用对应适配器的 GetDownloadURL,传入从 URL 里 parse 出来的 id。 +// 3. 任一步失败都直接返回原 URL,让 qb 自己去拉(保持向后兼容)。 +// +// 这一步存在的意义:M-Team 等站点的搜索结果里 download_url 是 +// /api/torrent/genDlToken?id=xxx,需要带 x-api-key 才能调用,qb 自己 +// 是没法识别这种 PT 专属端点的。 +func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw string, log *zap.Logger) string { + 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)) + return raw + } + log.Info("resolved PT download URL", + zap.String("site", matched.Name), + zap.String("from", raw), + zap.String("to", resolved)) + return resolved +} + func addDownloadHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req addDownloadReq @@ -23,12 +117,14 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc { return } uid, _ := c.Get(middleware.CtxUserID) - t, err := svc.Downloads.AddDownload(c.Request.Context(), uid.(string), req.URL, req.SavePath) + // 把站点搜索 URL 转换成真实可下载 URL(M-Team 走 genDlToken 等)。 + realURL := resolvePTDownloadURL(c.Request.Context(), svc, req.URL, svc.Log) + t, err := svc.Downloads.AddDownload(c.Request.Context(), uid.(string), realURL, req.SavePath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", req.URL, c.ClientIP(), "") + svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", realURL, c.ClientIP(), "") c.JSON(http.StatusOK, t) } } diff --git a/internal/handler/emby.go b/internal/handler/emby.go index c36f297..44fbb4c 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -1,26 +1,134 @@ // Package handler — Emby/Jellyfin compatibility shim. // -// Routes are mounted under /emby/* so existing Emby-aware clients -// (Infuse / VidHub / Kodi) point at MediaStationGo and discover the -// library through their familiar API. We do not implement write paths; -// the React UI stays the canonical control plane. +// 路由挂在 /emby/* 和根路径下双前缀。Infuse / Yamby / Hills / +// Senplayer / Kodi 这类客户端会自动尝试 /System/Info 与 /emby/System/Info +// 两种 URL,我们都接住。 package handler import ( "net/http" + "net/url" "strconv" + "strings" "github.com/gin-gonic/gin" + "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/service" ) +// embyError 返回 Emby 风格的错误(顶层 Code/Message)。 +func embyError(c *gin.Context, status int, msg string) { + c.JSON(status, gin.H{"Code": status, "Message": msg}) +} + +// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。 +func embyUserID(c *gin.Context) string { + if uid, ok := c.Get(middleware.CtxUserID); ok { + if s, ok := uid.(string); ok { + return s + } + } + return "" +} + +// ─── System ────────────────────────────────────────────────────────────────── + func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, svc.Emby.SystemInfo()) } } +func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, svc.Emby.SystemInfoPublic()) + } +} + +func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "IsLocal": true, + "IsInNetwork": true, + }) + } +} + +func embyPingHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + // Emby/Jellyfin 期望 plain text "Emby Server" + c.String(http.StatusOK, "Emby Server") + } +} + +// ─── Users / Auth ──────────────────────────────────────────────────────────── + +type embyAuthByNameReq struct { + Username string `json:"Username"` + Pw string `json:"Pw"` + Password string `json:"Password"` +} + +// 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 { + embyError(c, http.StatusBadRequest, "invalid body") + return + } + password := req.Pw + if password == "" { + password = req.Password + } + resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password) + if err != nil { + embyError(c, http.StatusUnauthorized, err.Error()) + return + } + userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID) + c.JSON(http.StatusOK, gin.H{ + "AccessToken": resp.Tokens.AccessToken, + "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"), + "DeviceName": c.GetHeader("X-Emby-Device-Name"), + }, + }) + } +} + +func embyPublicUsersHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + // 公开用户列表(Emby Web 客户端登录页拉这个,列出可见用户)。 + users, err := svc.Emby.ListUsers(c.Request.Context()) + if err != nil { + c.JSON(http.StatusOK, []any{}) + return + } + // 公开版本只暴露 Id + Name,不包含 Policy。 + out := make([]map[string]any, 0, len(users)) + for _, u := range users { + out = append(out, map[string]any{ + "Id": u["Id"], + "Name": u["Name"], + "ServerId": u["ServerId"], + "HasPassword": true, + }) + } + c.JSON(http.StatusOK, out) + } +} + func embyListUsersHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { users, err := svc.Emby.ListUsers(c.Request.Context()) @@ -32,6 +140,35 @@ func embyListUsersHandler(svc *service.Container) gin.HandlerFunc { } } +func embyMeHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid := embyUserID(c) + if uid == "" { + embyError(c, http.StatusUnauthorized, "not authenticated") + return + } + u, err := svc.Emby.FindUser(c.Request.Context(), uid) + if err != nil || u == nil { + embyError(c, http.StatusNotFound, "user not found") + return + } + c.JSON(http.StatusOK, u) + } +} + +func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId")) + if err != nil || u == nil { + embyError(c, http.StatusNotFound, "user not found") + return + } + c.JSON(http.StatusOK, u) + } +} + +// ─── Views / MediaFolders ──────────────────────────────────────────────────── + func embyViewsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { out, err := svc.Emby.Views(c.Request.Context()) @@ -43,12 +180,46 @@ func embyViewsHandler(svc *service.Container) gin.HandlerFunc { } } +// ─── Items ─────────────────────────────────────────────────────────────────── + +func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { + limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50")) + offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0")) + uid := c.Param("userId") + if uid == "" { + uid = embyUserID(c) + } + splitOpt := func(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out + } + return service.ItemsParams{ + UserID: uid, + ParentID: c.Query("ParentId"), + IDs: splitOpt(c.Query("Ids")), + SearchTerm: c.Query("SearchTerm"), + IncludeItemTypes: splitOpt(c.Query("IncludeItemTypes")), + Recursive: strings.EqualFold(c.Query("Recursive"), "true"), + SortBy: c.Query("SortBy"), + SortOrder: c.Query("SortOrder"), + Limit: limit, + StartIndex: offset, + } +} + func embyItemsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - libraryID := c.Query("ParentId") - limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50")) - offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0")) - out, err := svc.Emby.Items(c.Request.Context(), libraryID, limit, offset) + out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -57,6 +228,93 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc { } } +func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + uid := c.Param("userId") + if uid == "" { + uid = embyUserID(c) + } + out, err := svc.Emby.Item(c.Request.Context(), id, uid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if out == nil { + embyError(c, http.StatusNotFound, "item not found") + return + } + c.JSON(http.StatusOK, out) + } +} + +func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid := c.Param("userId") + if uid == "" { + uid = embyUserID(c) + } + limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20")) + out, err := svc.Emby.LatestItems(c.Request.Context(), uid, c.Query("ParentId"), limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, out) + } +} + +func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid := c.Param("userId") + if uid == "" { + uid = embyUserID(c) + } + limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "20")) + out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, out) + } +} + +// ─── Images ────────────────────────────────────────────────────────────────── + +// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求重定向到 +// 我们的 /api/img 代理。Emby 客户端会自动追加 ?api_key=... 或 ?tag=... +// 我们只关心 id+type,从 media row 拉到 PosterURL/BackdropURL 后转成 +// /api/img?url=... 重定向。 +func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + imgType := strings.ToLower(c.Param("type")) + m, err := svc.Repo.Media.FindByID(c.Request.Context(), id) + if err != nil || m == nil { + c.Status(http.StatusNotFound) + return + } + var raw string + switch imgType { + case "primary", "thumb", "banner", "logo": + raw = m.PosterURL + case "backdrop", "art": + raw = m.BackdropURL + default: + raw = m.PosterURL + } + if raw == "" { + c.Status(http.StatusNotFound) + return + } + // 直接重定向到 /api/img;image proxy 自己缓存 + 兜底 1×1 PNG。 + c.Redirect(http.StatusFound, "/api/img?url="+url.QueryEscape(raw)) + } +} + +// ─── Playback ──────────────────────────────────────────────────────────────── + func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id")) @@ -65,9 +323,199 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { return } if out == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + embyError(c, http.StatusNotFound, "not found") return } c.JSON(http.StatusOK, out) } } + +// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口, +// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。 +func embyVideoStreamHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + // 直接调用 Stream service 写入 response + err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id")) + if err != nil { + c.Status(http.StatusNotFound) + } + } +} + +// ─── 播放进度 / 收藏 / 已看 ──────────────────────────────────────────────── + +type embyPlayingReq struct { + ItemId string `json:"ItemId"` + PositionTicks int64 `json:"PositionTicks"` + RunTimeTicks int64 `json:"RunTimeTicks"` +} + +func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid := embyUserID(c) + if uid == "" { + c.Status(http.StatusUnauthorized) + return + } + var req embyPlayingReq + _ = c.ShouldBindJSON(&req) + // 兼容 query 形式(一些客户端在 /Sessions/Playing/* 用 query) + if req.ItemId == "" { + req.ItemId = c.Query("ItemId") + } + if req.PositionTicks == 0 { + req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64) + } + if req.RunTimeTicks == 0 { + req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64) + } + if req.ItemId == "" { + c.Status(http.StatusOK) // Emby 期望 2xx;不是关键操作 + return + } + _ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks) + c.Status(http.StatusNoContent) + } +} + +func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc { + return func(c *gin.Context) { + uid := c.Param("userId") + if uid == "" { + uid = embyUserID(c) + } + mid := c.Param("itemId") + if uid == "" || mid == "" { + c.Status(http.StatusBadRequest) + return + } + if err := svc.Emby.SetFavorite(c.Request.Context(), uid, mid, fav); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // Emby 期望返回 UserItemDataDto;最小可工作版本:echo Item 即可。 + out, _ := svc.Emby.Item(c.Request.Context(), mid, uid) + if out != nil { + c.JSON(http.StatusOK, out["UserData"]) + return + } + c.JSON(http.StatusOK, gin.H{"IsFavorite": fav}) + } +} + +func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc { + return func(c *gin.Context) { + uid := c.Param("userId") + if uid == "" { + uid = embyUserID(c) + } + mid := c.Param("itemId") + if uid == "" || mid == "" { + c.Status(http.StatusBadRequest) + return + } + if err := svc.Emby.MarkPlayed(c.Request.Context(), uid, mid, played); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + out, _ := svc.Emby.Item(c.Request.Context(), mid, uid) + if out != nil { + c.JSON(http.StatusOK, out["UserData"]) + return + } + c.JSON(http.StatusOK, gin.H{"Played": played}) + } +} + +// ─── Sessions / Branding 占位 ──────────────────────────────────────────────── + +func embySessionsHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, []any{}) + } +} + +func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "LoginDisclaimer": "", + "CustomCss": "", + "SplashscreenEnabled": false, + }) + } +} + +func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + c.JSON(http.StatusOK, []map[string]any{ + {"Name": "简体中文", "Value": "zh-CN"}, + {"Name": "English", "Value": "en-US"}, + }) + } +} + +// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。 +func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) { + for _, prefix := range []string{"/emby", ""} { + 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)) + + // 图片公开(Infuse 缓存 URL 时会丢 token) + grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc)) + grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc)) + 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.GET("/Users/Me", embyMeHandler(svc)) + auth.GET("/Users", embyListUsersHandler(svc)) + auth.GET("/Users/:userId", embyGetUserByIDHandler(svc)) + auth.GET("/Users/:userId/Views", embyViewsHandler(svc)) + auth.GET("/Library/MediaFolders", embyViewsHandler(svc)) + + auth.GET("/Items", embyItemsHandler(svc)) + auth.GET("/Users/:userId/Items", embyItemsHandler(svc)) + auth.GET("/Items/:id", embyItemByIDHandler(svc)) + auth.GET("/Users/:userId/Items/Latest", embyLatestItemsHandler(svc)) + auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc)) + + auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) + auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) + + auth.GET("/Videos/:id/stream", embyVideoStreamHandler(svc)) + auth.HEAD("/Videos/:id/stream", embyVideoStreamHandler(svc)) + auth.GET("/Videos/:id/stream.:container", embyVideoStreamHandler(svc)) + auth.HEAD("/Videos/:id/stream.:container", embyVideoStreamHandler(svc)) + auth.GET("/Videos/:id/original", embyVideoStreamHandler(svc)) + auth.GET("/Videos/:id/original.:container", embyVideoStreamHandler(svc)) + + auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc)) + auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc)) + auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc)) + + auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true)) + auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false)) + auth.POST("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, true)) + auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false)) + + auth.GET("/Sessions", embySessionsHandler(svc)) + auth.GET("/DisplayPreferences/:id", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"Id": c.Param("id"), "CustomPrefs": gin.H{}}) + }) + auth.POST("/DisplayPreferences/:id", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index b546450..dc541d6 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -25,7 +25,11 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C { auth.POST("/login", loginHandler(svc)) auth.POST("/register", registerHandler(svc)) - auth.POST("/refresh", refreshHandler(svc)) + // /auth/refresh 用 RefreshHandler.RefreshToken:它从 body 读 + // refresh_token 并签发新 access/refresh 对。旧的 refreshHandler + // 依赖 AuthRequired 中间件,永远 401,因此弃用。 + refreshHd := NewRefreshHandler(svc, log) + auth.POST("/refresh", refreshHd.RefreshToken) } // Authenticated endpoints. @@ -336,19 +340,11 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C apiConfig.POST("/:provider/test", testApiConfigHandler(svc)) } - // Emby/Jellyfin compatibility shim (read-only). - // Mounted at /emby/* (NOT /api/*) to mirror the upstream surface. + // Emby/Jellyfin compatibility shim — routes mounted at /emby/* AND + // the root path so Infuse / Yamby / Hills / Senplayer 都能自动连接。 } - emby := r.Group("/emby") - emby.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) - { - emby.GET("/System/Info", embySystemInfoHandler(svc)) - emby.GET("/Users", embyListUsersHandler(svc)) - emby.GET("/Users/:userId/Views", embyViewsHandler(svc)) - emby.GET("/Users/:userId/Items", embyItemsHandler(svc)) - emby.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc)) - } + registerEmbyRoutes(r, cfg.Secrets.JWTSecret, svc) } func healthCheck(c *gin.Context) { diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 59dd90f..cddb614 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -45,7 +45,10 @@ func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc { func imageProxyHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { raw := c.Query("url") - if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, raw); err != nil { + // Serve handles upstream errors internally by returning a 1×1 PNG + // placeholder, so the only error we can get back here is a malformed + // URL. In that case we still return 400 to make the misuse visible. + if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/internal/helper/http.go b/internal/helper/http.go index 8f95108..da86710 100644 --- a/internal/helper/http.go +++ b/internal/helper/http.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -14,6 +15,34 @@ import ( "go.uber.org/zap" ) +// NewSiteHTTPClient builds an http.Client honoring per-site policies: +// - timeout (seconds, defaults to 15) +// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on +// +// When useProxy is false, the client is created without proxy plumbing so +// the request goes out direct, matching the user's checkbox intent. +func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client { + if timeoutSeconds <= 0 { + timeoutSeconds = 15 + } + tr := &http.Transport{ + MaxIdleConns: 16, + MaxIdleConnsPerHost: 4, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + if useProxy { + tr.Proxy = func(r *http.Request) (*url.URL, error) { + return http.ProxyFromEnvironment(r) + } + } + return &http.Client{ + Timeout: time.Duration(timeoutSeconds) * time.Second, + Transport: tr, + } +} + // HTTPHeaderPresets returns a map of realistic browser HTTP headers. // These mimic a real Chrome browser to avoid WAF/bot detection. func HTTPHeaderPresets() map[string]string { @@ -193,11 +222,15 @@ func IsCloudflareChallenge(html string) bool { // ─── Site Connectivity Test ───────────────────────────────────────────── // TestSiteConnectivity performs a site connectivity test with browser-like headers. -// If flareSolverrURL is non-empty, it will attempt to use FlareSolverr first. +// If flareSolverrURL is non-empty AND the site has BrowserEmulation turned on, +// it will attempt to use FlareSolverr first. // Returns (ok, message, error). func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, log *zap.Logger) (bool, string, error) { - // Try FlareSolverr first if configured - if flareSolverrURL != "" { + // Try FlareSolverr first when (a) globally enabled and (b) the site + // asked for browser emulation. This matches the contract used by the + // search path (see service.SiteService.siteModelToConfig). + useFlare := flareSolverrURL != "" && site.BrowserEmulation + if useFlare { log.Info("Trying FlareSolverr for site test", zap.String("url", site.URL)) body, err := FetchURLWithFlareSolverr(flareSolverrURL, site.URL, site.Cookie, timeout, "", log) if err == nil { @@ -211,15 +244,15 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, // Fall through to direct request } - // Direct HTTP request with browser-like headers - client := &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return fmt.Errorf("too many redirects") - } - return nil - }, + // Direct HTTP request with browser-like headers. Honors HTTP(S)_PROXY + // when the site has UseProxy enabled — this makes the "use proxy" + // checkbox in the UI actually do something. + client := NewSiteHTTPClient(timeout, site.UseProxy) + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil } req, err := http.NewRequest("GET", site.URL, nil) @@ -253,7 +286,9 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, return false, "站点被 Cloudflare/WAF 拦截,请配置 FlareSolverr 或浏览器模拟", nil } - // Evaluate status code (same logic as before) + // Evaluate status code (mirror the reference Python project's semantics: + // 200 → success, 3xx → success/redirect-to-self, 401/403 → failure with + // hint to check credentials, 4xx/5xx → failure with raw status text). switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: return true, fmt.Sprintf("连接成功 (%s)", resp.Status), nil @@ -262,18 +297,18 @@ func TestSiteConnectivity(site *model.Site, flareSolverrURL string, timeout int, if loc == "" { loc = "(unknown)" } - return true, fmt.Sprintf("站点可达,但返回重定向至 %s", loc), nil + // Most PT sites redirect logged-out users to login; treat as failure. + return false, fmt.Sprintf("未登录或 Cookie 失效(重定向至 %s)", loc), nil case resp.StatusCode == 401: - return true, "站点可达,需要认证 (HTTP 401)", nil + return false, "未授权(HTTP 401),请检查 API Key / Cookie", nil case resp.StatusCode == 403: - return true, "站点可达,但访问被拒绝 — 可能被 Cloudflare/WAF 拦截 (HTTP 403)", nil + return false, "认证失败(HTTP 403),请检查 Cookie / API Key 或站点是否需要浏览器模拟", nil case resp.StatusCode == 429: - return true, "站点可达,但被限流 (HTTP 429)", nil + return false, "请求被限流(HTTP 429),请稍后再试", nil case resp.StatusCode == 503: - return true, "站点可达,服务暂时不可用 (HTTP 503)", nil + return false, "服务暂时不可用(HTTP 503)", nil default: - ok := resp.StatusCode >= 400 && resp.StatusCode < 500 - return ok, resp.Status, nil + return false, resp.Status, nil } } @@ -303,9 +338,7 @@ func ApplySiteAuthHeaders(req *http.Request, site *model.Site) { // GetPageSource fetches a page with browser-like headers. // Returns (pageSource, cookies, error). func GetPageSource(url string, site *model.Site, timeout int, log *zap.Logger) (string, string, error) { - client := &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - } + client := NewSiteHTTPClient(timeout, site.UseProxy) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 8cc1880..3de1829 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -182,9 +182,68 @@ func (r *LibraryRepository) Delete(ctx context.Context, id string) error { type MediaRepository struct{ db *gorm.DB } // Upsert inserts or updates a media row keyed by Path (unique index). +// +// 重要:当一条行已经存在时,scanner 重扫只应该刷新文件级元数据 +// (时长、宽高、编码、容器、大小),不能把刮削器维护的字段(标题改写、 +// 海报、TMDb/Bangumi ID、scrape_status 等)覆盖回零值。 +// +// 之前用 Assign(*m).FirstOrCreate(m) 会把整张零值结构体写回,导致: +// 1. scrape_status 从 'matched' / 'no_match' 被清空成 ''; +// 2. 新建行使 GORM `default:pending` 也得不到应用(因为 zero value 被 +// 显式写入)。这两个问题都让 EnrichLibrary(WHERE scrape_status='pending') +// 永远捞不到数据。 func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error { - return r.db.WithContext(ctx).Where("path = ?", m.Path). - Assign(*m).FirstOrCreate(m).Error + var existing model.Media + err := r.db.WithContext(ctx).Where("path = ?", m.Path).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + // 新行:保证 scrape_status 走 GORM default:pending(即留空让数据库填)。 + if m.ScrapeStatus == "" { + m.ScrapeStatus = "pending" + } + return r.db.WithContext(ctx).Create(m).Error + } + if err != nil { + return err + } + + // 已存在:仅刷新文件层面的字段。 + updates := map[string]any{ + "size_bytes": m.SizeBytes, + "duration_sec": m.DurationSec, + "width": m.Width, + "height": m.Height, + "video_codec": m.VideoCodec, + "audio_codec": m.AudioCodec, + "container": m.Container, + } + if m.Title != "" { + // scanner 给出的标题只是从路径推导,刮削后 title 已被替换为 + // 真实剧名。仅在 existing 还停留在 'pending'/'' 时回填扫描标题, + // 避免覆盖刮削结果。 + if existing.ScrapeStatus == "pending" || existing.ScrapeStatus == "" || existing.ScrapeStatus == "no_match" { + updates["title"] = m.Title + if m.Year > 0 { + updates["year"] = m.Year + } + } + } + if lib := m.LibraryID; lib != "" && lib != existing.LibraryID { + updates["library_id"] = m.LibraryID + } + if m.SeasonNum > 0 && existing.SeasonNum != m.SeasonNum { + updates["season_num"] = m.SeasonNum + } + if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum { + updates["episode_num"] = m.EpisodeNum + } + + if err := r.db.WithContext(ctx).Model(&model.Media{}). + Where("id = ?", existing.ID).Updates(updates).Error; err != nil { + return err + } + // 回写 ID / 不可变字段,让 caller 拿到完整的现有行。 + *m = existing + return nil } // FindByID returns the media row or (nil, nil). diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go index 21a7571..7810d2d 100644 --- a/internal/service/download_clients.go +++ b/internal/service/download_clients.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -129,23 +130,39 @@ func (s *DownloadClientService) Test(ctx context.Context, id string) error { } switch c.Type { case "qbittorrent": + host := strings.TrimRight(c.Host, "/") body := url.Values{} body.Set("username", c.Username) body.Set("password", c.Password) req, _ := http.NewRequestWithContext( ctx, http.MethodPost, - strings.TrimRight(c.Host, "/")+"/api/v2/auth/login", + host+"/api/v2/auth/login", strings.NewReader(body.Encode()), ) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // qBittorrent v4.6+ 默认开启 "host header validation" 并要求 + // Referer 与 Host 同源,否则即使账户正确也会拒绝登录。 + req.Header.Set("Referer", host) + req.Header.Set("Origin", host) + req.Header.Set("User-Agent", "MediaStationGo/0.1") resp, err := s.client.Do(req) if err != nil { return err } defer resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + return errors.New("qbittorrent: 403 — 用户名/密码错误,或 WebUI 启用了 IP 封禁") + } if resp.StatusCode >= 400 { return fmt.Errorf("qbittorrent returned %d", resp.StatusCode) } + // 即使返回 200,body 内容仍可能是 "Fails." 表示登录失败。 + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + text := strings.TrimSpace(string(raw)) + if text == "Fails." { + return errors.New("qbittorrent: 用户名/密码错误") + } + // 正确响应是 "Ok." — 但部分版本会重定向或返回空体,不强校验。 return nil case "aria2", "transmission": req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil) diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 69652d5..376879b 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -18,6 +18,7 @@ package service import ( "context" "errors" + "strings" "sync" "time" @@ -67,19 +68,41 @@ func (d *DownloadService) Stop() { close(d.stopCh) } -// ReloadConfig rebuilds the qBittorrent client from the system settings. +// ReloadConfig rebuilds the qBittorrent client from the configured +// download clients (preferred) or the legacy Setting table (fallback). +// +// 配置来源优先级: +// +// 1. download_clients 表中 type=qbittorrent 且 is_default=true 且 enabled=true +// 的行(侧边栏「下载器」页面写入的数据)。 +// 2. system Setting 表中的 qbittorrent.url / username / password +// (旧版「系统设置」表单写入的数据;保留作向后兼容)。 +// +// 这避免了两套配置各跑各的:之前操作员明明已经在「下载器」页面填好 +// 默认 qb,但实际下载链路读的还是 Setting 表,导致一直连不上。 func (d *DownloadService) ReloadConfig(ctx context.Context) error { cfg := QBitConfig{} - for _, key := range []struct{ from, into *string }{} { - _ = key + + // Path 1: download_clients 表 + if d.repo.DownloadClient != nil { + if c, err := d.repo.DownloadClient.FindDefault(ctx); err == nil && c != nil && c.Type == "qbittorrent" { + cfg.BaseURL = strings.TrimRight(c.Host, "/") + cfg.Username = c.Username + cfg.Password = c.Password + } } - get := func(k string) string { - v, _ := d.repo.Setting.Get(ctx, k) - return v + + // Path 2: legacy Setting 表(仅在 client 表未配置时回退) + if cfg.BaseURL == "" { + get := func(k string) string { + v, _ := d.repo.Setting.Get(ctx, k) + return v + } + cfg.BaseURL = get("qbittorrent.url") + cfg.Username = get("qbittorrent.username") + cfg.Password = get("qbittorrent.password") } - cfg.BaseURL = get("qbittorrent.url") - cfg.Username = get("qbittorrent.username") - cfg.Password = get("qbittorrent.password") + d.qb.Configure(cfg) return nil } @@ -171,11 +194,12 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa if d.organizer == nil || savePath == "" { return } - // Check if auto-organize after download is enabled - autoOrganize := d.organizer.isSmartClassifyEnabled(ctx) - // Also check dedicated config key + // 仅当显式开启 organizer.auto_after_download 时才在下载完成后整理。 + // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关, + // 让操作员只想启用"分类子目录"就被动触发了文件 move。 + autoOrganize := false if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil { - autoOrganize = autoOrganize || v == "true" || v == "1" || v == "on" + autoOrganize = v == "true" || v == "1" || v == "on" } if !autoOrganize { d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash)) diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index dc0e586..ab32214 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -1,33 +1,36 @@ -// Package service — minimal Emby/Jellyfin compatibility shim. +// Package service — Emby/Jellyfin compatibility shim. // // EmbyService produces JSON envelopes shaped like the most-consumed -// Emby-API endpoints so existing players (Infuse / Kodi NextPVR -// extension / iOS native clients) can talk to MediaStationGo without a -// custom plugin. +// Emby-API endpoints so existing players (Infuse / Yamby / Hills / +// Senplayer / Kodi NextPVR extension / iOS native clients) can talk to +// MediaStationGo without a custom plugin. // -// Implemented surface (matches what nowen-video exposes): -// -// GET /emby/System/Info server identity -// GET /emby/Users list of users (admin only field) -// GET /emby/Users/{userId}/Views virtual root: one entry per library -// GET /emby/Users/{userId}/Items paginated media listing -// GET /emby/Items/{id} single item -// GET /emby/Items/{id}/PlaybackInfo stream URL (delegates to /api/stream) -// -// The shim is read-only — Emby write operations (mark watched, etc.) are -// not implemented; the React UI stays the canonical control plane. +// The shim is read-mostly: items, images, playback are fully covered; +//播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory / +// Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到 +// 我们自己的 React UI。 package service import ( "context" + "errors" + "fmt" + "strconv" + "strings" + "time" "go.uber.org/zap" + "gorm.io/gorm" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) +// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到 +// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。 +const embyServerID = "mediastation-go-001" + // EmbyService produces Emby-shaped JSON. type EmbyService struct { cfg *config.Config @@ -40,17 +43,46 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai return &EmbyService{cfg: cfg, log: log, repo: repo} } -// SystemInfo returns the Emby identity payload. +// ─── System ────────────────────────────────────────────────────────────────── + +// SystemInfo returns the full Emby identity payload. func (e *EmbyService) SystemInfo() map[string]any { return map[string]any{ - "ServerName": "MediaStationGo", - "Version": "0.1.0", - "Id": "mediastation-go", - "OperatingSystem": "Linux", - "ProductName": "MediaStationGo", + "Id": embyServerID, + "ServerName": "MediaStationGo", + "Version": "10.8.13", + "ProductName": "MediaStationGo", + "OperatingSystem": "Linux", + "Architecture": "X64", + "LocalAddress": "", + "WanAddress": "", + "HasPendingRestart": false, + "IsShuttingDown": false, + "SupportsLibraryMonitor": true, + "SupportsHttps": false, + "SupportsAutoDiscovery": true, + "WebSocketPortNumber": e.cfg.App.Port, + "CompletedInstallations": []any{}, + "CanSelfRestart": false, + "CanLaunchWebBrowser": false, + "CanRestart": false, } } +// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。 +func (e *EmbyService) SystemInfoPublic() map[string]any { + return map[string]any{ + "Id": embyServerID, + "ServerName": "MediaStationGo", + "Version": "10.8.13", + "ProductName": "MediaStationGo", + "OperatingSystem": "Linux", + "StartupWizardCompleted": true, + } +} + +// ─── Users ─────────────────────────────────────────────────────────────────── + // ListUsers returns Emby-shaped users. func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) { users, err := e.repo.User.List(ctx) @@ -64,23 +96,62 @@ func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) { return out, nil } +// FindUser 用 ID 查用户,用于 /Users/Me 与 /Users/{id}。 +func (e *EmbyService) FindUser(ctx context.Context, id string) (map[string]any, error) { + u, err := e.repo.User.FindByID(ctx, id) + if err != nil || u == nil { + return nil, err + } + return e.userPayload(u), nil +} + func (e *EmbyService) userPayload(u *model.User) map[string]any { return map[string]any{ - "Id": u.ID, - "Name": u.Username, - "ServerId": "mediastation-go", - "HasPassword": true, + "Id": u.ID, + "Name": u.Username, + "ServerId": embyServerID, + "ServerName": "MediaStationGo", + "HasPassword": true, + "HasConfiguredPassword": true, "HasConfiguredEasyPassword": false, + "EnableAutoLogin": false, + "LastLoginDate": u.LastLoginAt, + "LastActivityDate": u.UpdatedAt, + "Configuration": map[string]any{ + "PlayDefaultAudioTrack": true, + "DisplayCollectionsView": true, + "DisplayMissingEpisodes": false, + "SubtitleMode": "Default", + "EnableNextEpisodeAutoPlay": true, + "AudioLanguagePreference": "", + "SubtitleLanguagePreference": "", + }, "Policy": map[string]any{ - "IsAdministrator": u.Role == "admin", - "IsHidden": false, - "IsDisabled": false, - "EnableUserPreferenceAccess": true, + "IsAdministrator": u.Role == "admin", + "IsHidden": false, + "IsDisabled": !u.IsActive, + "EnableUserPreferenceAccess": true, + "EnableRemoteAccess": true, + "EnableMediaPlayback": true, + "EnableAudioPlaybackTranscoding": true, + "EnableVideoPlaybackTranscoding": true, + "EnablePlaybackRemuxing": true, + "EnableLiveTvAccess": false, + "EnableContentDownloading": true, + "EnableSyncTranscoding": true, + "EnableMediaConversion": true, + "EnableAllChannels": true, + "EnableAllFolders": true, + "EnableAllDevices": true, + "AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider", + "PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider", }, } } -// Views (Emby's name for libraries). +// ─── Views / MediaFolders ──────────────────────────────────────────────────── + +// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。 func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) { libs, err := e.repo.Library.List(ctx) if err != nil { @@ -88,98 +159,454 @@ func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) { } items := make([]map[string]any, 0, len(libs)) for _, l := range libs { - collectionType := "movies" - if l.Type == "tv" { - collectionType = "tvshows" - } else if l.Type == "music" { - collectionType = "music" - } - items = append(items, map[string]any{ - "Id": l.ID, - "Name": l.Name, - "CollectionType": collectionType, - "ServerId": "mediastation-go", - "Type": "CollectionFolder", - }) + items = append(items, e.libraryAsView(&l)) } return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil } -// Items paginates media in Emby's flat shape. -func (e *EmbyService) Items(ctx context.Context, libraryID string, limit, offset int) (map[string]any, error) { - if limit <= 0 || limit > 200 { - limit = 50 +func (e *EmbyService) libraryAsView(l *model.Library) map[string]any { + collectionType := "movies" + switch l.Type { + case "tv": + collectionType = "tvshows" + case "anime": + collectionType = "tvshows" // Emby 没有专门的 anime CollectionType + case "music": + collectionType = "music" } - if offset < 0 { - offset = 0 + return map[string]any{ + "Id": l.ID, + "Name": l.Name, + "CollectionType": collectionType, + "ServerId": embyServerID, + "Type": "CollectionFolder", + "IsFolder": true, + "ImageTags": map[string]string{}, + "BackdropImageTags": []string{}, + "UserData": map[string]any{ + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "UnplayedItemCount": 0, + }, + } +} + +// ─── Items ─────────────────────────────────────────────────────────────────── + +// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。 +type ItemsParams struct { + UserID string + ParentID string + IDs []string + SearchTerm string + IncludeItemTypes []string + Recursive bool + SortBy string + SortOrder string + Limit int + StartIndex int +} + +// Items paginates media in Emby's flat shape. +func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) { + if p.Limit <= 0 || p.Limit > 500 { + p.Limit = 50 + } + if p.StartIndex < 0 { + p.StartIndex = 0 } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") - if libraryID != "" { - q = q.Where("library_id = ?", libraryID) + if p.ParentID != "" { + // ParentID 既可能是 library_id 也可能是 series_id(剧集详情下钻) + q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID) } + if len(p.IDs) > 0 { + q = q.Where("id IN ?", p.IDs) + } + if p.SearchTerm != "" { + q = q.Where("title LIKE ?", "%"+p.SearchTerm+"%") + } + var total int64 if err := q.Count(&total).Error; err != nil { return nil, err } + + // 排序 + order := "created_at desc" + switch strings.ToLower(p.SortBy) { + case "sortname", "name": + order = "title" + case "premieredate", "productionyear": + order = "year" + case "datecreated": + order = "created_at" + case "communityrating": + order = "rating" + } + if strings.EqualFold(p.SortOrder, "Descending") { + if !strings.HasSuffix(order, " desc") { + order = order + " desc" + } + } + var rows []model.Media - if err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error; err != nil { + if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil { return nil, err } + + // User-data: 收藏 + 进度 + userFavs := map[string]bool{} + userPos := map[string]int64{} + if p.UserID != "" { + var favs []model.Favorite + _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&favs).Error + for _, f := range favs { + userFavs[f.MediaID] = true + } + var hist []model.PlaybackHistory + _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&hist).Error + for _, h := range hist { + userPos[h.MediaID] = h.PositionMs + } + } + items := make([]map[string]any, 0, len(rows)) for _, m := range rows { - items = append(items, e.itemPayload(&m)) + items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID])) } return map[string]any{ "Items": items, "TotalRecordCount": total, - "StartIndex": offset, + "StartIndex": p.StartIndex, }, nil } -func (e *EmbyService) itemPayload(m *model.Media) map[string]any { +// Item 单条目详情。 +func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { + m, err := e.repo.Media.FindByID(ctx, mediaID) + if err != nil { + return nil, err + } + if m == nil { + return nil, nil + } + fav := false + pos := int64(0) + if userID != "" { + var f model.Favorite + ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error + if ferr == nil { + fav = true + } + var h model.PlaybackHistory + herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID). + Order("watched_at desc").First(&h).Error + if herr == nil { + pos = h.PositionMs + } + } + return e.itemPayload(m, fav, pos), nil +} + +// LatestItems 最近添加,全库或指定库。 +func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL") + if parentID != "" { + q = q.Where("library_id = ?", parentID) + } + var rows []model.Media + if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + favs := map[string]bool{} + if userID != "" { + var fr []model.Favorite + _ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&fr).Error + for _, f := range fr { + favs[f.MediaID] = true + } + } + out := make([]map[string]any, 0, len(rows)) + for _, m := range rows { + out = append(out, e.itemPayload(&m, favs[m.ID], 0)) + } + return out, nil +} + +// ResumeItems 列出有未完成播放进度的媒体。 +func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + type row struct { + MediaID string + PositionMs int64 + DurationMs int64 + } + var hist []model.PlaybackHistory + if err := e.repo.DB.WithContext(ctx). + Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false). + Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil { + return nil, err + } + if len(hist) == 0 { + return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil + } + ids := make([]string, 0, len(hist)) + posByID := map[string]int64{} + for _, h := range hist { + ids = append(ids, h.MediaID) + posByID[h.MediaID] = h.PositionMs + } + var medias []model.Media + if err := e.repo.DB.WithContext(ctx).Where("id IN ?", ids).Find(&medias).Error; err != nil { + return nil, err + } + // 维持时间倒序 + byID := map[string]*model.Media{} + for i := range medias { + byID[medias[i].ID] = &medias[i] + } + items := make([]map[string]any, 0, len(hist)) + for _, h := range hist { + if m, ok := byID[h.MediaID]; ok { + items = append(items, e.itemPayload(m, false, posByID[h.MediaID])) + } + } + return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil +} + +func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any { itemType := "Movie" if m.SeasonNum > 0 || m.EpisodeNum > 0 { itemType = "Episode" } + imageTags := map[string]string{} + backdropTags := []string{} + if m.PosterURL != "" { + imageTags["Primary"] = m.ID + } + if m.BackdropURL != "" { + backdropTags = append(backdropTags, m.ID+"-bd") + } + + runTimeTicks := int64(m.DurationSec) * 10_000_000 + durationMs := int64(m.DurationSec) * 1000 + played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10 + pct := 0.0 + if durationMs > 0 { + pct = float64(posMs) / float64(durationMs) * 100 + } + return map[string]any{ "Id": m.ID, "Name": m.Title, - "ServerId": "mediastation-go", + "OriginalTitle": m.OriginalName, + "ServerId": embyServerID, "Type": itemType, + "MediaType": "Video", + "IsFolder": false, "ProductionYear": m.Year, "ParentIndexNumber": m.SeasonNum, "IndexNumber": m.EpisodeNum, "Overview": m.Overview, - "RunTimeTicks": int64(m.DurationSec) * 10_000_000, + "RunTimeTicks": runTimeTicks, "CommunityRating": m.Rating, - "MediaSources": []map[string]any{{ - "Id": m.ID, - "Path": m.Path, - "Container": m.Container, - "Size": m.SizeBytes, - }}, + "Container": m.Container, + "Width": m.Width, + "Height": m.Height, + "DateCreated": m.CreatedAt, + "Path": m.Path, + "ParentId": m.LibraryID, + "SeriesId": m.SeriesID, + "ImageTags": imageTags, + "BackdropImageTags": backdropTags, + "Genres": splitCSV(m.Genres), + "ProviderIds": map[string]string{ + "Tmdb": intToStr(m.TMDbID), + "Bangumi": intToStr(m.BangumiID), + }, + "UserData": map[string]any{ + "PlaybackPositionTicks": posMs * 10_000, + "PlayCount": 0, + "IsFavorite": fav, + "Played": played, + "PlayedPercentage": pct, + }, + "MediaSources": []map[string]any{e.mediaSource(m, true)}, } } -// PlaybackInfo returns the stream URL (caller must append ?token=). +// ─── Playback ──────────────────────────────────────────────────────────────── + +// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients. func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) { m, err := e.repo.Media.FindByID(ctx, mediaID) if err != nil || m == nil { return nil, err } - url := "/api/stream/" + m.ID - if m.STRMURL != "" { - url = m.STRMURL - } return map[string]any{ - "MediaSources": []map[string]any{{ - "Id": m.ID, - "Path": url, - "Protocol": "Http", - "DirectStreamUrl": url, - "Container": m.Container, - "Size": m.SizeBytes, - }}, - "PlaySessionId": m.ID, + "MediaSources": []map[string]any{e.mediaSource(m, false)}, + "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()), }, nil } + +// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。 +// +// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露 +// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 完整指向 +// /api/stream/{id}(Emby 客户端会自动 append ?api_key=token)。 +func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]any { + src := map[string]any{ + "Id": m.ID, + "Name": m.Title, + "Path": m.Path, + "Container": m.Container, + "Size": m.SizeBytes, + "Protocol": "Http", + "Type": "Default", + "IsRemote": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "SupportsProbing": true, + "RunTimeTicks": int64(m.DurationSec) * 10_000_000, + "MediaStreams": e.mediaStreams(m), + } + if !asEmbedded { + // 完整 URL,让 Infuse 直接 GET。Emby 客户端会自动加 ?api_key=token。 + src["DirectStreamUrl"] = "/api/stream/" + m.ID + } + if strings.TrimSpace(m.STRMURL) != "" { + // STRM 重定向:客户端直接拉远端,跳过我们这一层。 + src["IsRemote"] = true + src["DirectStreamUrl"] = m.STRMURL + src["Path"] = m.STRMURL + } + return src +} + +func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any { + streams := []map[string]any{} + if m.VideoCodec != "" || m.Width > 0 { + streams = append(streams, map[string]any{ + "Codec": m.VideoCodec, + "Type": "Video", + "Index": 0, + "Width": m.Width, + "Height": m.Height, + "AspectRatio": "", + "IsDefault": true, + "IsForced": false, + "IsExternal": false, + "DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec), + }) + } + if m.AudioCodec != "" { + streams = append(streams, map[string]any{ + "Codec": m.AudioCodec, + "Type": "Audio", + "Index": 1, + "IsDefault": true, + "IsForced": false, + "IsExternal": false, + }) + } + return streams +} + +// ─── 收藏 / 已看(Emby 客户端写路径) ────────────────────────────────────── + +// SetFavorite 把 mediaID 标为 userID 的收藏。 +func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error { + if favorite { + var f model.Favorite + err := e.repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return e.repo.DB.WithContext(ctx).Create(&model.Favorite{ + UserID: userID, MediaID: mediaID, + }).Error + } + return err + } + return e.repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID). + Delete(&model.Favorite{}).Error +} + +// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。 +func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error { + if !played { + return e.repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID). + Delete(&model.PlaybackHistory{}).Error + } + m, err := e.repo.Media.FindByID(ctx, mediaID) + if err != nil || m == nil { + return errors.New("media not found") + } + dur := int64(m.DurationSec) * 1000 + if dur <= 0 { + dur = 1 + } + return e.repo.History.Upsert(ctx, &model.PlaybackHistory{ + UserID: userID, + MediaID: mediaID, + PositionMs: dur, + DurationMs: dur, + WatchedAt: time.Now(), + Completed: true, + }) +} + +// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。 +func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error { + pos := positionTicks / 10_000 + dur := runtimeTicks / 10_000 + if dur <= 0 { + // runtimeTicks 缺失时回退到 media.DurationSec + if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil { + dur = int64(m.DurationSec) * 1000 + } + } + completed := dur > 0 && pos >= dur*9/10 + return e.repo.History.Upsert(ctx, &model.PlaybackHistory{ + UserID: userID, + MediaID: mediaID, + PositionMs: pos, + DurationMs: dur, + WatchedAt: time.Now(), + Completed: completed, + }) +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return []string{} + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func intToStr(v int) string { + if v == 0 { + return "" + } + return strconv.Itoa(v) +} diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index f01ce66..5e77433 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -4,13 +4,16 @@ // networks). ImageProxy fronts a remote image URL so the browser only ever // talks to the MediaStationGo origin. The proxy: // -// - validates the URL belongs to a small allow-list of trusted hosts, +// - validates the URL scheme is http/https, // - streams bytes through with a small disk cache under cache/images, // - falls back to a transparent 1×1 PNG on upstream failure so the UI -// never breaks layout. +// never breaks layout, +// - honors HTTP(S)_PROXY environment variables so users behind GFW can +// route image fetches through their proxy. package service import ( + "bytes" "context" "crypto/sha1" "encoding/hex" @@ -29,52 +32,113 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" ) +// transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the +// upstream image cannot be retrieved, so browser layouts never collapse. +var transparent1x1PNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +} + +// knownImageHosts are hosts we explicitly recognize. The list is no longer +// a hard allow-list — it only short-circuits cases where we can be 100% +// sure the destination is a public image CDN. Other hosts are accepted as +// long as the scheme is http/https; this is required so users behind GFW +// can configure their own TMDb mirror via secrets.tmdb_image_proxy. +var knownImageHosts = map[string]struct{}{ + "image.tmdb.org": {}, + "www.themoviedb.org": {}, + "lain.bgm.tv": {}, + "img.bgm.tv": {}, + "webdav.bgm.tv": {}, + "img1.doubanio.com": {}, + "img2.doubanio.com": {}, + "img3.doubanio.com": {}, + "img9.doubanio.com": {}, + "assets.fanart.tv": {}, + "artworks.thetvdb.com": {}, +} + // ImageProxy fetches and caches remote images on behalf of the browser. type ImageProxy struct { - cfg *config.Config - log *zap.Logger - client *http.Client - cacheDir string - allowHost map[string]struct{} - mu sync.Mutex + cfg *config.Config + log *zap.Logger + client *http.Client + cacheDir string + mu sync.Mutex } // NewImageProxy is the constructor. func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy { + // Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull + // from image.tmdb.org via their HTTP proxy without extra config. + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 32, + MaxIdleConnsPerHost: 8, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } return &ImageProxy{ cfg: cfg, log: log, cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"), - client: &http.Client{Timeout: 20 * time.Second}, - allowHost: map[string]struct{}{ - "image.tmdb.org": {}, - "www.themoviedb.org": {}, - "lain.bgm.tv": {}, - "img.bgm.tv": {}, - "webdav.bgm.tv": {}, - "img1.doubanio.com": {}, - "img2.doubanio.com": {}, - "img3.doubanio.com": {}, - "img9.doubanio.com": {}, - "assets.fanart.tv": {}, - "artworks.thetvdb.com": {}, - }, + client: &http.Client{Timeout: 30 * time.Second, Transport: transport}, } } +// validateURL parses raw and ensures the scheme is http/https. The host +// allow-list is now advisory — every reachable URL is accepted so users +// can freely configure mirror domains via tmdb_image_proxy. +func (p *ImageProxy) validateURL(raw string) (*url.URL, error) { + if raw == "" { + return nil, errors.New("missing url") + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil, errors.New("invalid url") + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return nil, errors.New("unsupported scheme") + } + return u, nil +} + +// detectContentType returns the MIME type of data using the first 512 bytes. +func detectContentType(data []byte) string { + if len(data) > 512 { + return http.DetectContentType(data[:512]) + } + return http.DetectContentType(data) +} + +// servePlaceholder writes a 1×1 transparent PNG to w. Used as a fallback +// when upstream fetch fails so the browser layout stays intact. +func servePlaceholder(w http.ResponseWriter) { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(transparent1x1PNG) +} + // Serve writes the requested image to w. Caller is expected to validate // the JWT before invoking it. -func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw string) error { - if raw == "" { - return errors.New("missing url") - } - u, err := url.Parse(raw) - if err != nil || u.Scheme == "" || u.Host == "" { - return errors.New("invalid url") - } - if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok { - return errors.New("host not allowed") +func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error { + u, err := p.validateURL(raw) + if err != nil { + // Bad URL is the only request-side error; everything else falls + // through to the placeholder so the UI stays clean. + return err } + host := strings.ToLower(u.Host) // Cache key = sha1(url) sum := sha1.Sum([]byte(raw)) @@ -82,77 +146,96 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw strin cachePath := filepath.Join(p.cacheDir, key) // Cache hit. - if f, err := os.Open(cachePath); err == nil { - defer f.Close() - stat, _ := f.Stat() + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { + w.Header().Set("Content-Type", detectContentType(data)) w.Header().Set("Cache-Control", "public, max-age=604800") - http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f) + stat, _ := os.Stat(cachePath) + modTime := time.Now() + if stat != nil { + modTime = stat.ModTime() + } + http.ServeContent(w, r, key, modTime, bytes.NewReader(data)) return nil } // Cache miss → fetch upstream. if err := os.MkdirAll(p.cacheDir, 0o755); err != nil { - return err + p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err)) + servePlaceholder(w) + return nil } req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) if err != nil { - return err + p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err)) + servePlaceholder(w) + return nil } req.Header.Set("User-Agent", "MediaStationGo/0.1") resp, err := p.client.Do(req) if err != nil { - return err + p.log.Warn("imageproxy: upstream fetch failed", + zap.String("host", host), zap.Error(err)) + servePlaceholder(w) + return nil } defer resp.Body.Close() if resp.StatusCode >= 400 { - return errors.New("upstream returned " + resp.Status) + p.log.Warn("imageproxy: upstream returned non-OK", + zap.String("host", host), zap.String("status", resp.Status)) + servePlaceholder(w) + return nil + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) // 32 MiB cap + if err != nil || len(data) == 0 { + p.log.Warn("imageproxy: read upstream body failed", + zap.String("host", host), zap.Error(err)) + servePlaceholder(w) + return nil } // Write to a temp file then rename for atomicity. - tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp") - if err != nil { - return err - } - if _, err := io.Copy(tmp, resp.Body); err != nil { - tmp.Close() - os.Remove(tmp.Name()) - return err - } - tmp.Close() - if err := os.Rename(tmp.Name(), cachePath); err != nil { - os.Remove(tmp.Name()) - } - - // Now serve the freshly cached file. - f, err := os.Open(cachePath) - if err != nil { - return err - } - defer f.Close() - stat, _ := f.Stat() - for _, h := range []string{"Content-Type", "Content-Length", "ETag", "Last-Modified"} { - if v := resp.Header.Get(h); v != "" { - w.Header().Set(h, v) + p.mu.Lock() + tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-*.tmp") + if tmpErr == nil { + if _, werr := tmp.Write(data); werr == nil { + tmp.Close() + if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil { + _ = os.Remove(tmp.Name()) + } + } else { + tmp.Close() + _ = os.Remove(tmp.Name()) } } + p.mu.Unlock() + + ctype := resp.Header.Get("Content-Type") + if ctype == "" { + ctype = detectContentType(data) + } + w.Header().Set("Content-Type", ctype) + if v := resp.Header.Get("Content-Length"); v != "" { + w.Header().Set("Content-Length", v) + } + if v := resp.Header.Get("ETag"); v != "" { + w.Header().Set("ETag", v) + } + if v := resp.Header.Get("Last-Modified"); v != "" { + w.Header().Set("Last-Modified", v) + } w.Header().Set("Cache-Control", "public, max-age=604800") - http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f) + http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data)) return nil } // Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。 func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) { - if raw == "" { - return nil, "", errors.New("missing url") - } - u, err := url.Parse(raw) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil, "", errors.New("invalid url") - } - if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok { - return nil, "", errors.New("host not allowed") + u, err := p.validateURL(raw) + if err != nil { + return nil, "", err } // Cache lookup @@ -160,10 +243,8 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err key := hex.EncodeToString(sum[:]) cachePath := filepath.Join(p.cacheDir, key) - if data, err := os.ReadFile(cachePath); err == nil { - // Content-Type from file extension or upstream headers — use a simple detect - ctype := detectContentType(data) - return data, ctype, nil + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { + return data, detectContentType(data), nil } // Fetch upstream @@ -186,34 +267,32 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err return nil, "", errors.New("upstream returned " + resp.Status) } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return nil, "", err } // Write to cache - tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp") - if err == nil { - if _, err := tmp.Write(data); err == nil { + p.mu.Lock() + tmp, terr := os.CreateTemp(p.cacheDir, "img-*.tmp") + if terr == nil { + if _, werr := tmp.Write(data); werr == nil { tmp.Close() - os.Rename(tmp.Name(), cachePath) + if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil { + _ = os.Remove(tmp.Name()) + } } else { tmp.Close() - os.Remove(tmp.Name()) + _ = os.Remove(tmp.Name()) } } + p.mu.Unlock() ctype := resp.Header.Get("Content-Type") if ctype == "" { ctype = detectContentType(data) } + // host is unused here but referenced for log clarity in the future. + _ = u return data, ctype, nil } - -// detectContentType 通过前 512 字节检测 MIME 类型。 -func detectContentType(data []byte) string { - if len(data) > 512 { - return http.DetectContentType(data[:512]) - } - return http.DetectContentType(data) -} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 5396f42..b115e19 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -96,6 +96,17 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s return dst, nil } + // Refuse to overwrite an existing different file. 当多个 release(如 + // 不同字幕组、不同源)刮削后被统一改名,原本不重复的文件会被映射到 + // 同一个目标路径,盲目 move 会导致后者覆盖前者,造成数据丢失。 + if _, err := os.Stat(dst); err == nil { + o.log.Warn("organize skipped: destination already exists", + zap.String("media", m.ID), + zap.String("from", m.Path), + zap.String("to", dst)) + return dst, nil + } + // Create directories. if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return "", err @@ -149,7 +160,13 @@ func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string // moveFile tries os.Rename first (instant on same fs), then falls back // to copy + remove for cross-device moves. +// +// 重要:如果 dst 已经存在,moveFile 会直接报错而不是覆盖。OrganizeMedia +// 已经在调用前做过 stat 检查,这里是第二道防线。 func moveFile(src, dst string) error { + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("destination already exists: %s", dst) + } if err := os.Rename(src, dst); err == nil { return nil } @@ -158,9 +175,19 @@ func moveFile(src, dst string) error { if err != nil { return err } - if err := os.WriteFile(dst, data, 0o644); err != nil { + // O_EXCL 保证不会覆盖已存在的目标。 + f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { return err } + if _, werr := f.Write(data); werr != nil { + f.Close() + os.Remove(dst) + return werr + } + if cerr := f.Close(); cerr != nil { + return cerr + } return os.Remove(src) } diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index 2af8321..cfaedf7 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -114,6 +114,10 @@ func (q *QBitClient) Login(ctx context.Context) error { } // AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent. +// +// qBittorrent 的 /api/v2/torrents/add 在很多失败场景下仍然返回 HTTP 200 +// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免 +// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。 func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error { q.mu.Lock() defer q.mu.Unlock() @@ -137,16 +141,26 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin } req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Referer", q.cfg.BaseURL) + req.Header.Set("Origin", q.cfg.BaseURL) resp, err := q.client.Do(req) if err != nil { return err } defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + bodyText := strings.TrimSpace(string(raw)) if resp.StatusCode >= 400 { - raw, _ := io.ReadAll(resp.Body) - return fmt.Errorf("qbittorrent add: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText) } + // qb 的成功响应是 "Ok." 或空体;任何 "Fails." 视为失败。 + if strings.EqualFold(bodyText, "Fails.") { + return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)") + } + q.log.Info("qbittorrent: torrent added", + zap.String("url", magnetOrURL), + zap.String("save_path", savePath), + zap.String("body", bodyText)) return nil } diff --git a/internal/service/scraper.go b/internal/service/scraper.go index d3dae15..8ccf09f 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -60,12 +60,26 @@ var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`) // noiseTokens are stripped before search. var noiseTokens = []string{ + // 视频规格 "1080p", "2160p", "4k", "720p", "480p", "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", "x264", "x265", "h264", "h265", "hevc", "avc", "hdr", "sdr", "dts", "ddp", "atmos", "aac", "ac3", "flac", "remux", "extended", "uncut", "directors-cut", "directors_cut", "hkfree", "yify", "rarbg", "ettv", "fgt", + + // 流媒体平台 / 字幕组 / 国家版本(动漫常见) + "netflix", "nf", "amzn", "hulu", "disney", "max", "hbo", + "linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj", + "crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease", + "erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", + + // 中文字幕标记 + "zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc", + "中字", "繁字", "简中", "繁中", "国语", "粤语", "日语", + + // 季数前缀残留 — ParseEpisode 已抽取过 + "season", } // bracketedTag matches "[anything]" or "(anything)" segments. @@ -94,12 +108,23 @@ func CleanQuery(raw string) (title string, year int) { for _, t := range noiseTokens { lower = strings.ReplaceAll(lower, t, " ") } - for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} { + for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×", "x"} { lower = strings.ReplaceAll(lower, sep, " ") } - fields := strings.Fields(lower) - title = strings.Join(fields, " ") - return strings.TrimSpace(title), year + // 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免 + // 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。 + out := make([]string, 0, 8) + for _, w := range strings.Fields(lower) { + if len(w) <= 1 { + r := []rune(w) + if len(r) == 1 && r[0] < 128 { + continue + } + } + out = append(out, w) + } + title = strings.TrimSpace(strings.Join(out, " ")) + return title, year } // EnrichOne runs the provider chain for a single media row. @@ -126,7 +151,6 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { Update("scrape_status", "no_match").Error return nil } - // Optional Fanart upgrade. if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 { if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil { @@ -197,6 +221,16 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { // lookup runs the provider chain. When the library is missing we fall // back to TMDb only. +// +// 库类型决定首选 provider: +// +// anime -> Bangumi -> TMDb /search/tv -> TMDb /search/movie +// tv -> TheTVDB -> TMDb /search/tv -> TMDb /search/movie +// movie -> TMDb /search/movie +// (空) -> TMDb /search/movie +// +// 任何 provider 错误都不会中止链式查询;只要返回 nil/err,就继续走下一个 +// provider。这避免了 Bangumi token 未配置时 anime 库整体失败的问题。 func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match { kind := "" if lib != nil { @@ -204,21 +238,35 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s } switch kind { case "anime": - if s.bangumi != nil { + if s.bangumi != nil && s.bangumi.Enabled() { if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil { return m + } else if err != nil { + s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err)) } } case "tv": if s.thetvdb != nil && s.thetvdb.Enabled() { if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil { return m + } else if err != nil { + s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err)) } } } if s.tmdb != nil && s.tmdb.Enabled() { + // anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。 + if kind == "anime" || kind == "tv" { + if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil { + return m + } else if err != nil { + s.log.Debug("tmdb tv search failed", zap.String("query", query), zap.Error(err)) + } + } if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil { return m + } else if err != nil { + s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err)) } } return nil @@ -226,9 +274,13 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s // EnrichLibrary runs the provider chain for every "pending" media in a // library. It throttles to 4 RPS and publishes a summary event when done. +// +// Pending status includes both the canonical "pending" string and the +// empty / NULL values, because MediaRepository.Upsert can wipe the GORM +// default when re-running a scan over an already-existing row. func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) { var rows []model.Media - q := s.repo.DB.Where("scrape_status = ?", "pending") + q := s.repo.DB.Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?", "pending") if libraryID != "" { q = q.Where("library_id = ?", libraryID) } diff --git a/internal/service/site.go b/internal/service/site.go index f693040..f584057 100644 --- a/internal/service/site.go +++ b/internal/service/site.go @@ -81,8 +81,16 @@ func (s *SiteService) Delete(ctx context.Context, id string) error { // TestConnection tries to reach the site's base URL with the configured // credentials and reports success/failure. -// Now uses helper.TestSiteConnectivity with browser-like headers -// and optional FlareSolverr support. +// +// 测试逻辑(与参考项目 ShukeBta/MediaStation 对齐): +// +// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D / +// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常 +// 被 Cloudflare 直接 403 但 API 能正常访问。 +// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的 +// 通用浏览器头 GET 方案。 +// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了 +// BrowserEmulation 时,会自动走 FlareSolverr。 func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) { site, err := s.FindByID(ctx, id) if err != nil || site == nil { @@ -94,25 +102,58 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri if timeout <= 0 { timeout = 15 } - - // Use configured FlareSolverr URL (may be empty if not configured) flareSolverrURL := s.flareSolverrURL + // ── Path 1: site-aware adapter Authenticate ──────────────────────── + // custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。 + if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" { + cfg := s.siteModelToConfig(site) + actx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + if authErr := adapter.Authenticate(actx, cfg); authErr == nil { + now := time.Now() + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Updates(map[string]any{ + "login_status": "ok", + "last_error": "", + "last_check_at": &now, + }).Error + return true, "连接成功", nil + } else { + s.log.Warn("site adapter authenticate failed, falling back to generic test", + zap.String("site", site.Name), + zap.String("type", site.Type), + zap.Error(authErr)) + // 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会 + } + } + + // ── Path 2: generic GET with browser headers / FlareSolverr ─────── ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log) if err != nil { now := time.Now() _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{"last_error": err.Error(), "last_check_at": &now}).Error + Updates(map[string]any{ + "login_status": "fail", + "last_error": err.Error(), + "last_check_at": &now, + }).Error return false, err.Error(), nil } loginStatus := "ok" + storedError := "" if !ok { loginStatus = "fail" + storedError = msg } now := time.Now() _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{"last_error": loginStatus, "last_check_at": &now}).Error + Updates(map[string]any{ + "login_status": loginStatus, + "last_error": storedError, + "last_check_at": &now, + }).Error return ok, msg, nil } @@ -172,8 +213,11 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1) if err != nil { - s.log.Debug("site search failed", - zap.String("site", site.Name), zap.Error(err)) + s.log.Warn("site search failed", + zap.String("site", site.Name), + zap.String("type", site.Type), + zap.String("url", site.URL), + zap.Error(err)) return } if result == nil { @@ -249,5 +293,6 @@ func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig { Timeout: timeout, Extra: extra, FlareSolverrURL: flareSolverrURL, + UseProxy: s.UseProxy, } } diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go index d920451..88fe3bf 100644 --- a/internal/service/site_adapter.go +++ b/internal/service/site_adapter.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "os" "regexp" "strconv" "strings" @@ -30,6 +31,7 @@ type SiteConfig struct { Timeout time.Duration // 请求超时 Extra map[string]string // JSON 扩展配置 FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF) + UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站 } // SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。 @@ -97,8 +99,14 @@ type SiteAdapter interface { } // newHTTPClient 创建带有认证头的 HTTP 客户端。 +// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量; +// 否则忽略环境变量直连。 func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client { - return &http.Client{Timeout: timeout} + secs := int(timeout.Seconds()) + if secs <= 0 { + secs = 30 + } + return helper.NewSiteHTTPClient(secs, cfg.UseProxy) } // buildRequest 构建带认证的 HTTP 请求。 @@ -114,13 +122,11 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo req.Header.Set("Cookie", cfg.Cookie) } case "api_key": - // MTeam 使用 Authorization: Bearer 格式 - if cfg.Type == "mteam" { - if cfg.APIKey != "" { - req.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } - } else if cfg.APIKey != "" { - req.Header.Set("X-API-Key", cfg.APIKey) + // 与参考项目(ShukeBta/MediaStation)的 ApplySiteAuthHeaders 对齐: + // M-Team / UNIT3D 等开放 API 的 PT 站点都使用 `x-api-key` 头部, + // 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。 + if cfg.APIKey != "" { + req.Header.Set("x-api-key", cfg.APIKey) } case "auth_header": if cfg.AuthHeader != "" { @@ -169,7 +175,14 @@ func doRequest(ctx context.Context, client *http.Client, method, rawURL string, req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } - resp, err := client.Do(req) + // 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY + // 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。 + httpClient := client + if cfg.UseProxy { + httpClient = newHTTPClient(cfg, cfg.Timeout) + } + + resp, err := httpClient.Do(req) if err != nil { return nil, 0, err } @@ -197,33 +210,32 @@ func NewNexusPHPAdapter() *NexusPHPAdapter { } func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { - resp, err := buildRequest(ctx, "GET", cfg.URL+"/index.php", cfg, nil) - if err != nil { - return fmt.Errorf("build request: %w", err) - } - httpResp, err := a.client.Do(resp) + // 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。 + data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil) if err != nil { return fmt.Errorf("request failed: %w", err) } - defer httpResp.Body.Close() - if httpResp.StatusCode == http.StatusFound { + if status == http.StatusFound { return fmt.Errorf("authentication failed: redirected to login page") } - if httpResp.StatusCode != http.StatusOK { - return fmt.Errorf("authentication failed: status %d", httpResp.StatusCode) + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return fmt.Errorf("authentication failed: status %d", status) + } + if status >= 400 { + return fmt.Errorf("authentication failed: status %d", status) } - body, _ := io.ReadAll(httpResp.Body) - bodyStr := string(body) - // NexusPHP 登录页面通常包含 logout 或 userdetails - if strings.Contains(bodyStr, "userdetails") || strings.Contains(bodyStr, "logout") { + body := string(data) + // NexusPHP 登录后页面通常包含 logout 或 userdetails; + // 仅当二者都不存在且明确显示登录表单时才判失败。 + if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") { return nil } - // Check for common login indicators - if strings.Contains(bodyStr, "login") && !strings.Contains(bodyStr, "userdetails") { + if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") { return fmt.Errorf("authentication failed: not logged in") } + // 状态码 OK 但页面不含明显标记时不再武断判失败。 return nil } @@ -843,33 +855,68 @@ func NewMTeamAdapter() *MTeamAdapter { } func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { + // 与 ShukeBta/MediaStation 参考实现对齐: + // 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0" + // 或数值 0;兼容 M-Team v3 API 不同版本的返回。 u := cfg.URL + "/api/torrent/search" - payload := `{"mode":"search","keyword":"","page":1,"pageSize":1}` + payload := `{"pageNumber":1,"pageSize":1,"keyword":"test"}` data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload)) + // 调试开关:MEDIASTATION_DEBUG_MTEAM=1 时把请求/响应详情写入 stderr。 + if os.Getenv("MEDIASTATION_DEBUG_MTEAM") == "1" { + preview := string(data) + if len(preview) > 800 { + preview = preview[:800] + "..." + } + fmt.Fprintf(os.Stderr, + "[DEBUG mteam.Authenticate] url=%s status=%d err=%v body=%s\n", + u, status, err, preview) + } if err != nil { return fmt.Errorf("authenticate: %w", err) } - if status == http.StatusUnauthorized { - return fmt.Errorf("authentication failed: unauthorized") + preview := string(data) + if len(preview) > 400 { + preview = preview[:400] + "..." + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return fmt.Errorf("authentication failed: status %d, body=%s", status, preview) + } + if status >= 300 && status < 400 { + return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview) } if status != http.StatusOK { - return fmt.Errorf("authenticate failed: status %d", status) + return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview) } var resp map[string]interface{} - if err := json.Unmarshal(data, &resp); err == nil { - if code, ok := resp["code"].(float64); ok && code != 0 { - return fmt.Errorf("authentication failed: code %v", code) - } + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("parse response: %w (body=%s)", err, preview) } - return nil + codeStr := "" + switch v := resp["code"].(type) { + case string: + codeStr = v + case float64: + codeStr = strconv.Itoa(int(v)) + } + if codeStr == "0" || codeStr == "200" { + return nil + } + msg, _ := resp["message"].(string) + if msg == "" { + msg = fmt.Sprintf("code=%s", codeStr) + } + return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview) } func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { + // 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。 + if page <= 0 { + page = 1 + } payload := map[string]interface{}{ - "mode": "search", - "keyword": keyword, - "page": page, - "pageSize": 50, + "keyword": keyword, + "pageNumber": page, + "pageSize": 50, } body, _ := json.Marshal(payload) @@ -886,11 +933,16 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin } func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) { + if page <= 0 { + page = 1 + } payload := map[string]interface{}{ - "mode": "browse", - "category": category, - "page": page, - "pageSize": 50, + "keyword": "", + "pageNumber": page, + "pageSize": 50, + } + if category != "" { + payload["categories"] = []string{category} } body, _ := json.Marshal(payload) @@ -964,30 +1016,122 @@ func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) return detail, nil } +// GetDownloadURL 解析 M-Team 种子的真实下载链接。 +// +// M-Team v3 流程: +// +// POST /api/torrent/genDlToken?id={tid} (带 x-api-key) +// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."} +// +// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目 +// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。 func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) { - return cfg.URL + "/api/torrent/detail?id=" + id, nil + u := cfg.URL + "/api/torrent/genDlToken?id=" + id + // genDlToken 是 POST 但参数走 query string;body 留空。 + data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}")) + if err != nil { + return "", fmt.Errorf("genDlToken: %w", err) + } + if status >= 300 { + return "", fmt.Errorf("genDlToken: HTTP %d", status) + } + var resp map[string]interface{} + if err := json.Unmarshal(data, &resp); err != nil { + return "", fmt.Errorf("genDlToken parse: %w", err) + } + codeStr := "" + switch v := resp["code"].(type) { + case string: + codeStr = v + case float64: + codeStr = strconv.Itoa(int(v)) + } + if codeStr != "0" && codeStr != "200" { + msg, _ := resp["message"].(string) + if msg == "" { + msg = "unknown error" + } + return "", fmt.Errorf("genDlToken: %s", msg) + } + dl, _ := resp["data"].(string) + if dl == "" { + return "", fmt.Errorf("genDlToken: empty data field") + } + return dl, nil } -// parseMTeamJSON 解析 MTeam JSON 响应。 +// parseMTeamJSON 解析 MTeam v3 JSON 响应。 +// +// 响应结构(与 ShukeBta/MediaStation 参考项目一致): +// +// { +// "code": "0", // 字符串 "0" 表示成功 +// "message": "SUCCESS", +// "data": { +// "total": "123", +// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data" +// } +// } func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { - var resp struct { - Code int `json:"code"` - Data struct { - Total int `json:"total"` - Lists []map[string]interface{} `json:"lists"` - } `json:"data"` - } - if err := json.Unmarshal(data, &resp); err != nil { + // 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。 + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parse JSON: %w", err) } + // code 兼容字符串与数字。 + codeStr := "" + switch v := raw["code"].(type) { + case string: + codeStr = v + case float64: + codeStr = strconv.Itoa(int(v)) + } + if codeStr != "" && codeStr != "0" && codeStr != "200" { + msg, _ := raw["message"].(string) + if msg == "" { + msg = fmt.Sprintf("code=%s", codeStr) + } + return nil, fmt.Errorf("mteam: %s", msg) + } + + dataField, _ := raw["data"].(map[string]interface{}) + if dataField == nil { + return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil + } + + // total 兼容字符串与数字。 + total := 0 + switch v := dataField["total"].(type) { + case string: + total, _ = strconv.Atoi(v) + case float64: + total = int(v) + } + + // data.data(v3)优先;兜底兼容旧的 data.lists。 + var rows []interface{} + switch v := dataField["data"].(type) { + case []interface{}: + rows = v + } + if rows == nil { + if v, ok := dataField["lists"].([]interface{}); ok { + rows = v + } + } + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, - Total: resp.Data.Total, + Total: total, } - for _, t := range resp.Data.Lists { + for _, rawT := range rows { + t, ok := rawT.(map[string]interface{}) + if !ok { + continue + } item := TorrentItem{} if v, ok := t["id"].(string); ok { item.ID = v @@ -1007,6 +1151,11 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e } if v, ok := t["size"].(float64); ok { item.Size = int64(v) + } else if v, ok := t["size"].(string); ok { + // v3 API 把 size 序列化成字符串。 + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + item.Size = n + } } if v, ok := t["status"].(map[string]interface{}); ok { if seeders, ok := v["seeders"].(float64); ok { @@ -1027,6 +1176,10 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e } item.DetailURL = baseURL + "/detail/" + item.ID + // 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层 + // 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。 + // 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。 + item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID result.Items = append(result.Items, item) } diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index dc0c716..1ffb666 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -58,7 +58,20 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigSe apiConfig: apiConfig, base: base, imgCDN: img, - client: &http.Client{Timeout: 15 * time.Second}, + // 默认 8s 超时:首页同时调 trending + popular,15s 太久会让用户感觉 + // 卡死。如果 TMDb 真有问题,handler 层会快速降级返回空列表。 + // 同时让 client 显式读 HTTP(S)_PROXY 环境变量——这是 GFW 内部署最低 + // 成本能拉到 TMDb 的方式。 + client: &http.Client{ + Timeout: 8 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 16, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 8 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + }, } } @@ -197,6 +210,72 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int) return m, nil } +// SearchTV issues `/search/tv` and returns the best match. Used by anime / +// tv libraries before falling back to SearchMovie. +func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) { + if query == "" { + return nil, errors.New("empty query") + } + + apiKey := t.resolveAPIKey(ctx) + if apiKey == "" { + return nil, nil + } + base := t.resolveBaseURL(ctx) + + q := url.Values{} + q.Set("api_key", apiKey) + q.Set("query", query) + q.Set("language", "zh-CN") + q.Set("include_adult", "false") + if year > 0 { + q.Set("first_air_date_year", fmt.Sprintf("%d", year)) + } + u := base + "/search/tv?" + q.Encode() + + type result struct { + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + VoteAverage float32 `json:"vote_average"` + } + type page struct { + Results []result `json:"results"` + } + + var p page + if err := t.getJSON(ctx, u, &p); err != nil { + return nil, err + } + if len(p.Results) == 0 { + return nil, nil + } + r := p.Results[0] + m := &Match{ + TMDbID: r.ID, + Title: r.Name, + Overview: r.Overview, + Rating: r.VoteAverage, + } + if m.Title == "" { + m.Title = r.OriginalName + } + if r.PosterPath != "" { + m.PosterURL = t.imgCDN + "/w500" + r.PosterPath + } + if r.BackdropPath != "" { + m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath + } + if len(r.FirstAirDate) >= 4 { + fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year) + } + return m, nil +} + func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/web/src/api/discover.ts b/web/src/api/discover.ts index 492079d..50b8947 100644 --- a/web/src/api/discover.ts +++ b/web/src/api/discover.ts @@ -6,16 +6,30 @@ import type { Media } from '../types' export interface DiscoverItem extends Partial { tmdb_id: number title: string - overview: string - poster_url: string - backdrop_url: string + poster_url?: string + backdrop_url?: string + overview?: string year: number rating: number } +// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." } +// 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0 +// 推断"未配置 API key"。 +export interface DiscoverResp { + items: DiscoverItem[] + error?: string +} + export const discoverAPI = { trending: () => - api.get<{ items: DiscoverItem[] }>('/discover/trending').then((r) => r.data.items ?? []), + api.get('/discover/trending').then((r) => ({ + items: r.data.items ?? [], + error: r.data.error, + })), popular: () => - api.get<{ items: DiscoverItem[] }>('/discover/popular').then((r) => r.data.items ?? []), + api.get('/discover/popular').then((r) => ({ + items: r.data.items ?? [], + error: r.data.error, + })), } diff --git a/web/src/api/download_clients.ts b/web/src/api/download_clients.ts index c5aaf22..2a923d4 100644 --- a/web/src/api/download_clients.ts +++ b/web/src/api/download_clients.ts @@ -2,13 +2,14 @@ import { api } from './client' export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission' +// 与后端 model.DownloadClient 字段对齐(json:"host")。 +// 之前前端用的 `url` / `save_path` 字段会被后端忽略并触发 400 binding 错误。 export interface DownloadClient { id: string name: string type: DownloadClientType - url: string + host: string username?: string - save_path?: string is_default: boolean enabled: boolean created_at: string @@ -18,10 +19,9 @@ export interface DownloadClient { export interface DownloadClientInput { name: string type: DownloadClientType - url: string + host: string username?: string password?: string - save_path?: string is_default: boolean enabled: boolean } diff --git a/web/src/api/refresh.ts b/web/src/api/refresh.ts index b32f2e7..25eb4c8 100644 --- a/web/src/api/refresh.ts +++ b/web/src/api/refresh.ts @@ -13,12 +13,22 @@ export interface RefreshTokenResponse { token_type: string } -// 刷新访问令牌 +// 刷新访问令牌。 +// +// 后端响应封装在 { code, message, data } 里,需解包 .data。 +// /auth/login 的响应是直接展开的({tokens:..., user:...}), +// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。 export async function refreshToken(refreshToken: string): Promise { - const resp = await api.post('/auth/refresh', { - refresh_token: refreshToken, - }) - return resp.data as unknown as RefreshTokenResponse + const resp = await api.post<{ + code: number + message: string + data: RefreshTokenResponse + }>('/auth/refresh', { refresh_token: refreshToken }) + const body = resp.data + if (!body || !body.data || !body.data.token) { + throw new Error(body?.message || 'refresh failed') + } + return body.data } // 登出 diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 120f036..66331d7 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -4,7 +4,12 @@ import { Film, Play } from 'lucide-react' import { imageURL } from '../api/client' import type { Media } from '../types' -/** Compact poster tile — used by library, search, favourites, poster wall, etc. */ +/** + * Compact poster tile — used by library, search, favourites, poster wall, etc. + * + * 视觉密度调小:之前字体偏大、卡片整体偏宽,导致每页只能塞下 5 个左右; + * 现在文字行 truncate + 字号缩到 12px,让 8 列网格不再"挤"。 + */ export function MediaCard({ media, progress, @@ -29,14 +34,14 @@ export function MediaCard({ /> ) : (
- +
)} {/* Hover overlay */} -
- - +
+ + 播放
@@ -53,12 +58,12 @@ export function MediaCard({
{/* Info */} -
-

+

+

{media.title}

{media.year > 0 && ( -

{media.year}

+

{media.year}

)}
diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx index 87ba641..4970254 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -1,42 +1,64 @@ import { useEffect, useState } from 'react' -import { Sparkles, AlertTriangle, ExternalLink } from 'lucide-react' +import { Sparkles, AlertTriangle, ExternalLink, Wifi } from 'lucide-react' import { Link } from 'react-router-dom' import { discoverAPI, type DiscoverItem } from '../api/discover' import { imageURL } from '../api/client' +// 判断后端返回的错误是不是"未配置 API key"。其它(网络超时/被墙/上游 5xx) +// 都归为"网络/上游故障",避免误导用户去再配一次 key。 +function isMissingKey(err?: string): boolean { + if (!err) return false + const low = err.toLowerCase() + return low.includes('api key') || low.includes('apikey') || low.includes('not configured') +} + +function isNetworkError(err?: string): boolean { + if (!err) return false + const low = err.toLowerCase() + return ( + low.includes('deadline exceeded') || + low.includes('timeout') || + low.includes('no such host') || + low.includes('connection refused') || + low.includes('eof') || + low.includes('tls') || + low.includes('reset') + ) +} + export function DiscoverPage() { const [trending, setTrending] = useState([]) const [popular, setPopular] = useState([]) + const [trendingErr, setTrendingErr] = useState() + const [popularErr, setPopularErr] = useState() const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) useEffect(() => { setLoading(true) - setError(null) - Promise.all([ - discoverAPI.trending().catch((err) => { - console.error('Failed to fetch trending:', err) - return [] as DiscoverItem[] - }), - discoverAPI.popular().catch((err) => { - console.error('Failed to fetch popular:', err) - return [] as DiscoverItem[] - }), + discoverAPI.trending().catch((err) => ({ + items: [] as DiscoverItem[], + error: err instanceof Error ? err.message : String(err), + })), + discoverAPI.popular().catch((err) => ({ + items: [] as DiscoverItem[], + error: err instanceof Error ? err.message : String(err), + })), ]) .then(([t, p]) => { - setTrending(t) - setPopular(p) - }) - .catch((err) => { - setError(err instanceof Error ? err.message : 'Failed to load discover data') + setTrending(t.items) + setTrendingErr(t.error) + setPopular(p.items) + setPopularErr(p.error) }) .finally(() => setLoading(false)) }, []) - // Check if TMDB API key is likely not configured - const isTMDBMissing = !loading && trending.length === 0 && popular.length === 0 && !error + const anyErr = trendingErr || popularErr + const missingKey = isMissingKey(anyErr) + const networkErr = !missingKey && isNetworkError(anyErr) + const otherErr = !missingKey && !networkErr && anyErr return (
@@ -53,26 +75,17 @@ export function DiscoverPage() {
- {/* Error Alert */} - {error && ( -
- -

{error}

-
- )} - - {/* Loading State */} {loading && } - {/* TMDB API Key Missing */} - {isTMDBMissing && ( + {/* TMDb API Key 未配置 */} + {!loading && missingKey && (

TMDb API Key 未配置

- 您需要配置 TMDb API Key 才能查看发现内容。请前往管理后台配置。 + 您需要在管理后台填入 TMDb API Key 才能查看发现内容。

)} + {/* 网络无法访问 TMDb */} + {!loading && networkErr && ( +
+
+ +
+

无法连接到 TMDb

+

+ 服务器到 api.themoviedb.org 的连接超时。 + 通常是因为部署机器没有走代理。可以在系统环境变量里设置 + HTTPS_PROXY, + 或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。 +

+
+ 查看原始错误 +
{anyErr}
+
+
+ )} + + {/* 其它错误 */} + {!loading && otherErr && ( +
+ +

{otherErr}

+
+ )} + {/* Content Rows */} - {!loading && !isTMDBMissing && ( + {!loading && !missingKey && (
{trending.length > 0 && } {popular.length > 0 && } - - {/* Empty State (TMDB configured but no data) */} - {trending.length === 0 && popular.length === 0 && ( + + {/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */} + {!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (

暂无发现内容

@@ -106,7 +147,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) return (

{title}

-
+
{items.map((item) => ( ))} @@ -117,8 +158,7 @@ function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) function DiscoverCard({ item }: { item: DiscoverItem }) { return ( -
- {/* Poster */} +
{item.poster_url ? ( ) : ( -
+
无海报
)} - - {/* Rating Badge */} {item.rating > 0 && ( -
+
★ {item.rating.toFixed(1)}
)}
- - {/* Info */} -
-

+

+

{item.title}

{item.year > 0 && ( -

{item.year}

+

{item.year}

)}
@@ -161,9 +197,9 @@ function DiscoverSkeleton() { {[1, 2].map((section) => (
-
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( +
))}
diff --git a/web/src/pages/DownloadClientsPage.tsx b/web/src/pages/DownloadClientsPage.tsx index 701b3c5..baf5f09 100644 --- a/web/src/pages/DownloadClientsPage.tsx +++ b/web/src/pages/DownloadClientsPage.tsx @@ -118,9 +118,8 @@ export function DownloadClientsPage() { )}
- {c.url} + {c.host} {c.username && ` · ${c.username}`} - {c.save_path && ` · ${c.save_path}`}
@@ -177,10 +176,9 @@ function ClientFormModal({ const [form, setForm] = useState(() => ({ name: editing?.name ?? '', type: editing?.type ?? 'qbittorrent', - url: editing?.url ?? '', + host: editing?.host ?? '', username: editing?.username ?? '', password: '', - save_path: editing?.save_path ?? '', is_default: editing?.is_default ?? false, enabled: editing?.enabled ?? true, })) @@ -246,8 +244,8 @@ function ClientFormModal({ required className="input-base" placeholder={placeholder} - value={form.url} - onChange={(e) => update('url', e.target.value)} + value={form.host} + onChange={(e) => update('host', e.target.value)} /> {form.type !== 'aria2' && ( @@ -279,13 +277,6 @@ function ClientFormModal({ /> )} - - update('save_path', e.target.value)} - /> -