mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
feat: improve subscription and download cards
This commit is contained in:
+112
-12
@@ -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")
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
+17
-8
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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] + "..."
|
||||
|
||||
@@ -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<DownloadsState>('/downloads').then((r) => r.data),
|
||||
|
||||
add: (url: string, savePath = '') =>
|
||||
add: (url: string, savePath = '', meta: Omit<AddDownloadInput, 'url' | 'save_path'> = {}) =>
|
||||
api
|
||||
.post<DownloadTask>('/downloads', { url, save_path: savePath })
|
||||
.post<DownloadTask>('/downloads', { url, save_path: savePath, ...meta })
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (hash: string, deleteFiles = false) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: (
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
洗版优先级
|
||||
<select className="input-base mt-1" value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
|
||||
<select className="input-base mt-1 disabled:opacity-50" disabled={!form.wash_enabled} value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
|
||||
<option value="balanced">均衡</option>
|
||||
<option value="resolution">分辨率优先</option>
|
||||
<option value="quality">片源质量优先</option>
|
||||
@@ -397,6 +403,10 @@ function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: (
|
||||
<option value="seeders">做种数优先</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={form.wash_enabled} onChange={(e) => setForm((f) => ({ ...f, wash_enabled: e.target.checked }))} />
|
||||
启用洗版择优
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
发布组
|
||||
<input className="input-base mt-1" placeholder="如 FRDS,OurTV" value={form.release_groups} onChange={(e) => setForm((f) => ({ ...f, release_groups: e.target.value }))} />
|
||||
|
||||
+236
-94
@@ -1,12 +1,33 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Download, Trash2 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, Download, Film, HardDrive, Rss, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { downloadsAPI } from '../api/downloads'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { DownloadTask, QBitTorrent } from '../types'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
type DownloadCardItem = {
|
||||
id?: string
|
||||
hash?: string
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
save_path?: string
|
||||
status?: string
|
||||
state?: string
|
||||
progress: number
|
||||
dlspeed?: number
|
||||
upspeed?: number
|
||||
num_seeds?: number
|
||||
num_leechs?: number
|
||||
size?: number
|
||||
downloaded?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function fmtBytes(n?: number): string {
|
||||
if (!n || n <= 0) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
@@ -15,14 +36,178 @@ function fmtBytes(n: number): string {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtSpeed(n: number): string {
|
||||
function fmtSpeed(n?: number): string {
|
||||
return `${fmtBytes(n)}/s`
|
||||
}
|
||||
|
||||
// DownloadsPage: shows running torrents (live) + persisted task rows.
|
||||
function pct(progress?: number): number {
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10))
|
||||
}
|
||||
|
||||
function stateLabel(item: DownloadCardItem): string {
|
||||
const state = (item.state || item.status || 'queued').toLowerCase()
|
||||
if (state.includes('down') || state.includes('meta')) return '下载中'
|
||||
if (state.includes('up') || state.includes('seed')) return '做种中'
|
||||
if (state.includes('pause')) return '已暂停'
|
||||
if (state.includes('error')) return '出错'
|
||||
if (state.includes('complete') || pct(item.progress) >= 100) return '已完成'
|
||||
if (state.includes('queue')) return '排队中'
|
||||
return item.state || item.status || '等待中'
|
||||
}
|
||||
|
||||
function statusTone(item: DownloadCardItem): string {
|
||||
const state = stateLabel(item)
|
||||
if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600'
|
||||
if (state === '出错') return 'bg-red-50 text-red-500'
|
||||
if (state === '已暂停') return 'bg-amber-50 text-amber-600'
|
||||
return 'bg-primary-400/10 text-brand-500'
|
||||
}
|
||||
|
||||
function toLiveCard(t: QBitTorrent): DownloadCardItem {
|
||||
return {
|
||||
hash: t.hash,
|
||||
title: t.title || t.name || '下载任务',
|
||||
poster_url: t.poster_url,
|
||||
backdrop_url: t.backdrop_url,
|
||||
overview: t.overview,
|
||||
save_path: t.save_path,
|
||||
state: t.state,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
num_seeds: t.num_seeds,
|
||||
num_leechs: t.num_leechs,
|
||||
size: t.size,
|
||||
downloaded: t.downloaded,
|
||||
}
|
||||
}
|
||||
|
||||
function toTaskCard(t: DownloadTask): DownloadCardItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title || '下载任务',
|
||||
poster_url: t.poster_url,
|
||||
backdrop_url: t.backdrop_url,
|
||||
overview: t.overview,
|
||||
save_path: t.save_path,
|
||||
status: t.status,
|
||||
state: t.state,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
num_seeds: t.num_seeds,
|
||||
num_leechs: t.num_leechs,
|
||||
size: t.size,
|
||||
downloaded: t.downloaded,
|
||||
created_at: t.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function DownloadCard({
|
||||
item,
|
||||
removable,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DownloadCardItem
|
||||
removable?: boolean
|
||||
onRemove?: () => Promise<void>
|
||||
}) {
|
||||
const progress = pct(item.progress)
|
||||
const visual = item.poster_url || item.backdrop_url
|
||||
const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0)
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
|
||||
{visual ? (
|
||||
<img
|
||||
src={imageURL(visual)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={24} />
|
||||
<span className="line-clamp-3">{item.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
|
||||
{stateLabel(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
|
||||
<span>进度 {progress.toFixed(1)}%</span>
|
||||
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<ArrowDown size={13} className="mr-1 inline text-brand-500" />
|
||||
{fmtSpeed(item.dlspeed)}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<ArrowUp size={13} className="mr-1 inline text-brand-500" />
|
||||
{fmtSpeed(item.upspeed)}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<Rss size={13} className="mr-1 inline text-brand-500" />
|
||||
{item.num_seeds ?? 0} / {item.num_leechs ?? 0}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<HardDrive size={13} className="mr-1 inline text-brand-500" />
|
||||
{fmtBytes(item.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
|
||||
<span className="truncate" title={item.save_path || ''}>
|
||||
{item.save_path || '默认下载目录'}
|
||||
</span>
|
||||
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{removable && onRemove && (
|
||||
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownloadsPage() {
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
const [tasks, setTasks] = useState<DownloadTask[]>([])
|
||||
@@ -60,13 +245,19 @@ export function DownloadsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">下载</h1>
|
||||
<header className="space-y-2">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">下载管理</h1>
|
||||
<p className="flex items-center gap-2 text-sm text-ink-50">
|
||||
<ShieldCheck size={16} className="text-brand-500" />
|
||||
页面仅展示安全标题、海报和进度信息,不再暴露种子原始 URL 或私有 Token。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="磁力链接 / .torrent URL"
|
||||
placeholder="磁力链接 / .torrent URL(提交后不会在页面公开显示)"
|
||||
value={url}
|
||||
onChange={(e) => setURL(e.target.value)}
|
||||
/>
|
||||
@@ -81,103 +272,54 @@ export function DownloadsPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="glass-panel">
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">实时状态</h2>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">当前下载</h2>
|
||||
<span className="text-xs text-ink-50">每 5 秒自动刷新</span>
|
||||
</div>
|
||||
{torrents === null && (
|
||||
<p className="text-sand-500">
|
||||
<div className="glass-panel text-sand-500">
|
||||
尚未连接到下载器 — 请到{' '}
|
||||
<a href="/download-clients" className="text-brand-500 hover:underline">
|
||||
下载器
|
||||
</a>{' '}
|
||||
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
|
||||
</p>
|
||||
页面添加并测试连接。
|
||||
</div>
|
||||
)}
|
||||
{torrents && torrents.length === 0 && (
|
||||
<div className="glass-panel text-sand-500">暂无运行中任务。</div>
|
||||
)}
|
||||
{torrents && torrents.length === 0 && <p className="text-sand-500">暂无运行中任务。</p>}
|
||||
{torrents && torrents.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>状态</th>
|
||||
<th>进度</th>
|
||||
<th>速度</th>
|
||||
<th>体积</th>
|
||||
{role === 'admin' && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{torrents.map((t) => (
|
||||
<tr key={t.hash} className="border-t border-gray-200 align-top">
|
||||
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
||||
{t.name}
|
||||
</td>
|
||||
<td className="text-ink-100">{t.state}</td>
|
||||
<td className="text-ink-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-24 overflow-hidden rounded-lg bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${Math.round(t.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{(t.progress * 100).toFixed(1)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-ink-100">
|
||||
↓ {fmtSpeed(t.dlspeed)} / ↑ {fmtSpeed(t.upspeed)}
|
||||
</td>
|
||||
<td className="text-ink-100">{fmtBytes(t.size)}</td>
|
||||
{role === 'admin' && (
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!confirm(`删除「${t.name}」?`)) return
|
||||
await downloadsAPI.remove(t.hash, false)
|
||||
toast.success('已删除任务')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{torrents.map((torrent) => (
|
||||
<DownloadCard
|
||||
key={torrent.hash}
|
||||
item={toLiveCard(torrent)}
|
||||
removable={role === 'admin'}
|
||||
onRemove={async () => {
|
||||
if (!confirm(`删除「${torrent.title || torrent.name}」?`)) return
|
||||
await downloadsAPI.remove(torrent.hash, false)
|
||||
toast.success('已删除任务')
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<section className="glass-panel">
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">历史记录</h2>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">来源</th>
|
||||
<th>URL</th>
|
||||
<th>保存路径</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-100">{t.source}</td>
|
||||
<td className="max-w-md truncate text-ink-100" title={t.url}>
|
||||
{t.url}
|
||||
</td>
|
||||
<td className="text-ink-100">{t.save_path || '—'}</td>
|
||||
<td className="text-sand-500">
|
||||
{new Date(t.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">下载历史</h2>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="glass-panel text-sand-500">暂无历史下载。</div>
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{tasks.map((task) => (
|
||||
<DownloadCard key={task.id} item={toTaskCard(task)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,6 +186,10 @@ export function SearchPage() {
|
||||
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)
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SiteSearchPage() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await downloadsAPI.add(url)
|
||||
await downloadsAPI.add(url, '', { title: item.title })
|
||||
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Pencil, Play, Plus, Save, Trash2 } from 'lucide-react'
|
||||
import { CalendarClock, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Subscription } from '../types'
|
||||
|
||||
export function SubscriptionsPage() {
|
||||
@@ -20,6 +21,7 @@ export function SubscriptionsPage() {
|
||||
const [effects, setEffects] = useState('')
|
||||
const [releaseGroups, setReleaseGroups] = useState('')
|
||||
const [excludeWords, setExcludeWords] = useState('cam,ts,tc,枪版')
|
||||
const [washEnabled, setWashEnabled] = useState(false)
|
||||
const [washPriority, setWashPriority] = useState('balanced')
|
||||
const [editingId, setEditingId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -51,6 +53,7 @@ export function SubscriptionsPage() {
|
||||
effects: effects || undefined,
|
||||
release_groups: releaseGroups || undefined,
|
||||
exclude_words: excludeWords || undefined,
|
||||
wash_enabled: washEnabled,
|
||||
wash_priority: washPriority,
|
||||
priority: 50,
|
||||
}
|
||||
@@ -85,6 +88,7 @@ export function SubscriptionsPage() {
|
||||
setEffects('')
|
||||
setReleaseGroups('')
|
||||
setExcludeWords('cam,ts,tc,枪版')
|
||||
setWashEnabled(false)
|
||||
setWashPriority('balanced')
|
||||
}
|
||||
|
||||
@@ -103,6 +107,7 @@ export function SubscriptionsPage() {
|
||||
setEffects(s.effects || '')
|
||||
setReleaseGroups(s.release_groups || '')
|
||||
setExcludeWords(s.exclude_words || '')
|
||||
setWashEnabled(Boolean(s.wash_enabled))
|
||||
setWashPriority(s.wash_priority || 'balanced')
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
@@ -173,7 +178,11 @@ export function SubscriptionsPage() {
|
||||
<option value="hdtv">HDTV</option>
|
||||
</select>
|
||||
<input className="input-base" placeholder="特效/音轨 hdr,dolby-vision,atmos" value={effects} onChange={(e) => setEffects(e.target.value)} />
|
||||
<select className="input-base" value={washPriority} onChange={(e) => setWashPriority(e.target.value)}>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-ink-100">
|
||||
<input type="checkbox" checked={washEnabled} onChange={(e) => setWashEnabled(e.target.checked)} />
|
||||
启用洗版择优
|
||||
</label>
|
||||
<select className="input-base disabled:opacity-50" disabled={!washEnabled} value={washPriority} onChange={(e) => setWashPriority(e.target.value)}>
|
||||
<option value="balanced">洗版:均衡</option>
|
||||
<option value="resolution">洗版:分辨率优先</option>
|
||||
<option value="quality">洗版:片源质量优先</option>
|
||||
@@ -197,72 +206,132 @@ export function SubscriptionsPage() {
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>RSS</th>
|
||||
<th>过滤器</th>
|
||||
<th>分类</th>
|
||||
<th>规则</th>
|
||||
<th>最近运行</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">{s.name}</td>
|
||||
<td className="max-w-md truncate text-ink-100" title={s.feed_url}>
|
||||
{s.feed_url}
|
||||
</td>
|
||||
<td className="text-ink-100">{s.filter || '—'}</td>
|
||||
<td className="text-ink-100">
|
||||
{[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'}
|
||||
</td>
|
||||
<td className="max-w-xs text-xs text-ink-100">
|
||||
{[s.search_mode === 'imdb' ? `IMDB:${s.imdb_id || '未填'}` : '', s.resolution, s.quality, s.effects, s.wash_priority]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '默认'}
|
||||
</td>
|
||||
<td className="text-sand-500">
|
||||
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => startEdit(s)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await subscriptionsAPI.runNow(s.id)
|
||||
toast.success(`已加入 ${r.queued} 项`)
|
||||
}}
|
||||
>
|
||||
<Play size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!confirm(`删除订阅「${s.name}」?`)) return
|
||||
await subscriptionsAPI.remove(s.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((subscription) => (
|
||||
<article
|
||||
key={subscription.id}
|
||||
className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl"
|
||||
>
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-36 w-24 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 to-surface-200 shadow-inner">
|
||||
{subscription.poster_url ? (
|
||||
<img
|
||||
src={imageURL(subscription.poster_url)}
|
||||
alt={subscription.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-2 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={22} />
|
||||
<span className="line-clamp-3">{subscription.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-brand-500">
|
||||
{subscription.source || 'RSS'}
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-sand-500">
|
||||
{[subscription.media_type, subscription.media_category].filter(Boolean).join(' / ') || '自动分类'}
|
||||
</span>
|
||||
<span className={'rounded-full px-2 py-0.5 text-[10px] font-semibold ' + (subscription.enabled ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500')}>
|
||||
{subscription.enabled ? '启用中' : '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="truncate font-display text-lg font-semibold text-ink-600" title={subscription.name}>
|
||||
{subscription.name}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{subscription.overview || subscription.filter || '已隐藏订阅源地址,避免多用户场景泄露私有 RSS Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-ink-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck size={13} className="text-brand-500" />
|
||||
<span>订阅源已脱敏</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarClock size={13} className="text-brand-500" />
|
||||
<span>{subscription.last_run_at ? new Date(subscription.last_run_at).toLocaleString() : '尚未运行'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subscriptionRuleBadges(subscription).map((label) => (
|
||||
<span key={label} className="rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-ink-100">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => startEdit(subscription)}
|
||||
>
|
||||
<Pencil size={13} className="mr-1 inline" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const result = await subscriptionsAPI.runNow(subscription.id)
|
||||
toast.success(`已加入 ${result.queued} 项`)
|
||||
}}
|
||||
>
|
||||
<Play size={13} className="mr-1 inline" />
|
||||
运行
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!confirm(`删除订阅「${subscription.name}」?`)) return
|
||||
await subscriptionsAPI.remove(subscription.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function subscriptionRuleBadges(subscription: Subscription): string[] {
|
||||
const labels = [
|
||||
subscription.search_mode === 'imdb' ? `IMDB ${subscription.imdb_id || '未填'}` : '关键词搜索',
|
||||
subscription.resolution && subscription.resolution !== 'best' ? subscription.resolution : '分辨率不限',
|
||||
subscription.quality || '质量不限',
|
||||
subscription.effects || '',
|
||||
subscription.release_groups ? `发布组 ${subscription.release_groups}` : '',
|
||||
subscription.wash_enabled ? `洗版 ${washPriorityLabel(subscription.wash_priority)}` : '未启用洗版',
|
||||
]
|
||||
return labels.filter(Boolean)
|
||||
}
|
||||
|
||||
function washPriorityLabel(priority?: string): string {
|
||||
switch (priority) {
|
||||
case 'resolution':
|
||||
return '分辨率优先'
|
||||
case 'quality':
|
||||
return '片源质量优先'
|
||||
case 'effects':
|
||||
return '特效优先'
|
||||
case 'seeders':
|
||||
return '做种数优先'
|
||||
default:
|
||||
return '均衡'
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -178,11 +178,16 @@ export interface Subscription {
|
||||
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
|
||||
@@ -193,12 +198,21 @@ export interface Subscription {
|
||||
|
||||
export interface DownloadTask {
|
||||
id: string
|
||||
user_id: string
|
||||
source: string
|
||||
url: string
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
save_path: string
|
||||
status: string
|
||||
progress: number
|
||||
state?: string
|
||||
dlspeed?: number
|
||||
upspeed?: number
|
||||
num_seeds?: number
|
||||
num_leechs?: number
|
||||
size?: number
|
||||
downloaded?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -206,6 +220,10 @@ export interface DownloadTask {
|
||||
export interface QBitTorrent {
|
||||
hash: string
|
||||
name: string
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
state: string
|
||||
progress: number
|
||||
dlspeed: number
|
||||
@@ -213,6 +231,7 @@ export interface QBitTorrent {
|
||||
num_seeds: number
|
||||
num_leechs: number
|
||||
size: number
|
||||
downloaded: number
|
||||
save_path: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user