From 05d49ca470b6bec6b3121bcc445bbf147d8c8e3f Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Thu, 28 May 2026 22:21:48 +0800 Subject: [PATCH] Add advanced discovery subscriptions --- .github/workflows/docker-publish.yml | 2 +- .github/workflows/release.yml | 2 +- internal/handler/subscription_extra.go | 89 +++++++-- internal/handler/subscriptions.go | 18 ++ internal/model/model.go | 9 + internal/service/subscription.go | 199 +++++++++++++++++++- internal/service/subscription_test.go | 37 +++- web/src/api/subscriptions.ts | 12 ++ web/src/pages/DiscoverPage.tsx | 239 ++++++++++++++++++++++++- web/src/pages/SubscriptionsPage.tsx | 124 +++++++++++-- web/src/types/index.ts | 9 + 11 files changed, 707 insertions(+), 33 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5e56384..f9dcdc8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,7 +3,7 @@ name: Publish Docker image on: push: branches: [main] - tags: ['v*'] + tags: ['v*', 'MediaStationGo-v*'] workflow_dispatch: permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d6d5fc..7174b84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Publish release packages on: push: - tags: ['v*'] + tags: ['v*', 'MediaStationGo-v*'] workflow_dispatch: permissions: diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go index 9d30af2..8cb98a5 100644 --- a/internal/handler/subscription_extra.go +++ b/internal/handler/subscription_extra.go @@ -10,26 +10,42 @@ import ( "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. func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - var patch model.Subscription + var patch subscriptionPatchReq if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + updates := subscriptionPatchUpdates(patch) + if len(updates) == 0 { + c.Status(http.StatusNoContent) + return + } if err := svc.Repo.DB.WithContext(c.Request.Context()). Model(&model.Subscription{}). Where("id = ?", c.Param("id")). - Updates(map[string]any{ - "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 { + Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 // configured tracker sites for the given subscription. We treat the // subscription's filter as the search term; this lets the UI preview diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index c432bb3..13fb70e 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -18,6 +18,15 @@ type subscriptionReq struct { 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"` } @@ -41,6 +50,15 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { MediaType: req.MediaType, MediaCategory: req.MediaCategory, 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, } if err := svc.Subscription.Create(c.Request.Context(), s); err != nil { diff --git a/internal/model/model.go b/internal/model/model.go index 4854357..cdef454 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -189,6 +189,15 @@ type Subscription struct { MediaType string `gorm:"size:16" json:"media_type,omitempty"` MediaCategory string `gorm:"size:128" json:"media_category,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"` LastRunAt *time.Time `json:"last_run_at,omitempty"` } diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 8839d67..4b0c32f 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -15,6 +15,7 @@ import ( "net/http" "net/url" "regexp" + "sort" "strings" "time" @@ -37,6 +38,7 @@ type siteSearchCandidate struct { Season int Episode int Pack bool + Score int } // SubscriptionService runs the polling loop. @@ -92,6 +94,7 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio if sub.Name == "" || sub.FeedURL == "" { return errors.New("name and feed_url required") } + normalizeSubscriptionDefaults(sub) enabled := sub.Enabled if err := s.repo.Subscription.Create(ctx, sub); err != nil { return err @@ -105,6 +108,21 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio 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. func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) { 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 { candidates := make([]siteSearchCandidate, 0, len(results)) for _, item := range results { + if !matchesSubscriptionRules(sub, item.Title) { + continue + } download := strings.TrimSpace(item.DownloadURL) if download == "" { download = strings.TrimSpace(item.TorrentURL) @@ -330,6 +351,7 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, continue } season, episode := ParseEpisode(item.Title) + score := subscriptionCandidateScore(sub, item) candidates = append(candidates, siteSearchCandidate{ Item: item, Download: download, @@ -337,11 +359,21 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, Season: season, Episode: episode, Pack: isSeriesPackTitle(item.Title), + Score: score, }) } if len(candidates) <= 1 { 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, "") if !isSubscriptionSeriesType(mediaType) { @@ -365,7 +397,10 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, season = 1 } 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 } byEpisode[key] = candidate @@ -376,12 +411,171 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, } selected := make([]siteSearchCandidate, 0, len(order)) + sort.Strings(order) for _, key := range order { selected = append(selected, byEpisode[key]) } 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 { switch normalizeMediaType(mediaType, "", "") { case "tv", "anime", "variety": @@ -417,6 +611,9 @@ func siteSearchKeyword(sub *model.Subscription) string { if sub == nil { 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 keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" { return keyword diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 86a4df2..ba03346 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -24,7 +24,7 @@ func TestSelectSiteSearchCandidatesPrefersSeriesPack(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{ {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1a", Seeders: 90}, {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 { 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" { - t.Fatalf("duplicate episode should keep first/best result, got %q", got[0].Download) + if got[0].Download != "https://pt/download/1b" { + t.Fatalf("duplicate episode should keep wash-priority best result, got %q", got[0].Download) } } 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{ {Title: "Inception 2010 1080p", DownloadURL: "https://pt/download/1080", Seeders: 90}, {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) } } + +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) + } +} diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts index 540e795..f42ac81 100644 --- a/web/src/api/subscriptions.ts +++ b/web/src/api/subscriptions.ts @@ -12,10 +12,22 @@ export const subscriptionsAPI = { media_type?: string media_category?: 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 }) => api.post('/subscriptions', input).then((r) => r.data), + update: (id: string, input: Partial) => + api.put(`/subscriptions/${id}`, input).then((r) => r.data), + remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data), runNow: (id: string) => diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx index 2232e31..dff2b13 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -1,8 +1,10 @@ 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 { imageURL } from '../api/client' +import { subscriptionsAPI } from '../api/subscriptions' const defaultSections = [ 'tmdb_trending_day', @@ -19,6 +21,7 @@ export function DiscoverPage() { const [rows, setRows] = useState>({}) const [error, setError] = useState('') const [loading, setLoading] = useState(true) + const [activeItem, setActiveItem] = useState(null) useEffect(() => { discoverAPI @@ -138,6 +141,7 @@ export function DiscoverPage() { key={key} title={sectionMap.get(key)?.label ?? key} items={items} + onSelect={setActiveItem} /> ) })} @@ -151,27 +155,46 @@ export function DiscoverPage() { )} )} + + {activeItem && ( + setActiveItem(null)} + /> + )} ) } -function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) { +function ContentRow({ + title, + items, + onSelect, +}: { + title: string + items: DiscoverItem[] + onSelect: (item: DiscoverItem) => void +}) { return (

{title}

{items.map((item, index) => ( - + ))}
) } -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') return ( -
+ + ) +} + +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 ( +
+
+
+
+

{source}

+

{item.title}

+

+ {[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : ''] + .filter(Boolean) + .join(' · ')} +

+
+ +
+ +
+
+
+ {item.poster_url ? ( + {item.title} + ) : ( +
无海报
+ )} +
+ {item.backdrop_url && ( + + )} +
+ +
+
+

简介

+

{item.overview || '当前数据源没有返回简介。'}

+
+ +
+

+ + 订阅下载规则 +

+
+ + + + + + + + + + + + +
+
+ + +
+
+
+
) } +function buildSubscribeKeyword(item: DiscoverItem): string { + return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ') +} + function DiscoverSkeleton() { return (
diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 26d43bd..f87d5f9 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' 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 type { Subscription } from '../types' @@ -13,6 +13,15 @@ export function SubscriptionsPage() { const [mediaType, setMediaType] = useState('') const [mediaCategory, setMediaCategory] = 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 refresh = () => @@ -28,21 +37,31 @@ export function SubscriptionsPage() { const onCreate = async (e: FormEvent) => { e.preventDefault() try { - await subscriptionsAPI.create({ + const payload = { name, feed_url: feed, filter, media_type: mediaType || undefined, media_category: mediaCategory || undefined, save_path: savePath || undefined, - }) - toast.success('已创建订阅') - setName('') - setFeed('') - setFilter('') - setMediaType('') - setMediaCategory('') - setSavePath('') + search_mode: searchMode, + imdb_id: imdbID || undefined, + resolution, + quality: quality || undefined, + effects: effects || undefined, + release_groups: releaseGroups || undefined, + exclude_words: excludeWords || undefined, + wash_priority: washPriority, + priority: 50, + } + if (editingId) { + await subscriptionsAPI.update(editingId, payload) + toast.success('已更新订阅') + } else { + await subscriptionsAPI.create(payload) + toast.success('已创建订阅') + } + resetForm() await refresh() } catch (err: unknown) { 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 (

RSS 订阅

@@ -98,9 +154,43 @@ export function SubscriptionsPage() { value={savePath} onChange={(e) => setSavePath(e.target.value)} /> + + setImdbID(e.target.value)} /> + + + setEffects(e.target.value)} /> + + setReleaseGroups(e.target.value)} /> + setExcludeWords(e.target.value)} /> + {editingId && ( + + )} {loading &&

加载中…

} @@ -115,6 +205,7 @@ export function SubscriptionsPage() { RSS 过滤器 分类 + 规则 最近运行 操作 @@ -130,10 +221,21 @@ export function SubscriptionsPage() { {[s.media_type, s.media_category].filter(Boolean).join(' / ') || '自动'} + + {[s.search_mode === 'imdb' ? `IMDB:${s.imdb_id || '未填'}` : '', s.resolution, s.quality, s.effects, s.wash_priority] + .filter(Boolean) + .join(' · ') || '默认'} + {s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'} +