Add advanced discovery subscriptions

This commit is contained in:
ShukeBta
2026-05-28 22:21:48 +08:00
parent a6a1226ede
commit 05d49ca470
11 changed files with 707 additions and 33 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: Publish Docker image
on: on:
push: push:
branches: [main] branches: [main]
tags: ['v*'] tags: ['v*', 'MediaStationGo-v*']
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+1 -1
View File
@@ -2,7 +2,7 @@ name: Publish release packages
on: on:
push: push:
tags: ['v*'] tags: ['v*', 'MediaStationGo-v*']
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+79 -10
View File
@@ -10,26 +10,42 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/service" "github.com/ShukeBta/MediaStationGo/internal/service"
) )
type subscriptionPatchReq struct {
Name *string `json:"name"`
FeedURL *string `json:"feed_url"`
Filter *string `json:"filter"`
MediaType *string `json:"media_type"`
MediaCategory *string `json:"media_category"`
SavePath *string `json:"save_path"`
SearchMode *string `json:"search_mode"`
IMDBID *string `json:"imdb_id"`
Resolution *string `json:"resolution"`
Quality *string `json:"quality"`
Effects *string `json:"effects"`
ReleaseGroups *string `json:"release_groups"`
ExcludeWords *string `json:"exclude_words"`
WashPriority *string `json:"wash_priority"`
Priority *int `json:"priority"`
Enabled *bool `json:"enabled"`
}
// updateSubscriptionHandler patches a subscription row. // updateSubscriptionHandler patches a subscription row.
func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc { func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var patch model.Subscription var patch subscriptionPatchReq
if err := c.ShouldBindJSON(&patch); err != nil { if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
updates := subscriptionPatchUpdates(patch)
if len(updates) == 0 {
c.Status(http.StatusNoContent)
return
}
if err := svc.Repo.DB.WithContext(c.Request.Context()). if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.Subscription{}). Model(&model.Subscription{}).
Where("id = ?", c.Param("id")). Where("id = ?", c.Param("id")).
Updates(map[string]any{ Updates(updates).Error; err != nil {
"name": patch.Name,
"feed_url": patch.FeedURL,
"filter": patch.Filter,
"media_type": patch.MediaType,
"media_category": patch.MediaCategory,
"save_path": patch.SavePath,
"enabled": patch.Enabled,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -37,6 +53,59 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
} }
} }
func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any {
updates := map[string]any{}
if patch.Name != nil {
updates["name"] = *patch.Name
}
if patch.FeedURL != nil {
updates["feed_url"] = *patch.FeedURL
}
if patch.Filter != nil {
updates["filter"] = *patch.Filter
}
if patch.MediaType != nil {
updates["media_type"] = *patch.MediaType
}
if patch.MediaCategory != nil {
updates["media_category"] = *patch.MediaCategory
}
if patch.SavePath != nil {
updates["save_path"] = *patch.SavePath
}
if patch.SearchMode != nil {
updates["search_mode"] = *patch.SearchMode
}
if patch.IMDBID != nil {
updates["imdb_id"] = *patch.IMDBID
}
if patch.Resolution != nil {
updates["resolution"] = *patch.Resolution
}
if patch.Quality != nil {
updates["quality"] = *patch.Quality
}
if patch.Effects != nil {
updates["effects"] = *patch.Effects
}
if patch.ReleaseGroups != nil {
updates["release_groups"] = *patch.ReleaseGroups
}
if patch.ExcludeWords != nil {
updates["exclude_words"] = *patch.ExcludeWords
}
if patch.WashPriority != nil {
updates["wash_priority"] = *patch.WashPriority
}
if patch.Priority != nil {
updates["priority"] = *patch.Priority
}
if patch.Enabled != nil {
updates["enabled"] = *patch.Enabled
}
return updates
}
// searchSubscriptionHandler runs a one-off keyword search against the // searchSubscriptionHandler runs a one-off keyword search against the
// configured tracker sites for the given subscription. We treat the // configured tracker sites for the given subscription. We treat the
// subscription's filter as the search term; this lets the UI preview // subscription's filter as the search term; this lets the UI preview
+18
View File
@@ -18,6 +18,15 @@ type subscriptionReq struct {
MediaType string `json:"media_type"` MediaType string `json:"media_type"`
MediaCategory string `json:"media_category"` MediaCategory string `json:"media_category"`
SavePath string `json:"save_path"` SavePath string `json:"save_path"`
SearchMode string `json:"search_mode"`
IMDBID string `json:"imdb_id"`
Resolution string `json:"resolution"`
Quality string `json:"quality"`
Effects string `json:"effects"`
ReleaseGroups string `json:"release_groups"`
ExcludeWords string `json:"exclude_words"`
WashPriority string `json:"wash_priority"`
Priority int `json:"priority"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
} }
@@ -41,6 +50,15 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
MediaType: req.MediaType, MediaType: req.MediaType,
MediaCategory: req.MediaCategory, MediaCategory: req.MediaCategory,
SavePath: req.SavePath, SavePath: req.SavePath,
SearchMode: req.SearchMode,
IMDBID: req.IMDBID,
Resolution: req.Resolution,
Quality: req.Quality,
Effects: req.Effects,
ReleaseGroups: req.ReleaseGroups,
ExcludeWords: req.ExcludeWords,
WashPriority: req.WashPriority,
Priority: req.Priority,
Enabled: enabled, Enabled: enabled,
} }
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
+9
View File
@@ -189,6 +189,15 @@ type Subscription struct {
MediaType string `gorm:"size:16" json:"media_type,omitempty"` MediaType string `gorm:"size:16" json:"media_type,omitempty"`
MediaCategory string `gorm:"size:128" json:"media_category,omitempty"` MediaCategory string `gorm:"size:128" json:"media_category,omitempty"`
SavePath string `gorm:"size:1024" json:"save_path,omitempty"` 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"`
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
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"` LastRunAt *time.Time `json:"last_run_at,omitempty"`
} }
+198 -1
View File
@@ -15,6 +15,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
@@ -37,6 +38,7 @@ type siteSearchCandidate struct {
Season int Season int
Episode int Episode int
Pack bool Pack bool
Score int
} }
// SubscriptionService runs the polling loop. // SubscriptionService runs the polling loop.
@@ -92,6 +94,7 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio
if sub.Name == "" || sub.FeedURL == "" { if sub.Name == "" || sub.FeedURL == "" {
return errors.New("name and feed_url required") return errors.New("name and feed_url required")
} }
normalizeSubscriptionDefaults(sub)
enabled := sub.Enabled enabled := sub.Enabled
if err := s.repo.Subscription.Create(ctx, sub); err != nil { if err := s.repo.Subscription.Create(ctx, sub); err != nil {
return err return err
@@ -105,6 +108,21 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio
return nil return nil
} }
func normalizeSubscriptionDefaults(sub *model.Subscription) {
if strings.TrimSpace(sub.SearchMode) == "" {
sub.SearchMode = "keyword"
}
if strings.TrimSpace(sub.Resolution) == "" {
sub.Resolution = "best"
}
if strings.TrimSpace(sub.WashPriority) == "" {
sub.WashPriority = "balanced"
}
if sub.Priority == 0 {
sub.Priority = 50
}
}
// List returns every subscription rule. // List returns every subscription rule.
func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) { func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) {
return s.repo.Subscription.List(ctx) return s.repo.Subscription.List(ctx)
@@ -318,6 +336,9 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}) []siteSearchCandidate { func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}) []siteSearchCandidate {
candidates := make([]siteSearchCandidate, 0, len(results)) candidates := make([]siteSearchCandidate, 0, len(results))
for _, item := range results { for _, item := range results {
if !matchesSubscriptionRules(sub, item.Title) {
continue
}
download := strings.TrimSpace(item.DownloadURL) download := strings.TrimSpace(item.DownloadURL)
if download == "" { if download == "" {
download = strings.TrimSpace(item.TorrentURL) download = strings.TrimSpace(item.TorrentURL)
@@ -330,6 +351,7 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
continue continue
} }
season, episode := ParseEpisode(item.Title) season, episode := ParseEpisode(item.Title)
score := subscriptionCandidateScore(sub, item)
candidates = append(candidates, siteSearchCandidate{ candidates = append(candidates, siteSearchCandidate{
Item: item, Item: item,
Download: download, Download: download,
@@ -337,11 +359,21 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
Season: season, Season: season,
Episode: episode, Episode: episode,
Pack: isSeriesPackTitle(item.Title), Pack: isSeriesPackTitle(item.Title),
Score: score,
}) })
} }
if len(candidates) <= 1 { if len(candidates) <= 1 {
return candidates return candidates
} }
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Score != candidates[j].Score {
return candidates[i].Score > candidates[j].Score
}
if candidates[i].Item.Seeders != candidates[j].Item.Seeders {
return candidates[i].Item.Seeders > candidates[j].Item.Seeders
}
return candidates[i].Item.Size > candidates[j].Item.Size
})
mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "")
if !isSubscriptionSeriesType(mediaType) { if !isSubscriptionSeriesType(mediaType) {
@@ -365,7 +397,10 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
season = 1 season = 1
} }
key := fmt.Sprintf("%02dE%03d", season, candidate.Episode) key := fmt.Sprintf("%02dE%03d", season, candidate.Episode)
if _, ok := byEpisode[key]; ok { if current, ok := byEpisode[key]; ok {
if current.Score < candidate.Score {
byEpisode[key] = candidate
}
continue continue
} }
byEpisode[key] = candidate byEpisode[key] = candidate
@@ -376,12 +411,171 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription,
} }
selected := make([]siteSearchCandidate, 0, len(order)) selected := make([]siteSearchCandidate, 0, len(order))
sort.Strings(order)
for _, key := range order { for _, key := range order {
selected = append(selected, byEpisode[key]) selected = append(selected, byEpisode[key])
} }
return selected return selected
} }
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
if sub == nil {
return true
}
titleFold := strings.ToLower(title)
if sub.ExcludeWords != "" && containsAnyToken(titleFold, sub.ExcludeWords) {
return false
}
if sub.ReleaseGroups != "" && !containsAnyToken(titleFold, sub.ReleaseGroups) {
return false
}
if sub.Resolution != "" && sub.Resolution != "best" && !titleMatchesResolution(titleFold, sub.Resolution) {
return false
}
if sub.Quality != "" && sub.Quality != "best" && !titleMatchesQuality(titleFold, sub.Quality) {
return false
}
if sub.Effects != "" && !containsAnyEffect(titleFold, sub.Effects) {
return false
}
return true
}
func subscriptionCandidateScore(sub *model.Subscription, item SearchResult) int {
title := strings.ToLower(item.Title)
score := item.Seeders
resolutionScore := detectResolutionScore(title)
qualityScore := detectQualityScore(title)
effectScore := detectEffectScore(title)
priority := "balanced"
if sub != nil && strings.TrimSpace(sub.WashPriority) != "" {
priority = strings.ToLower(strings.TrimSpace(sub.WashPriority))
}
switch priority {
case "resolution":
score += resolutionScore*1000 + qualityScore*100 + effectScore*50
case "quality":
score += qualityScore*1000 + resolutionScore*200 + effectScore*50
case "effects":
score += effectScore*1000 + resolutionScore*200 + qualityScore*100
case "seeders":
score += qualityScore*3 + resolutionScore*2 + effectScore
default:
score += resolutionScore*500 + qualityScore*300 + effectScore*150
}
if item.Free {
score += 25
}
return score
}
func containsAnyToken(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" && strings.Contains(titleFold, token) {
return true
}
}
return false
}
func containsAnyEffect(titleFold, csv string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch token {
case "dolby-vision", "dolby vision", "dv":
if strings.Contains(titleFold, "dolby vision") || strings.Contains(titleFold, "dovi") || regexp.MustCompile(`\bdv\b`).MatchString(titleFold) {
return true
}
default:
if strings.Contains(titleFold, token) {
return true
}
}
}
return false
}
func titleMatchesResolution(titleFold, resolution string) bool {
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "2160p", "4k", "uhd":
return strings.Contains(titleFold, "2160p") || strings.Contains(titleFold, "4k") || strings.Contains(titleFold, "uhd")
case "1080p":
return strings.Contains(titleFold, "1080p") || strings.Contains(titleFold, "fhd")
case "720p":
return strings.Contains(titleFold, "720p")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(resolution)))
}
}
func titleMatchesQuality(titleFold, quality string) bool {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "webdl", "web-dl":
return strings.Contains(titleFold, "web-dl") || strings.Contains(titleFold, "webdl")
case "bluray", "blu-ray":
return strings.Contains(titleFold, "bluray") || strings.Contains(titleFold, "blu-ray") || strings.Contains(titleFold, "bdrip")
case "remux":
return strings.Contains(titleFold, "remux")
case "hdtv":
return strings.Contains(titleFold, "hdtv")
default:
return strings.Contains(titleFold, strings.ToLower(strings.TrimSpace(quality)))
}
}
func detectResolutionScore(titleFold string) int {
switch {
case titleMatchesResolution(titleFold, "2160p"):
return 4
case titleMatchesResolution(titleFold, "1080p"):
return 3
case titleMatchesResolution(titleFold, "720p"):
return 2
default:
return 1
}
}
func detectQualityScore(titleFold string) int {
switch {
case titleMatchesQuality(titleFold, "remux"):
return 5
case titleMatchesQuality(titleFold, "bluray"):
return 4
case titleMatchesQuality(titleFold, "web-dl"):
return 3
case titleMatchesQuality(titleFold, "hdtv"):
return 2
default:
return 1
}
}
func detectEffectScore(titleFold string) int {
score := 0
if containsAnyEffect(titleFold, "dolby-vision") {
score += 4
}
if strings.Contains(titleFold, "hdr10+") {
score += 3
} else if strings.Contains(titleFold, "hdr") {
score += 2
}
if strings.Contains(titleFold, "atmos") {
score += 2
}
return score
}
func isSubscriptionSeriesType(mediaType string) bool { func isSubscriptionSeriesType(mediaType string) bool {
switch normalizeMediaType(mediaType, "", "") { switch normalizeMediaType(mediaType, "", "") {
case "tv", "anime", "variety": case "tv", "anime", "variety":
@@ -417,6 +611,9 @@ func siteSearchKeyword(sub *model.Subscription) string {
if sub == nil { if sub == nil {
return "" return ""
} }
if strings.EqualFold(strings.TrimSpace(sub.SearchMode), "imdb") && strings.TrimSpace(sub.IMDBID) != "" {
return strings.TrimSpace(sub.IMDBID)
}
if u, err := url.Parse(sub.FeedURL); err == nil { if u, err := url.Parse(sub.FeedURL); err == nil {
if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" { if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" {
return keyword return keyword
+33 -4
View File
@@ -24,7 +24,7 @@ func TestSelectSiteSearchCandidatesPrefersSeriesPack(t *testing.T) {
} }
func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T) { func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T) {
sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime"} sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime", WashPriority: "resolution"}
results := []SearchResult{ results := []SearchResult{
{Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90}, {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90},
{Title: "葬送的芙莉莲 S01E01 2160p", DownloadURL: "https://pt/download/1b", Seeders: 80}, {Title: "葬送的芙莉莲 S01E01 2160p", DownloadURL: "https://pt/download/1b", Seeders: 80},
@@ -39,13 +39,13 @@ func TestSelectSiteSearchCandidatesQueuesDistinctEpisodesWhenNoPack(t *testing.T
if got[0].Episode != 1 || got[1].Episode != 2 || got[2].Episode != 3 { if got[0].Episode != 1 || got[1].Episode != 2 || got[2].Episode != 3 {
t.Fatalf("episodes = %d,%d,%d; want 1,2,3", got[0].Episode, got[1].Episode, got[2].Episode) t.Fatalf("episodes = %d,%d,%d; want 1,2,3", got[0].Episode, got[1].Episode, got[2].Episode)
} }
if got[0].Download != "https://pt/download/1a" { if got[0].Download != "https://pt/download/1b" {
t.Fatalf("duplicate episode should keep first/best result, got %q", got[0].Download) t.Fatalf("duplicate episode should keep wash-priority best result, got %q", got[0].Download)
} }
} }
func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) { func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) {
sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie"} sub := &model.Subscription{Name: "Inception 自动订阅", Filter: "Inception 2010", MediaType: "movie", WashPriority: "seeders"}
results := []SearchResult{ results := []SearchResult{
{Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90}, {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90},
{Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80}, {Title: "Inception 2010 2160p", DownloadURL: "https://pt/download/2160", Seeders: 80},
@@ -56,3 +56,32 @@ func TestSelectSiteSearchCandidatesKeepsMovieSingleBest(t *testing.T) {
t.Fatalf("selected %#v, want movie best only", got) t.Fatalf("selected %#v, want movie best only", got)
} }
} }
func TestSelectSiteSearchCandidatesAppliesQualityRules(t *testing.T) {
sub := &model.Subscription{
Name: "Dune 自动订阅",
Filter: "Dune 2021",
MediaType: "movie",
Resolution: "2160p",
Quality: "remux",
Effects: "hdr",
ExcludeWords: "cam,ts",
}
results := []SearchResult{
{Title: "Dune 2021 2160p WEB-DL HDR", DownloadURL: "https://pt/download/web", Seeders: 100},
{Title: "Dune 2021 2160p UHD BluRay REMUX HDR", DownloadURL: "https://pt/download/remux", Seeders: 30},
{Title: "Dune 2021 2160p REMUX HDR CAM", DownloadURL: "https://pt/download/cam", Seeders: 200},
}
got := selectSiteSearchCandidates(results, sub, map[string]struct{}{})
if len(got) != 1 || got[0].Download != "https://pt/download/remux" {
t.Fatalf("selected %#v, want filtered remux", got)
}
}
func TestSiteSearchKeywordCanUseIMDB(t *testing.T) {
sub := &model.Subscription{Name: "沙丘 自动订阅", Filter: "Dune 2021", SearchMode: "imdb", IMDBID: "tt1160419"}
if got := siteSearchKeyword(sub); got != "tt1160419" {
t.Fatalf("keyword = %q, want imdb id", got)
}
}
+12
View File
@@ -12,10 +12,22 @@ export const subscriptionsAPI = {
media_type?: string media_type?: string
media_category?: string media_category?: string
save_path?: string save_path?: string
search_mode?: string
imdb_id?: string
resolution?: string
quality?: string
effects?: string
release_groups?: string
exclude_words?: string
wash_priority?: string
priority?: number
enabled?: boolean enabled?: boolean
}) => }) =>
api.post<Subscription>('/subscriptions', input).then((r) => r.data), api.post<Subscription>('/subscriptions', input).then((r) => r.data),
update: (id: string, input: Partial<Subscription>) =>
api.put(`/subscriptions/${id}`, input).then((r) => r.data),
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data), remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
runNow: (id: string) => runNow: (id: string) =>
+234 -5
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Sparkles } from 'lucide-react' import toast from 'react-hot-toast'
import { AlertTriangle, Download, Info, Rss, Sparkles, X } from 'lucide-react'
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover' import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import { subscriptionsAPI } from '../api/subscriptions'
const defaultSections = [ const defaultSections = [
'tmdb_trending_day', 'tmdb_trending_day',
@@ -19,6 +21,7 @@ export function DiscoverPage() {
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({}) const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
useEffect(() => { useEffect(() => {
discoverAPI discoverAPI
@@ -138,6 +141,7 @@ export function DiscoverPage() {
key={key} key={key}
title={sectionMap.get(key)?.label ?? key} title={sectionMap.get(key)?.label ?? key}
items={items} items={items}
onSelect={setActiveItem}
/> />
) )
})} })}
@@ -151,27 +155,46 @@ export function DiscoverPage() {
)} )}
</div> </div>
)} )}
{activeItem && (
<DiscoverDetailModal
item={activeItem}
onClose={() => setActiveItem(null)}
/>
)}
</div> </div>
) )
} }
function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) { function ContentRow({
title,
items,
onSelect,
}: {
title: string
items: DiscoverItem[]
onSelect: (item: DiscoverItem) => void
}) {
return ( return (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2> <h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8"> <div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item, index) => ( {items.map((item, index) => (
<DiscoverCard key={discoverKey(item, index)} item={item} /> <DiscoverCard key={discoverKey(item, index)} item={item} onSelect={onSelect} />
))} ))}
</div> </div>
</section> </section>
) )
} }
function DiscoverCard({ item }: { item: DiscoverItem }) { function DiscoverCard({ item, onSelect }: { item: DiscoverItem; onSelect: (item: DiscoverItem) => void }) {
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb') const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
return ( return (
<div className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-all duration-300 hover:border-primary-500/30"> <button
type="button"
onClick={() => onSelect(item)}
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left transition-all duration-300 hover:-translate-y-1 hover:border-primary-500/30 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400/40"
>
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900"> <div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
{item.poster_url ? ( {item.poster_url ? (
<img <img
@@ -202,11 +225,217 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
<p className="text-[11px] text-sand-500"> <p className="text-[11px] text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'} {[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
</p> </p>
<p className="flex items-center gap-1 pt-1 text-[10px] font-semibold text-brand-500">
<Info size={10} />
详情 / 订阅
</p>
</div>
</button>
)
}
function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
const keyword = item.subscribe_keyword || buildSubscribeKeyword(item)
const [form, setForm] = useState({
keyword,
search_mode: 'keyword',
imdb_id: '',
media_type: item.media_type || '',
resolution: 'best',
quality: '',
effects: '',
release_groups: '',
exclude_words: 'cam,ts,tc,枪版',
wash_priority: 'balanced',
save_path: '',
media_category: '',
priority: 50,
run_now: true,
})
const [busy, setBusy] = useState(false)
const submit = async () => {
const finalKeyword = form.keyword.trim() || keyword
const feed = `site-search://search?keyword=${encodeURIComponent(finalKeyword)}&source=${encodeURIComponent(source)}`
setBusy(true)
try {
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: finalKeyword,
media_type: form.media_type || undefined,
media_category: form.media_category || undefined,
save_path: form.save_path || undefined,
search_mode: form.search_mode,
imdb_id: form.imdb_id || 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_priority: form.wash_priority,
priority: form.priority,
enabled: true,
})
if (form.run_now) {
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
} else {
toast.success('已创建订阅')
}
onClose()
} catch (err) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-3xl border border-white/60 bg-white p-5 shadow-2xl">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-brand-500">{source}</p>
<h2 className="font-display text-2xl font-bold text-ink-600">{item.title}</h2>
<p className="mt-1 text-sm text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : '']
.filter(Boolean)
.join(' · ')}
</p>
</div>
<button className="rounded-full border border-gray-200 p-2 text-ink-50 hover:bg-gray-50" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl bg-gray-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="aspect-[2/3] w-full object-cover" />
) : (
<div className="flex aspect-[2/3] items-center justify-center text-sand-500">无海报</div>
)}
</div>
{item.backdrop_url && (
<img src={imageURL(item.backdrop_url)} alt="" className="h-24 w-full rounded-2xl object-cover" />
)}
</div>
<div className="space-y-5">
<section className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<h3 className="mb-2 font-semibold text-ink-600">简介</h3>
<p className="text-sm leading-6 text-ink-100">{item.overview || '当前数据源没有返回简介。'}</p>
</section>
<section className="rounded-2xl border border-primary-400/20 bg-primary-400/5 p-4">
<h3 className="mb-3 flex items-center gap-2 font-semibold text-ink-600">
<Rss size={16} />
订阅下载规则
</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="text-xs text-sand-500 md:col-span-2">
搜索关键词
<input className="input-base mt-1" value={form.keyword} onChange={(e) => setForm((f) => ({ ...f, keyword: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
搜索方式
<select className="input-base mt-1" value={form.search_mode} onChange={(e) => setForm((f) => ({ ...f, search_mode: e.target.value }))}>
<option value="keyword">标题关键词</option>
<option value="imdb">IMDB ID</option>
</select>
</label>
<label className="text-xs text-sand-500">
IMDB ID
<input className="input-base mt-1" placeholder="tt1160419" value={form.imdb_id} onChange={(e) => setForm((f) => ({ ...f, imdb_id: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
类型
<select className="input-base mt-1" value={form.media_type} onChange={(e) => setForm((f) => ({ ...f, media_type: e.target.value }))}>
<option value="">自动识别</option>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
</select>
</label>
<label className="text-xs text-sand-500">
分辨率
<select className="input-base mt-1" value={form.resolution} onChange={(e) => setForm((f) => ({ ...f, resolution: e.target.value }))}>
<option value="best">自动择优</option>
<option value="2160p">2160p / 4K</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
</select>
</label>
<label className="text-xs text-sand-500">
质量
<select className="input-base mt-1" value={form.quality} onChange={(e) => setForm((f) => ({ ...f, quality: e.target.value }))}>
<option value="">不限</option>
<option value="remux">REMUX</option>
<option value="bluray">BluRay</option>
<option value="web-dl">WEB-DL</option>
<option value="hdtv">HDTV</option>
</select>
</label>
<label className="text-xs text-sand-500">
特效 / 音轨
<input className="input-base mt-1" placeholder="hdr,dolby-vision,atmos" value={form.effects} onChange={(e) => setForm((f) => ({ ...f, effects: e.target.value }))} />
</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 }))}>
<option value="balanced">均衡</option>
<option value="resolution">分辨率优先</option>
<option value="quality">片源质量优先</option>
<option value="effects">HDR/DV/Atmos 优先</option>
<option value="seeders">做种数优先</option>
</select>
</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 }))} />
</label>
<label className="text-xs text-sand-500">
排除词
<input className="input-base mt-1" value={form.exclude_words} onChange={(e) => setForm((f) => ({ ...f, exclude_words: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
分类覆盖
<input className="input-base mt-1" placeholder="综艺 / 日番 / 欧美剧" value={form.media_category} onChange={(e) => setForm((f) => ({ ...f, media_category: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
保存路径覆盖
<input className="input-base mt-1" value={form.save_path} onChange={(e) => setForm((f) => ({ ...f, save_path: e.target.value }))} />
</label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input type="checkbox" checked={form.run_now} onChange={(e) => setForm((f) => ({ ...f, run_now: e.target.checked }))} />
创建后立即搜索并下载
</label>
<button disabled={busy} onClick={submit} className="neon-button disabled:opacity-60">
<Download size={16} />
{busy ? '处理中…' : '创建订阅'}
</button>
</div>
</section>
</div>
</div>
</div> </div>
</div> </div>
) )
} }
function buildSubscribeKeyword(item: DiscoverItem): string {
return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ')
}
function DiscoverSkeleton() { function DiscoverSkeleton() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
+113 -11
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Play, Plus, Trash2 } from 'lucide-react' import { Pencil, Play, Plus, Save, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions' import { subscriptionsAPI } from '../api/subscriptions'
import type { Subscription } from '../types' import type { Subscription } from '../types'
@@ -13,6 +13,15 @@ export function SubscriptionsPage() {
const [mediaType, setMediaType] = useState('') const [mediaType, setMediaType] = useState('')
const [mediaCategory, setMediaCategory] = useState('') const [mediaCategory, setMediaCategory] = useState('')
const [savePath, setSavePath] = useState('') const [savePath, setSavePath] = useState('')
const [searchMode, setSearchMode] = useState('keyword')
const [imdbID, setImdbID] = useState('')
const [resolution, setResolution] = useState('best')
const [quality, setQuality] = useState('')
const [effects, setEffects] = useState('')
const [releaseGroups, setReleaseGroups] = useState('')
const [excludeWords, setExcludeWords] = useState('cam,ts,tc,枪版')
const [washPriority, setWashPriority] = useState('balanced')
const [editingId, setEditingId] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const refresh = () => const refresh = () =>
@@ -28,21 +37,31 @@ export function SubscriptionsPage() {
const onCreate = async (e: FormEvent) => { const onCreate = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
try { try {
await subscriptionsAPI.create({ const payload = {
name, name,
feed_url: feed, feed_url: feed,
filter, filter,
media_type: mediaType || undefined, media_type: mediaType || undefined,
media_category: mediaCategory || undefined, media_category: mediaCategory || undefined,
save_path: savePath || undefined, save_path: savePath || undefined,
}) search_mode: searchMode,
toast.success('已创建订阅') imdb_id: imdbID || undefined,
setName('') resolution,
setFeed('') quality: quality || undefined,
setFilter('') effects: effects || undefined,
setMediaType('') release_groups: releaseGroups || undefined,
setMediaCategory('') exclude_words: excludeWords || undefined,
setSavePath('') wash_priority: washPriority,
priority: 50,
}
if (editingId) {
await subscriptionsAPI.update(editingId, payload)
toast.success('已更新订阅')
} else {
await subscriptionsAPI.create(payload)
toast.success('已创建订阅')
}
resetForm()
await refresh() await refresh()
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
@@ -51,6 +70,43 @@ export function SubscriptionsPage() {
} }
} }
const resetForm = () => {
setEditingId('')
setName('')
setFeed('')
setFilter('')
setMediaType('')
setMediaCategory('')
setSavePath('')
setSearchMode('keyword')
setImdbID('')
setResolution('best')
setQuality('')
setEffects('')
setReleaseGroups('')
setExcludeWords('cam,ts,tc,枪版')
setWashPriority('balanced')
}
const startEdit = (s: Subscription) => {
setEditingId(s.id)
setName(s.name)
setFeed(s.feed_url)
setFilter(s.filter || '')
setMediaType(s.media_type || '')
setMediaCategory(s.media_category || '')
setSavePath(s.save_path || '')
setSearchMode(s.search_mode || 'keyword')
setImdbID(s.imdb_id || '')
setResolution(s.resolution || 'best')
setQuality(s.quality || '')
setEffects(s.effects || '')
setReleaseGroups(s.release_groups || '')
setExcludeWords(s.exclude_words || '')
setWashPriority(s.wash_priority || 'balanced')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1> <h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
@@ -98,9 +154,43 @@ export function SubscriptionsPage() {
value={savePath} value={savePath}
onChange={(e) => setSavePath(e.target.value)} onChange={(e) => setSavePath(e.target.value)}
/> />
<select className="input-base" value={searchMode} onChange={(e) => setSearchMode(e.target.value)}>
<option value="keyword">标题关键词搜索</option>
<option value="imdb">IMDB ID 搜索</option>
</select>
<input className="input-base" placeholder="IMDB ID,如 tt1160419" value={imdbID} onChange={(e) => setImdbID(e.target.value)} />
<select className="input-base" value={resolution} onChange={(e) => setResolution(e.target.value)}>
<option value="best">分辨率自动择优</option>
<option value="2160p">2160p / 4K</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
</select>
<select className="input-base" value={quality} onChange={(e) => setQuality(e.target.value)}>
<option value="">质量不限</option>
<option value="remux">REMUX</option>
<option value="bluray">BluRay</option>
<option value="web-dl">WEB-DL</option>
<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)}>
<option value="balanced">洗版:均衡</option>
<option value="resolution">洗版:分辨率优先</option>
<option value="quality">洗版:片源质量优先</option>
<option value="effects">洗版:HDR/DV/Atmos 优先</option>
<option value="seeders">洗版:做种数优先</option>
</select>
<input className="input-base" placeholder="发布组白名单,如 FRDS,OurTV" value={releaseGroups} onChange={(e) => setReleaseGroups(e.target.value)} />
<input className="input-base" placeholder="排除词,如 cam,ts,tc" value={excludeWords} onChange={(e) => setExcludeWords(e.target.value)} />
<button type="submit" className="neon-button md:col-span-1"> <button type="submit" className="neon-button md:col-span-1">
<Plus size={16} /> 添加 {editingId ? <Save size={16} /> : <Plus size={16} />}
{editingId ? '保存' : '添加'}
</button> </button>
{editingId && (
<button type="button" onClick={resetForm} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50">
取消编辑
</button>
)}
</form> </form>
{loading && <p className="text-sand-500">加载中…</p>} {loading && <p className="text-sand-500">加载中…</p>}
@@ -115,6 +205,7 @@ export function SubscriptionsPage() {
<th>RSS</th> <th>RSS</th>
<th>过滤器</th> <th>过滤器</th>
<th>分类</th> <th>分类</th>
<th>规则</th>
<th>最近运行</th> <th>最近运行</th>
<th className="text-right">操作</th> <th className="text-right">操作</th>
</tr> </tr>
@@ -130,10 +221,21 @@ export function SubscriptionsPage() {
<td className="text-ink-100"> <td className="text-ink-100">
{[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'} {[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'}
</td> </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"> <td className="text-sand-500">
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'} {s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
</td> </td>
<td className="space-x-2 py-2 text-right"> <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 <button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10" className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => { onClick={async () => {
+9
View File
@@ -176,6 +176,15 @@ export interface Subscription {
media_type?: string media_type?: string
media_category?: string media_category?: string
save_path?: string save_path?: string
search_mode?: string
imdb_id?: string
resolution?: string
quality?: string
effects?: string
release_groups?: string
exclude_words?: string
wash_priority?: string
priority?: number
enabled: boolean enabled: boolean
last_run_at?: string last_run_at?: string
created_at: string created_at: string