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
+112 -12
View File
@@ -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")
+307
View File
@@ -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 ""
}
+20
View File
@@ -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
}
+12
View File
@@ -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})
}
}