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
+79 -10
View File
@@ -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
+18
View File
@@ -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 {
+9
View File
@@ -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"`
}
+198 -1
View File
@@ -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
+33 -4
View File
@@ -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)
}
}