feat: improve subscription and download cards

This commit is contained in:
ShukeBta
2026-05-28 23:27:34 +08:00
parent f457d0050e
commit c1b5dac784
20 changed files with 1239 additions and 204 deletions
+245 -8
View File
@@ -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)
+43
View File
@@ -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, "测试影片")
}
}
+18 -1
View File
@@ -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 == "" {
+18 -2
View File
@@ -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)
+14 -1
View File
@@ -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 自动订阅",
+5 -6
View File
@@ -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] + "..."