mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
feat: improve subscription and download cards
This commit is contained in:
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user