mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
feat: improve subscription and download cards
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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, "测试影片")
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 自动订阅",
|
||||
|
||||
@@ -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] + "..."
|
||||
|
||||
Reference in New Issue
Block a user