// Package handler — download manager endpoints. package handler import ( "context" "errors" "net/http" "net/url" "strings" "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" ) type addDownloadReq struct { URL string `json:"url" binding:"required"` SavePath string `json:"save_path"` Title string `json:"title"` PosterURL string `json:"poster_url"` BackdropURL string `json:"backdrop_url"` Overview string `json:"overview"` MediaType string `json:"media_type"` MediaCategory string `json:"media_category"` SourceCategory string `json:"source_category"` } // 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 } resolved := svc.Site.ResolveDownloadURL(ctx, raw) if resolved == raw { return raw } log.Info("resolved PT download URL", zap.String("from", redactDownloadURL(raw)), zap.String("to", redactDownloadURL(resolved))) return resolved } func addDownloadHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req addDownloadReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } uid, _ := c.Get(middleware.CtxUserID) // 把站点搜索 URL 转换成真实可下载 URL(M-Team 走 genDlToken 等)。 realURL := resolvePTDownloadURL(c.Request.Context(), svc, req.URL, svc.Log) fallbackTitle := req.Title if strings.TrimSpace(fallbackTitle) == "" { fallbackTitle = realURL } meta := enrichDownloadTaskMeta(c.Request.Context(), svc, service.DownloadTaskMeta{ Title: req.Title, PosterURL: req.PosterURL, BackdropURL: req.BackdropURL, Overview: req.Overview, MediaType: req.MediaType, MediaCategory: req.MediaCategory, SourceCategory: req.SourceCategory, }, fallbackTitle, req.MediaType) t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta) if err != nil { if errors.Is(err, service.ErrMediaAlreadyInLibrary) { c.JSON(http.StatusConflict, gin.H{"error": "media already exists in library"}) return } if errors.Is(err, service.ErrDownloadAlreadyExists) { c.JSON(http.StatusOK, t) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", redactDownloadURL(realURL), c.ClientIP(), "") c.JSON(http.StatusCreated, t) } } func listDownloadsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { rows, live, err := svc.Downloads.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } rows = visibleDownloadRows(c, rows) enrichAndPersistDownloadRows(c.Request.Context(), svc, rows) if !isAdminRequest(c) { live = visibleLiveTorrents(rows, live) } taskViews, torrentViews := service.DownloadViews(rows, live) enrichDownloadTorrentViews(c.Request.Context(), svc, torrentViews) c.JSON(http.StatusOK, gin.H{ "tasks": taskViews, "torrents": torrentViews, }) } } func visibleDownloadRows(c *gin.Context, rows []model.DownloadTask) []model.DownloadTask { if isAdminRequest(c) { return rows } uid, _ := c.Get(middleware.CtxUserID) userID, _ := uid.(string) filtered := make([]model.DownloadTask, 0, len(rows)) for _, row := range rows { if row.UserID == userID { filtered = append(filtered, row) } } return filtered } func visibleLiveTorrents(rows []model.DownloadTask, live []service.QBitTorrent) []service.QBitTorrent { if len(rows) == 0 || len(live) == 0 { return nil } filtered := make([]service.QBitTorrent, 0, len(live)) for _, torrent := range live { torrentTitle := normalizeTitle(torrent.Name) if torrentTitle == "" { continue } for _, row := range rows { rowTitle := normalizeTitle(row.Title) if rowTitle == "" { continue } if strings.Contains(torrentTitle, rowTitle) || strings.Contains(rowTitle, torrentTitle) { filtered = append(filtered, torrent) break } } } return filtered } func isAdminRequest(c *gin.Context) bool { role, _ := c.Get(middleware.CtxUserRole) return role == "admin" } func normalizeTitle(title string) string { title = strings.ToLower(title) var b strings.Builder for _, r := range title { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r > 127 { b.WriteRune(r) } } return b.String() } func redactDownloadURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if strings.HasPrefix(strings.ToLower(raw), "magnet:") { return "magnet:?xt=***" } u, err := url.Parse(raw) if err != nil || u.Host == "" { return "[redacted-download-url]" } u.RawQuery = "" u.Fragment = "" base := u.String() if base == "" { return u.Scheme + "://" + u.Host } return base } func deleteDownloadHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { hash := c.Param("hash") withFiles := c.Query("delete_files") == "true" if err := svc.Downloads.Delete(c.Request.Context(), hash, withFiles); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } } type relocateDownloadReq struct { Hash string `json:"hash" binding:"required"` Location string `json:"location" binding:"required"` } // relocateDownloadHandler moves a torrent's data to a new directory while // keeping it seeding (qBittorrent setLocation). func relocateDownloadHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req relocateDownloadReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := svc.Downloads.RelocateTorrent(c.Request.Context(), req.Hash, req.Location); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"hash": strings.TrimSpace(req.Hash), "location": strings.TrimSpace(req.Location)}) } } func reloadDownloadConfigHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.Status(http.StatusNoContent) } }