From c1b5dac7840bb5240ad3c43ef306f70950763551 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Thu, 28 May 2026 23:27:34 +0800 Subject: [PATCH] feat: improve subscription and download cards --- internal/handler/downloads.go | 124 +++++++++- internal/handler/metadata_enrich.go | 307 +++++++++++++++++++++++ internal/handler/subscription_extra.go | 20 ++ internal/handler/subscriptions.go | 12 + internal/model/model.go | 25 +- internal/service/downloads.go | 253 ++++++++++++++++++- internal/service/downloads_test.go | 43 ++++ internal/service/qbittorrent.go | 19 +- internal/service/subscription.go | 20 +- internal/service/subscription_test.go | 15 +- internal/service/telegram_bot.go | 11 +- web/src/api/downloads.ts | 13 +- web/src/api/subscriptions.ts | 5 + web/src/pages/AIAssistantPage.tsx | 4 + web/src/pages/DiscoverPage.tsx | 12 +- web/src/pages/DownloadsPage.tsx | 330 ++++++++++++++++++------- web/src/pages/SearchPage.tsx | 4 + web/src/pages/SiteSearchPage.tsx | 2 +- web/src/pages/SubscriptionsPage.tsx | 201 ++++++++++----- web/src/types/index.ts | 23 +- 20 files changed, 1239 insertions(+), 204 deletions(-) create mode 100644 internal/handler/metadata_enrich.go create mode 100644 internal/service/downloads_test.go diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go index cca8043..a391120 100644 --- a/internal/handler/downloads.go +++ b/internal/handler/downloads.go @@ -4,17 +4,24 @@ package handler import ( "context" "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"` + 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"` } // resolvePTDownloadURL 把站点搜索结果里的"详情/获取签名"URL 解析成 qb 能直接 @@ -22,10 +29,10 @@ type addDownloadReq struct { // // 链路: // -// 1. 拿 URL 的 host,到 sites 表里找 base_url 同源的站点。 -// 2. 如果站点的 type 是已知 PT 框架(mteam/nexusphp/unit3d/...), -// 就用对应适配器的 GetDownloadURL,传入从 URL 里 parse 出来的 id。 -// 3. 任一步失败都直接返回原 URL,让 qb 自己去拉(保持向后兼容)。 +// 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 自己 @@ -39,8 +46,8 @@ func resolvePTDownloadURL(ctx context.Context, svc *service.Container, raw strin return raw } log.Info("resolved PT download URL", - zap.String("from", raw), - zap.String("to", resolved)) + zap.String("from", redactDownloadURL(raw)), + zap.String("to", redactDownloadURL(resolved))) return resolved } @@ -54,12 +61,22 @@ func addDownloadHandler(svc *service.Container) gin.HandlerFunc { uid, _ := c.Get(middleware.CtxUserID) // 把站点搜索 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) + 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, + }, fallbackTitle, "") + t, err := svc.Downloads.AddDownloadWithMeta(c.Request.Context(), uid.(string), realURL, req.SavePath, meta) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", realURL, c.ClientIP(), "") + svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", redactDownloadURL(realURL), c.ClientIP(), "") c.JSON(http.StatusOK, t) } } @@ -71,13 +88,96 @@ func listDownloadsHandler(svc *service.Container) gin.HandlerFunc { 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": rows, - "torrents": live, + "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") diff --git a/internal/handler/metadata_enrich.go b/internal/handler/metadata_enrich.go new file mode 100644 index 0000000..1f5ae84 --- /dev/null +++ b/internal/handler/metadata_enrich.go @@ -0,0 +1,307 @@ +package handler + +import ( + "context" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +var metadataNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|season\s*\d+|s\d{1,2}|s\d{1,2}e\d{1,3}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`) +var metadataYearRE = regexp.MustCompile(`(?:19|20)\d{2}`) + +var displayMetadataCache sync.Map + +type cachedDisplayMetadata struct { + value displayMetadata + expiresAt time.Time +} + +func enrichSubscriptionArtwork(ctx context.Context, svc *service.Container, sub *model.Subscription) { + if svc == nil || sub == nil || (strings.TrimSpace(sub.PosterURL) != "" && strings.TrimSpace(sub.BackdropURL) != "") { + return + } + meta := lookupDisplayMetadata(ctx, svc, sub.Name, sub.Filter, sub.MediaType) + if meta.Title == "" && meta.PosterURL == "" && meta.BackdropURL == "" && meta.Overview == "" { + return + } + if strings.TrimSpace(sub.Source) == "" { + sub.Source = meta.Source + } + if strings.TrimSpace(sub.PosterURL) == "" { + sub.PosterURL = meta.PosterURL + } + if strings.TrimSpace(sub.BackdropURL) == "" { + sub.BackdropURL = meta.BackdropURL + } + if strings.TrimSpace(sub.Overview) == "" { + sub.Overview = meta.Overview + } +} + +func enrichAndPersistSubscriptions(ctx context.Context, svc *service.Container, items []model.Subscription) { + for i := range items { + before := items[i] + enrichSubscriptionArtwork(ctx, svc, &items[i]) + if before.Source == items[i].Source && + before.PosterURL == items[i].PosterURL && + before.BackdropURL == items[i].BackdropURL && + before.Overview == items[i].Overview { + continue + } + updates := map[string]any{} + if before.Source != items[i].Source { + updates["source"] = items[i].Source + } + if before.PosterURL != items[i].PosterURL { + updates["poster_url"] = items[i].PosterURL + } + if before.BackdropURL != items[i].BackdropURL { + updates["backdrop_url"] = items[i].BackdropURL + } + if before.Overview != items[i].Overview { + updates["overview"] = items[i].Overview + } + if len(updates) == 0 { + continue + } + if err := svc.Repo.DB.WithContext(ctx).Model(&model.Subscription{}).Where("id = ?", items[i].ID).Updates(updates).Error; err != nil { + svc.Log.Debug("subscription artwork backfill failed", zap.String("id", items[i].ID), zap.Error(err)) + } + } +} + +func enrichAndPersistDownloadRows(ctx context.Context, svc *service.Container, rows []model.DownloadTask) { + for i := range rows { + before := rows[i] + title := strings.TrimSpace(rows[i].Title) + if title == "" { + title = downloadDisplayTitle(rows[i].URL) + rows[i].Title = title + } + if strings.TrimSpace(rows[i].PosterURL) == "" || strings.TrimSpace(rows[i].BackdropURL) == "" { + meta := enrichDownloadTaskMeta(ctx, svc, service.DownloadTaskMeta{ + Title: rows[i].Title, + PosterURL: rows[i].PosterURL, + BackdropURL: rows[i].BackdropURL, + Overview: rows[i].Overview, + }, firstNonEmptyString(rows[i].Title, rows[i].URL), "") + rows[i].Title = firstNonEmptyString(rows[i].Title, meta.Title) + rows[i].PosterURL = meta.PosterURL + rows[i].BackdropURL = meta.BackdropURL + rows[i].Overview = meta.Overview + } + if before.Title == rows[i].Title && + before.PosterURL == rows[i].PosterURL && + before.BackdropURL == rows[i].BackdropURL && + before.Overview == rows[i].Overview { + continue + } + updates := map[string]any{} + if before.Title != rows[i].Title { + updates["title"] = rows[i].Title + } + if before.PosterURL != rows[i].PosterURL { + updates["poster_url"] = rows[i].PosterURL + } + if before.BackdropURL != rows[i].BackdropURL { + updates["backdrop_url"] = rows[i].BackdropURL + } + if before.Overview != rows[i].Overview { + updates["overview"] = rows[i].Overview + } + if len(updates) == 0 { + continue + } + if err := svc.Repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", rows[i].ID).Updates(updates).Error; err != nil { + svc.Log.Debug("download artwork backfill failed", zap.String("id", rows[i].ID), zap.Error(err)) + } + } +} + +func enrichDownloadTorrentViews(ctx context.Context, svc *service.Container, views []service.DownloadTorrentView) { + cache := map[string]displayMetadata{} + for i := range views { + if strings.TrimSpace(views[i].PosterURL) != "" && strings.TrimSpace(views[i].BackdropURL) != "" { + continue + } + query := firstNonEmptyString(views[i].Title, views[i].Name) + cacheKey, _ := metadataSearchQuery(query) + meta, ok := cache[cacheKey] + if !ok { + meta = lookupDisplayMetadata(ctx, svc, query, "", "") + cache[cacheKey] = meta + } + if strings.TrimSpace(views[i].PosterURL) == "" { + views[i].PosterURL = meta.PosterURL + } + if strings.TrimSpace(views[i].BackdropURL) == "" { + views[i].BackdropURL = meta.BackdropURL + } + if strings.TrimSpace(views[i].Overview) == "" { + views[i].Overview = meta.Overview + } + if (views[i].Title == "" || views[i].Title == views[i].Name) && meta.Title != "" { + views[i].Title = meta.Title + } + } +} + +func enrichDownloadTaskMeta(ctx context.Context, svc *service.Container, meta service.DownloadTaskMeta, fallbackTitle, mediaType string) service.DownloadTaskMeta { + if strings.TrimSpace(meta.Title) == "" { + meta.Title = strings.TrimSpace(downloadDisplayTitle(fallbackTitle)) + } + if strings.TrimSpace(meta.PosterURL) != "" && strings.TrimSpace(meta.BackdropURL) != "" { + return meta + } + found := lookupDisplayMetadata(ctx, svc, meta.Title, fallbackTitle, mediaType) + if strings.TrimSpace(meta.Title) == "" { + meta.Title = found.Title + } + if strings.TrimSpace(meta.PosterURL) == "" { + meta.PosterURL = found.PosterURL + } + if strings.TrimSpace(meta.BackdropURL) == "" { + meta.BackdropURL = found.BackdropURL + } + if strings.TrimSpace(meta.Overview) == "" { + meta.Overview = found.Overview + } + return meta +} + +func downloadDisplayTitle(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if u, err := url.Parse(raw); err == nil { + if dn := strings.TrimSpace(u.Query().Get("dn")); dn != "" { + if decoded, err := url.QueryUnescape(dn); err == nil && strings.TrimSpace(decoded) != "" { + return strings.TrimSpace(decoded) + } + return dn + } + if u.Host != "" { + base := path.Base(u.Path) + if base != "." && base != "/" && base != "" { + base = strings.TrimSuffix(base, path.Ext(base)) + return strings.TrimSpace(base) + } + } + } + return raw +} + +type displayMetadata struct { + Source string + Title string + PosterURL string + BackdropURL string + Overview string +} + +func lookupDisplayMetadata(ctx context.Context, svc *service.Container, title, fallback, mediaType string) displayMetadata { + if svc == nil { + return displayMetadata{} + } + query, year := metadataSearchQuery(title, fallback) + if query == "" { + return displayMetadata{} + } + searchType := normalizeMetadataMediaType(mediaType) + cacheKey := searchType + ":" + strconv.Itoa(year) + ":" + strings.ToLower(query) + if cached, ok := displayMetadataCache.Load(cacheKey); ok { + entry := cached.(cachedDisplayMetadata) + if time.Now().Before(entry.expiresAt) { + return entry.value + } + displayMetadataCache.Delete(cacheKey) + } + searchCtx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + results := service.SearchExternalMedia(searchCtx, query, year, searchType, svc.TMDb, svc.Douban, svc.Bangumi) + if len(results) == 0 { + displayMetadataCache.Store(cacheKey, cachedDisplayMetadata{expiresAt: time.Now().Add(30 * time.Minute)}) + return displayMetadata{} + } + best := results[0] + for _, item := range results { + if searchType != "" && item.MediaType != "" && item.MediaType != searchType && !(searchType == "tv" && item.MediaType == "anime") { + continue + } + if strings.TrimSpace(item.PosterURL) != "" { + best = item + break + } + } + meta := displayMetadata{ + Source: best.Source, + Title: best.Title, + PosterURL: best.PosterURL, + BackdropURL: best.BackdropURL, + Overview: best.Overview, + } + displayMetadataCache.Store(cacheKey, cachedDisplayMetadata{value: meta, expiresAt: time.Now().Add(12 * time.Hour)}) + return meta +} + +func metadataSearchQuery(values ...string) (string, int) { + for _, value := range values { + query := strings.TrimSpace(value) + if query == "" || strings.Contains(query, "://") { + continue + } + year := 0 + if yearLoc := metadataYearRE.FindStringIndex(query); len(yearLoc) == 2 { + yearText := query[yearLoc[0]:yearLoc[1]] + year, _ = strconv.Atoi(yearText) + query = query[:yearLoc[1]] + } + query = stripBracketNoise(query) + query = metadataNoiseRE.ReplaceAllString(query, " ") + query = strings.NewReplacer("_", " ", ".", " ", "-", " ", "|", " ", "/", " ").Replace(query) + query = strings.Join(strings.Fields(query), " ") + query = strings.TrimSpace(query) + if query != "" { + return query, year + } + } + return "", 0 +} + +func stripBracketNoise(value string) string { + replacer := strings.NewReplacer("[", " ", "]", " ", "【", " ", "】", " ", "(", " ", ")", " ") + return replacer.Replace(value) +} + +func normalizeMetadataMediaType(mediaType string) string { + switch strings.ToLower(strings.TrimSpace(mediaType)) { + case "movie": + return "movie" + case "anime": + return "anime" + case "tv", "series", "show", "variety", "综艺": + return "tv" + default: + return "" + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go index 8cb98a5..d19afb7 100644 --- a/internal/handler/subscription_extra.go +++ b/internal/handler/subscription_extra.go @@ -19,11 +19,16 @@ type subscriptionPatchReq struct { SavePath *string `json:"save_path"` SearchMode *string `json:"search_mode"` IMDBID *string `json:"imdb_id"` + Source *string `json:"source"` + PosterURL *string `json:"poster_url"` + BackdropURL *string `json:"backdrop_url"` + Overview *string `json:"overview"` Resolution *string `json:"resolution"` Quality *string `json:"quality"` Effects *string `json:"effects"` ReleaseGroups *string `json:"release_groups"` ExcludeWords *string `json:"exclude_words"` + WashEnabled *bool `json:"wash_enabled"` WashPriority *string `json:"wash_priority"` Priority *int `json:"priority"` Enabled *bool `json:"enabled"` @@ -79,6 +84,18 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { if patch.IMDBID != nil { updates["imdb_id"] = *patch.IMDBID } + if patch.Source != nil { + updates["source"] = *patch.Source + } + if patch.PosterURL != nil { + updates["poster_url"] = *patch.PosterURL + } + if patch.BackdropURL != nil { + updates["backdrop_url"] = *patch.BackdropURL + } + if patch.Overview != nil { + updates["overview"] = *patch.Overview + } if patch.Resolution != nil { updates["resolution"] = *patch.Resolution } @@ -94,6 +111,9 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { if patch.ExcludeWords != nil { updates["exclude_words"] = *patch.ExcludeWords } + if patch.WashEnabled != nil { + updates["wash_enabled"] = *patch.WashEnabled + } if patch.WashPriority != nil { updates["wash_priority"] = *patch.WashPriority } diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index 13fb70e..e1fd539 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -20,11 +20,16 @@ type subscriptionReq struct { SavePath string `json:"save_path"` SearchMode string `json:"search_mode"` IMDBID string `json:"imdb_id"` + Source string `json:"source"` + PosterURL string `json:"poster_url"` + BackdropURL string `json:"backdrop_url"` + Overview string `json:"overview"` Resolution string `json:"resolution"` Quality string `json:"quality"` Effects string `json:"effects"` ReleaseGroups string `json:"release_groups"` ExcludeWords string `json:"exclude_words"` + WashEnabled bool `json:"wash_enabled"` WashPriority string `json:"wash_priority"` Priority int `json:"priority"` Enabled *bool `json:"enabled"` @@ -52,15 +57,21 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { SavePath: req.SavePath, SearchMode: req.SearchMode, IMDBID: req.IMDBID, + Source: req.Source, + PosterURL: req.PosterURL, + BackdropURL: req.BackdropURL, + Overview: req.Overview, Resolution: req.Resolution, Quality: req.Quality, Effects: req.Effects, ReleaseGroups: req.ReleaseGroups, ExcludeWords: req.ExcludeWords, + WashEnabled: req.WashEnabled, WashPriority: req.WashPriority, Priority: req.Priority, Enabled: enabled, } + enrichSubscriptionArtwork(c.Request.Context(), svc, s) if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -76,6 +87,7 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + enrichAndPersistSubscriptions(c.Request.Context(), svc, items) c.JSON(http.StatusOK, gin.H{"items": items}) } } diff --git a/internal/model/model.go b/internal/model/model.go index cdef454..773549a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -171,12 +171,16 @@ type PlaylistItem struct { // DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。 type DownloadTask struct { Base - UserID string `gorm:"index;size:36" json:"user_id"` - Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http - URL string `gorm:"size:2048;not null" json:"url"` - SavePath string `gorm:"size:1024" json:"save_path"` - Status string `gorm:"size:32;default:queued" json:"status"` - Progress float32 `json:"progress"` + UserID string `gorm:"index;size:36" json:"user_id"` + Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http + URL string `gorm:"size:2048;not null" json:"-"` + Title string `gorm:"size:512" json:"title,omitempty"` + PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` + SavePath string `gorm:"size:1024" json:"save_path"` + Status string `gorm:"size:32;default:queued" json:"status"` + Progress float32 `json:"progress"` } // Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。 @@ -191,13 +195,18 @@ type Subscription struct { SavePath string `gorm:"size:1024" json:"save_path,omitempty"` SearchMode string `gorm:"size:16;default:keyword" json:"search_mode,omitempty"` // keyword / imdb IMDBID string `gorm:"size:32" json:"imdb_id,omitempty"` + Source string `gorm:"size:32" json:"source,omitempty"` + PosterURL string `gorm:"size:2048" json:"poster_url,omitempty"` + BackdropURL string `gorm:"size:2048" json:"backdrop_url,omitempty"` + Overview string `gorm:"type:text" json:"overview,omitempty"` Resolution string `gorm:"size:32" json:"resolution,omitempty"` // 2160p / 1080p / 720p / best Quality string `gorm:"size:64" json:"quality,omitempty"` // remux / bluray / web-dl / hdtv Effects string `gorm:"size:128" json:"effects,omitempty"` // hdr,dolby-vision,atmos ReleaseGroups string `gorm:"size:255" json:"release_groups,omitempty"` // comma separated ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated - WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders - Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later + WashEnabled bool `gorm:"default:false" json:"wash_enabled"` + WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders + Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later Enabled bool `gorm:"default:true" json:"enabled"` LastRunAt *time.Time `json:"last_run_at,omitempty"` } diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 052c0d2..8d2ec61 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -18,6 +18,9 @@ package service import ( "context" "errors" + "math" + "net/url" + "path" "regexp" "strings" "sync" @@ -47,6 +50,55 @@ type DownloadService struct { var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`) +// DownloadTaskMeta carries public display metadata for a download. It is +// deliberately separate from the private torrent URL so API responses never +// need to expose tracker tokens. +type DownloadTaskMeta struct { + Title string + PosterURL string + BackdropURL string + Overview string +} + +type DownloadTaskView struct { + ID string `json:"id"` + Source string `json:"source"` + Title string `json:"title"` + PosterURL string `json:"poster_url,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + Overview string `json:"overview,omitempty"` + SavePath string `json:"save_path"` + Status string `json:"status"` + Progress float32 `json:"progress"` + State string `json:"state,omitempty"` + DLSpeed int64 `json:"dlspeed,omitempty"` + UpSpeed int64 `json:"upspeed,omitempty"` + Size int64 `json:"size,omitempty"` + Downloaded int64 `json:"downloaded,omitempty"` + NumSeeds int `json:"num_seeds,omitempty"` + NumLeechs int `json:"num_leechs,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DownloadTorrentView struct { + Hash string `json:"hash"` + Name string `json:"name"` + Title string `json:"title"` + PosterURL string `json:"poster_url,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + Overview string `json:"overview,omitempty"` + State string `json:"state"` + Progress float32 `json:"progress"` + DLSpeed int64 `json:"dlspeed"` + UpSpeed int64 `json:"upspeed"` + NumSeeds int `json:"num_seeds"` + NumLeechs int `json:"num_leechs"` + Size int64 `json:"size"` + Downloaded int64 `json:"downloaded"` + SavePath string `json:"save_path"` +} + // NewDownloadService is the constructor. func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService, site ...*SiteService) *DownloadService { var siteSvc *SiteService @@ -119,6 +171,10 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error { // AddDownload accepts a magnet URL / HTTP URL and persists a tracking row. func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) { + return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{}) +} + +func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { if urlStr == "" { return nil, errors.New("empty url") } @@ -131,7 +187,10 @@ func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, saveP if err := d.qb.AddTorrentFile(ctx, data, name, savePath); err != nil { return nil, err } - return d.createTask(ctx, userID, urlStr, savePath) + if strings.TrimSpace(meta.Title) == "" { + meta.Title = strings.TrimSuffix(name, path.Ext(name)) + } + return d.createTask(ctx, userID, urlStr, savePath, meta) } else { siteFetchErr = err } @@ -142,16 +201,24 @@ func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, saveP } return nil, err } - return d.createTask(ctx, userID, urlStr, savePath) + return d.createTask(ctx, userID, urlStr, savePath, meta) } -func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) { +func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { + title := strings.TrimSpace(meta.Title) + if title == "" { + title = publicDownloadTitle(urlStr) + } t := &model.DownloadTask{ - UserID: userID, - Source: "qbittorrent", - URL: urlStr, - SavePath: savePath, - Status: "queued", + UserID: userID, + Source: "qbittorrent", + URL: urlStr, + Title: title, + PosterURL: meta.PosterURL, + BackdropURL: meta.BackdropURL, + Overview: meta.Overview, + SavePath: savePath, + Status: "queued", } if err := d.repo.Download.Create(ctx, t); err != nil { return nil, err @@ -159,6 +226,35 @@ func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePa return t, nil } +func publicDownloadTitle(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "下载任务" + } + if u, err := url.Parse(raw); err == nil { + if dn := strings.TrimSpace(u.Query().Get("dn")); dn != "" { + if decoded, err := url.QueryUnescape(dn); err == nil && strings.TrimSpace(decoded) != "" { + return strings.TrimSpace(decoded) + } + return dn + } + if u.Host != "" { + base := path.Base(u.Path) + if base != "." && base != "/" && base != "" { + base = strings.TrimSuffix(base, path.Ext(base)) + if base != "" { + return base + } + } + return u.Host + } + } + if strings.HasPrefix(strings.ToLower(raw), "magnet:") { + return "磁力下载" + } + return "下载任务" +} + func (d *DownloadService) TorrentExistsByName(ctx context.Context, name string) bool { query := normalizeTorrentName(name) if query == "" { @@ -208,6 +304,147 @@ func (d *DownloadService) List(ctx context.Context) ([]model.DownloadTask, []QBi return rows, live, nil } +func DownloadViews(rows []model.DownloadTask, live []QBitTorrent) ([]DownloadTaskView, []DownloadTorrentView) { + liveByKey := map[string]QBitTorrent{} + for _, torrent := range live { + key := normalizeTorrentName(torrent.Name) + if key != "" { + liveByKey[key] = torrent + } + } + taskByKey := map[string]model.DownloadTask{} + for _, row := range rows { + key := normalizeTorrentName(row.Title) + if key != "" { + taskByKey[key] = row + } + } + + taskViews := make([]DownloadTaskView, 0, len(rows)) + for _, row := range rows { + view := downloadTaskView(row, QBitTorrent{}) + if torrent, ok := findMatchingTorrent(row.Title, liveByKey); ok { + view = downloadTaskView(row, torrent) + } + taskViews = append(taskViews, view) + } + + torrentViews := make([]DownloadTorrentView, 0, len(live)) + for _, torrent := range live { + var row model.DownloadTask + if matched, ok := findMatchingTask(torrent.Name, taskByKey); ok { + row = matched + } + torrentViews = append(torrentViews, downloadTorrentView(torrent, row)) + } + return taskViews, torrentViews +} + +func downloadTaskView(row model.DownloadTask, torrent QBitTorrent) DownloadTaskView { + progress := row.Progress + state := row.Status + if torrent.Name != "" { + progress = torrent.Progress + state = torrent.State + } + size := torrent.Size + return DownloadTaskView{ + ID: row.ID, + Source: row.Source, + Title: firstNonEmpty(row.Title, "下载任务"), + PosterURL: row.PosterURL, + BackdropURL: row.BackdropURL, + Overview: row.Overview, + SavePath: row.SavePath, + Status: row.Status, + Progress: progress, + State: state, + DLSpeed: torrent.DLSpeed, + UpSpeed: torrent.UpSpeed, + Size: size, + Downloaded: downloadedBytes(size, progress), + NumSeeds: torrent.NumSeeds, + NumLeechs: torrent.NumLeech, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func downloadTorrentView(torrent QBitTorrent, row model.DownloadTask) DownloadTorrentView { + title := torrent.Name + if row.Title != "" { + title = row.Title + } + return DownloadTorrentView{ + Hash: torrent.Hash, + Name: torrent.Name, + Title: firstNonEmpty(title, "下载任务"), + PosterURL: row.PosterURL, + BackdropURL: row.BackdropURL, + Overview: row.Overview, + State: torrent.State, + Progress: torrent.Progress, + DLSpeed: torrent.DLSpeed, + UpSpeed: torrent.UpSpeed, + NumSeeds: torrent.NumSeeds, + NumLeechs: torrent.NumLeech, + Size: torrent.Size, + Downloaded: downloadedBytes(torrent.Size, torrent.Progress), + SavePath: torrent.SavePath, + } +} + +func findMatchingTorrent(title string, liveByKey map[string]QBitTorrent) (QBitTorrent, bool) { + key := normalizeTorrentName(title) + if key == "" { + return QBitTorrent{}, false + } + if torrent, ok := liveByKey[key]; ok { + return torrent, true + } + for currentKey, torrent := range liveByKey { + if strings.Contains(currentKey, key) || strings.Contains(key, currentKey) { + return torrent, true + } + } + return QBitTorrent{}, false +} + +func findMatchingTask(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) { + key := normalizeTorrentName(title) + if key == "" { + return model.DownloadTask{}, false + } + if row, ok := taskByKey[key]; ok { + return row, true + } + for currentKey, row := range taskByKey { + if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) { + return row, true + } + } + return model.DownloadTask{}, false +} + +func downloadedBytes(size int64, progress float32) int64 { + if size <= 0 || progress <= 0 { + return 0 + } + if progress > 1 { + progress = 1 + } + return int64(math.Round(float64(size) * float64(progress))) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + // Delete removes a torrent (and optionally its files) from qBittorrent. func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles bool) error { return d.qb.Delete(ctx, hash, withFiles) diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go new file mode 100644 index 0000000..60c4a97 --- /dev/null +++ b/internal/service/downloads_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestDownloadViewsDoNotExposePrivateURL(t *testing.T) { + rows := []model.DownloadTask{{ + UserID: "u1", + Source: "qbittorrent", + URL: "https://tracker.example/download?id=1&passkey=private-token", + Title: "测试影片", + SavePath: "/downloads", + Status: "queued", + }} + + tasks, torrents := DownloadViews(rows, nil) + data, err := json.Marshal(map[string]any{ + "tasks": tasks, + "torrents": torrents, + }) + if err != nil { + t.Fatal(err) + } + body := string(data) + if strings.Contains(body, "private-token") || strings.Contains(body, "passkey") || strings.Contains(body, "tracker.example") { + t.Fatalf("download views leaked private URL: %s", body) + } + if !strings.Contains(body, "测试影片") { + t.Fatalf("download views should keep public title: %s", body) + } +} + +func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) { + got := publicDownloadTitle("magnet:?xt=urn:btih:abc&dn=%E6%B5%8B%E8%AF%95%E5%BD%B1%E7%89%87") + if got != "测试影片" { + t.Fatalf("publicDownloadTitle = %q, want %q", got, "测试影片") + } +} diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go index 766237f..0903e49 100644 --- a/internal/service/qbittorrent.go +++ b/internal/service/qbittorrent.go @@ -247,13 +247,30 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t } } q.log.Info("qbittorrent: torrent added", - zap.String("url", magnetOrURL), + zap.String("url", redactTorrentURL(magnetOrURL)), zap.String("save_path", savePath), zap.Bool("file_upload", useFileUpload), zap.String("body", bodyText)) return nil } +func redactTorrentURL(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 = "" + return u.String() +} + func (q *QBitClient) fetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Host == "" { diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 4b0c32f..adb3a76 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -223,7 +223,12 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio } mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "") savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) - if _, err := s.downloads.AddDownload(ctx, sub.UserID, download, savePath); err != nil { + if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{ + Title: firstNonEmpty(item.Title, sub.Name), + PosterURL: sub.PosterURL, + BackdropURL: sub.BackdropURL, + Overview: sub.Overview, + }); err != nil { s.log.Warn("subscription enqueue failed", zap.String("title", item.Title), zap.String("media_type", mediaType), @@ -294,7 +299,12 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs } realURL := s.site.ResolveDownloadURL(ctx, candidate.Download) savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) - if _, err := s.downloads.AddDownload(ctx, sub.UserID, realURL, savePath); err != nil { + if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{ + Title: firstNonEmpty(item.Title, sub.Name), + PosterURL: sub.PosterURL, + BackdropURL: sub.BackdropURL, + Overview: sub.Overview, + }); err != nil { lastEnqueueErr = err s.log.Warn("site-search subscription enqueue failed", zap.String("subscription", sub.Name), @@ -444,6 +454,12 @@ func matchesSubscriptionRules(sub *model.Subscription, title string) bool { func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int { title := strings.ToLower(item.Title) score := item.Seeders + if sub == nil || !sub.WashEnabled { + if item.Free { + score += 25 + } + return score + } resolutionScore := detectResolutionScore(title) qualityScore := detectQualityScore(title) effectScore := detectEffectScore(title) diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index ba03346..8e5105d 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -24,7 +24,7 @@ func TestSelectSiteSearchCandidatesPrefersSeriesPack(t *testing.T) { } func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T) { - sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", WashPriority: "resolution"} + sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", WashEnabled: true, WashPriority: "resolution"} results := []SearchResult{ {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90}, {Title: "葬送的芙莉莲 S01E01 2160p", DownloadURL: "https://pt/download/1b", Seeders: 80}, @@ -57,6 +57,19 @@ func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) { } } +func TestSelectSiteSearchCandidatesDoesNotWashByDefault(t *testing.T) { + sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "resolution"} + results := []SearchResult{ + {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90}, + {Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80}, + } + + got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}) + if len(got) != 1 || got[0].Download != "https://pt/download/1080" { + t.Fatalf("selected %#v, want seeders best when wash disabled", got) + } +} + func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) { sub := &model.Subscription{ Name: "Dune 自动订阅", diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index e01ae01..10898e4 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -229,12 +229,12 @@ func (s *TelegramBotService) cmdSearch(ctx context.Context, args []string) (stri // cmdDownloads 处理 /downloads 命令。 func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) { type Row struct { - URL string + Title string Status string } var rows []Row if err := s.repo.DB.Raw( - "SELECT url, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8", + "SELECT COALESCE(NULLIF(title,''),'下载任务') as title, COALESCE(status,'unknown') as status FROM download_tasks ORDER BY created_at DESC LIMIT 8", ).Scan(&rows).Error; err != nil { return "", err } @@ -255,10 +255,9 @@ func (s *TelegramBotService) cmdDownloads(ctx context.Context) (string, error) { case "error": icon = "❌" } - // 截取文件名 - name := r.URL - if idx := strings.LastIndex(name, "/"); idx >= 0 { - name = name[idx+1:] + name := strings.TrimSpace(r.Title) + if name == "" { + name = "下载任务" } if len(name) > 60 { name = name[:57] + "..." diff --git a/web/src/api/downloads.ts b/web/src/api/downloads.ts index c6e8566..19c38e0 100644 --- a/web/src/api/downloads.ts +++ b/web/src/api/downloads.ts @@ -6,12 +6,21 @@ export interface DownloadsState { torrents: QBitTorrent[] | null } +export interface AddDownloadInput { + url: string + save_path?: string + title?: string + poster_url?: string + backdrop_url?: string + overview?: string +} + export const downloadsAPI = { list: () => api.get('/downloads').then((r) => r.data), - add: (url: string, savePath = '') => + add: (url: string, savePath = '', meta: Omit = {}) => api - .post('/downloads', { url, save_path: savePath }) + .post('/downloads', { url, save_path: savePath, ...meta }) .then((r) => r.data), remove: (hash: string, deleteFiles = false) => diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts index f42ac81..06c1017 100644 --- a/web/src/api/subscriptions.ts +++ b/web/src/api/subscriptions.ts @@ -14,11 +14,16 @@ export const subscriptionsAPI = { save_path?: string search_mode?: string imdb_id?: string + source?: string + poster_url?: string + backdrop_url?: string + overview?: string resolution?: string quality?: string effects?: string release_groups?: string exclude_words?: string + wash_enabled?: boolean wash_priority?: string priority?: number enabled?: boolean diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx index 2c9ca9f..4395009 100644 --- a/web/src/pages/AIAssistantPage.tsx +++ b/web/src/pages/AIAssistantPage.tsx @@ -232,6 +232,10 @@ export function AIAssistantPage() { feed_url: feed, filter: keyword, media_type: item.media_type, + source: item.source, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url, + overview: item.overview, enabled: true, }) const run = await subscriptionsAPI.runNow(sub.id) diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx index dff2b13..b95cb83 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -247,6 +247,7 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: ( effects: '', release_groups: '', exclude_words: 'cam,ts,tc,枪版', + wash_enabled: false, wash_priority: 'balanced', save_path: '', media_category: '', @@ -269,11 +270,16 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: ( save_path: form.save_path || undefined, search_mode: form.search_mode, imdb_id: form.imdb_id || undefined, + source, + poster_url: item.poster_url || undefined, + backdrop_url: item.backdrop_url || undefined, + overview: item.overview || undefined, resolution: form.resolution === 'best' ? 'best' : form.resolution, quality: form.quality || undefined, effects: form.effects || undefined, release_groups: form.release_groups || undefined, exclude_words: form.exclude_words || undefined, + wash_enabled: form.wash_enabled, wash_priority: form.wash_priority, priority: form.priority, enabled: true, @@ -389,7 +395,7 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: ( + +