From 1b2cbabc71b0183f70c5f18a5146617b31de2e0f Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:06:39 +0800 Subject: [PATCH] refactor: split subscription planner modules --- .../service/subscription_candidate_collect.go | 122 ++++ .../service/subscription_candidate_select.go | 124 ++++ internal/service/subscription_guid.go | 69 +++ internal/service/subscription_planner.go | 558 +----------------- internal/service/subscription_rules.go | 263 +++++++++ 5 files changed, 579 insertions(+), 557 deletions(-) create mode 100644 internal/service/subscription_candidate_collect.go create mode 100644 internal/service/subscription_candidate_select.go create mode 100644 internal/service/subscription_guid.go create mode 100644 internal/service/subscription_rules.go diff --git a/internal/service/subscription_candidate_collect.go b/internal/service/subscription_candidate_collect.go new file mode 100644 index 0000000..e103933 --- /dev/null +++ b/internal/service/subscription_candidate_collect.go @@ -0,0 +1,122 @@ +package service + +import ( + "regexp" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, allowQueryMismatch bool, stats *siteSearchSelectionStats) []siteSearchCandidate { + candidates := make([]siteSearchCandidate, 0, len(results)) + for _, item := range results { + matchText := subscriptionSearchResultText(item) + if !subscriptionTitleMatchesQuery(sub, matchText) { + if allowQueryMismatch { + stats.RelaxedQueryMatch++ + } else { + stats.QueryMismatch++ + stats.QueryMismatchExamples = appendLimitedStrings(stats.QueryMismatchExamples, matchText, 5) + continue + } + } + if !matchesSubscriptionRules(sub, matchText) { + stats.RuleMismatch++ + continue + } + download := strings.TrimSpace(item.DownloadURL) + if download == "" { + download = strings.TrimSpace(item.TorrentURL) + } + if download == "" { + stats.MissingDownload++ + continue + } + guid := stableSiteSearchGUID(item, download) + if _, ok := seenSet[guid]; ok { + stats.Seen++ + continue + } + season, episode := ParseEpisode(matchText) + score := subscriptionCandidateScore(sub, item) + stats.Prepared++ + candidates = append(candidates, siteSearchCandidate{ + Item: item, + Download: download, + GUID: guid, + Season: season, + Episode: episode, + Pack: isSeriesPackTitle(item.Title), + Score: score, + }) + } + return candidates +} + +func appendLimitedStrings(values []string, value string, limit int) []string { + value = strings.TrimSpace(value) + if value == "" || limit <= 0 || len(values) >= limit { + return values + } + return append(values, value) +} + +func shouldRelaxSiteSearchQueryMatch(sub *model.Subscription, local LocalAvailability) bool { + if sub == nil { + return false + } + mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") + if !isSubscriptionSeriesType(mediaType) { + return false + } + if local.LocalMediaCount == 0 && len(local.ExistingEpisodeKeys) == 0 { + return false + } + return local.TotalEpisodes > 0 || len(local.MissingEpisodes) > 0 +} + +func subscriptionSearchResultText(item SearchResult) string { + return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle}, " ")) +} + +func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, filter *regexp.Regexp, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate { + if seenSet == nil { + seenSet = map[string]struct{}{} + } + candidates := make([]siteSearchCandidate, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + if filter != nil && !filter.MatchString(title) { + continue + } + if !matchesSubscriptionRules(sub, title) { + continue + } + download := strings.TrimSpace(item.Enclosure.URL) + if download == "" { + download = strings.TrimSpace(item.Link) + } + if download == "" { + continue + } + guid := stableRSSItemGUID(title, item.GUID, item.Link, item.Enclosure.URL) + if _, ok := seenSet[guid]; ok { + continue + } + searchItem := SearchResult{Title: title, DownloadURL: download} + season, episode := ParseEpisode(title) + candidates = append(candidates, siteSearchCandidate{ + Item: searchItem, + Download: download, + GUID: guid, + Season: season, + Episode: episode, + Pack: isSeriesPackTitle(title), + Score: subscriptionCandidateScore(sub, searchItem), + }) + } + return selectPreparedSubscriptionCandidates(candidates, sub, local) +} diff --git a/internal/service/subscription_candidate_select.go b/internal/service/subscription_candidate_select.go new file mode 100644 index 0000000..6d8a5f0 --- /dev/null +++ b/internal/service/subscription_candidate_select.go @@ -0,0 +1,124 @@ +package service + +import ( + "sort" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability) []siteSearchCandidate { + return selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, nil) +} + +func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability, stats *siteSearchSelectionStats) []siteSearchCandidate { + if len(candidates) > 1 { + 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 + }) + } + if len(candidates) == 0 { + return recordPreparedSelection(nil, stats) + } + + mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") + if !isSubscriptionSeriesType(mediaType) { + // 非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。 + if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 { + if stats != nil { + stats.LocalAlreadySatisfied = true + } + return recordPreparedSelection(nil, stats) + } + return recordPreparedSelection(candidates[:1], stats) + } + + if local.HasSeriesPack { + if stats != nil { + stats.LocalSeriesPackPresent = true + } + return recordPreparedSelection(nil, stats) + } + if local.LocalMediaCount > 0 { + if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 { + if stats != nil { + stats.SeriesComplete = true + } + return recordPreparedSelection(nil, stats) + } + missingSet := missingEpisodeSet(local) + onlyMissing := make([]siteSearchCandidate, 0, len(candidates)) + var packFallback *siteSearchCandidate + for i := range candidates { + candidate := candidates[i] + if candidate.Episode <= 0 { + // 整季/全集包(无单集号)。剧集完结后站点常只挂全集包, + // 这里记下来作兜底:当单集候选不足以补齐缺失集时启用, + // 否则"补全缺失集"在站点只有全集包时永远匹配为空。 + if stats != nil { + stats.NoEpisodeSkipped++ + } + if candidate.Pack && packFallback == nil { + packFallback = &candidates[i] + if stats != nil { + stats.PackFallbackAvailable = true + } + } + continue + } + season := candidate.Season + if season <= 0 { + season = 1 + } + if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists { + if stats != nil { + stats.ExistingEpisodeSkipped++ + } + continue + } + if local.TotalEpisodes > 0 { + if _, missing := missingSet[candidate.Episode]; !missing { + if stats != nil { + stats.NotMissingEpisodeSkipped++ + } + continue + } + } + onlyMissing = append(onlyMissing, candidate) + } + selected := sortedEpisodeCandidates(onlyMissing) + if len(selected) == 0 && packFallback != nil { + // 没有可用的单集候选,但站点有整季/全集包 → 用包兜底补缺集。 + // 代价是会重下已有集,但用户主动触发补全时这是可接受的。 + if stats != nil { + stats.PackFallbackUsed = true + } + return recordPreparedSelection([]siteSearchCandidate{*packFallback}, stats) + } + return recordPreparedSelection(selected, stats) + } + + for _, candidate := range candidates { + if candidate.Pack { + return recordPreparedSelection([]siteSearchCandidate{candidate}, stats) + } + } + + selected := sortedEpisodeCandidates(candidates) + if len(selected) == 0 { + return recordPreparedSelection(candidates[:1], stats) + } + return recordPreparedSelection(selected, stats) +} + +func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearchSelectionStats) []siteSearchCandidate { + if stats != nil { + stats.Selected = len(candidates) + } + return candidates +} diff --git a/internal/service/subscription_guid.go b/internal/service/subscription_guid.go new file mode 100644 index 0000000..08a474b --- /dev/null +++ b/internal/service/subscription_guid.go @@ -0,0 +1,69 @@ +package service + +import ( + "fmt" + "net/url" + "strings" +) + +func stableRSSItemGUID(title, guid, link, enclosureURL string) string { + parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))} + for _, raw := range []string{guid, enclosureURL, link} { + if key := stableDownloadURLKey(raw); key != "" { + parts = append(parts, key) + return strings.Join(parts, "|") + } + if raw = strings.TrimSpace(raw); raw != "" { + parts = append(parts, strings.ToLower(raw)) + return strings.Join(parts, "|") + } + } + return strings.Join(parts, "|") +} + +func stableSiteSearchGUID(item SearchResult, download string) string { + parts := []string{ + "site", + strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))), + strings.ToLower(strings.TrimSpace(item.Category)), + strings.ToLower(strings.TrimSpace(item.Title)), + fmt.Sprintf("%d", item.Size), + } + if key := stableDownloadURLKey(download); key != "" { + parts = append(parts, key) + } + return strings.Join(parts, "|") +} + +func stableDownloadURLKey(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return strings.ToLower(raw) + } + if strings.EqualFold(u.Scheme, "magnet") { + xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt"))) + if xt != "" { + return "magnet:" + xt + } + return strings.ToLower(raw) + } + if u.Host == "" { + return strings.ToLower(raw) + } + q := u.Query() + kept := make([]string, 0, 4) + for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} { + if value := strings.TrimSpace(q.Get(key)); value != "" { + kept = append(kept, key+"="+strings.ToLower(value)) + } + } + base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/")) + if len(kept) > 0 { + return base + "?" + strings.Join(kept, "&") + } + return base +} diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go index 20da6f8..7d2df6d 100644 --- a/internal/service/subscription_planner.go +++ b/internal/service/subscription_planner.go @@ -1,21 +1,7 @@ // Package service — subscription planning and release candidate selection. package service -import ( - "fmt" - "net/url" - "regexp" - "sort" - "strings" - "unicode" - - "github.com/ShukeBta/MediaStationGo/internal/model" -) - -var ( - seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) - seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) -) +import "github.com/ShukeBta/MediaStationGo/internal/model" type siteSearchCandidate struct { Item SearchResult @@ -96,545 +82,3 @@ func selectSiteSearchCandidatesWithStats(results []SearchResult, sub *model.Subs selected := selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, &stats) return selected, stats } - -func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, allowQueryMismatch bool, stats *siteSearchSelectionStats) []siteSearchCandidate { - candidates := make([]siteSearchCandidate, 0, len(results)) - for _, item := range results { - matchText := subscriptionSearchResultText(item) - if !subscriptionTitleMatchesQuery(sub, matchText) { - if allowQueryMismatch { - stats.RelaxedQueryMatch++ - } else { - stats.QueryMismatch++ - stats.QueryMismatchExamples = appendLimitedStrings(stats.QueryMismatchExamples, matchText, 5) - continue - } - } - if !matchesSubscriptionRules(sub, matchText) { - stats.RuleMismatch++ - continue - } - download := strings.TrimSpace(item.DownloadURL) - if download == "" { - download = strings.TrimSpace(item.TorrentURL) - } - if download == "" { - stats.MissingDownload++ - continue - } - guid := stableSiteSearchGUID(item, download) - if _, ok := seenSet[guid]; ok { - stats.Seen++ - continue - } - season, episode := ParseEpisode(matchText) - score := subscriptionCandidateScore(sub, item) - stats.Prepared++ - candidates = append(candidates, siteSearchCandidate{ - Item: item, - Download: download, - GUID: guid, - Season: season, - Episode: episode, - Pack: isSeriesPackTitle(item.Title), - Score: score, - }) - } - return candidates -} - -func appendLimitedStrings(values []string, value string, limit int) []string { - value = strings.TrimSpace(value) - if value == "" || limit <= 0 || len(values) >= limit { - return values - } - return append(values, value) -} - -func shouldRelaxSiteSearchQueryMatch(sub *model.Subscription, local LocalAvailability) bool { - if sub == nil { - return false - } - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - if !isSubscriptionSeriesType(mediaType) { - return false - } - if local.LocalMediaCount == 0 && len(local.ExistingEpisodeKeys) == 0 { - return false - } - return local.TotalEpisodes > 0 || len(local.MissingEpisodes) > 0 -} - -func subscriptionSearchResultText(item SearchResult) string { - return strings.TrimSpace(strings.Join([]string{item.Title, item.Subtitle}, " ")) -} - -func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, filter *regexp.Regexp, seenSet map[string]struct{}, local LocalAvailability) []siteSearchCandidate { - if seenSet == nil { - seenSet = map[string]struct{}{} - } - candidates := make([]siteSearchCandidate, 0, len(items)) - for _, item := range items { - title := strings.TrimSpace(item.Title) - if title == "" { - continue - } - if filter != nil && !filter.MatchString(title) { - continue - } - if !matchesSubscriptionRules(sub, title) { - continue - } - download := strings.TrimSpace(item.Enclosure.URL) - if download == "" { - download = strings.TrimSpace(item.Link) - } - if download == "" { - continue - } - guid := stableRSSItemGUID(title, item.GUID, item.Link, item.Enclosure.URL) - if _, ok := seenSet[guid]; ok { - continue - } - searchItem := SearchResult{Title: title, DownloadURL: download} - season, episode := ParseEpisode(title) - candidates = append(candidates, siteSearchCandidate{ - Item: searchItem, - Download: download, - GUID: guid, - Season: season, - Episode: episode, - Pack: isSeriesPackTitle(title), - Score: subscriptionCandidateScore(sub, searchItem), - }) - } - return selectPreparedSubscriptionCandidates(candidates, sub, local) -} - -func selectPreparedSubscriptionCandidates(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability) []siteSearchCandidate { - return selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, nil) -} - -func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandidate, sub *model.Subscription, local LocalAvailability, stats *siteSearchSelectionStats) []siteSearchCandidate { - if len(candidates) > 1 { - 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 - }) - } - if len(candidates) == 0 { - return recordPreparedSelection(nil, stats) - } - - mediaType := normalizeMediaType(sub.MediaType, sub.Name+" "+sub.Filter, "") - if !isSubscriptionSeriesType(mediaType) { - // 非洗版订阅成功下载一次即满足,媒体库/下载中已存在则不再重复下载。 - if (sub == nil || !sub.WashEnabled) && local.LocalMediaCount > 0 { - if stats != nil { - stats.LocalAlreadySatisfied = true - } - return recordPreparedSelection(nil, stats) - } - return recordPreparedSelection(candidates[:1], stats) - } - - if local.HasSeriesPack { - if stats != nil { - stats.LocalSeriesPackPresent = true - } - return recordPreparedSelection(nil, stats) - } - if local.LocalMediaCount > 0 { - if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 { - if stats != nil { - stats.SeriesComplete = true - } - return recordPreparedSelection(nil, stats) - } - missingSet := missingEpisodeSet(local) - onlyMissing := make([]siteSearchCandidate, 0, len(candidates)) - var packFallback *siteSearchCandidate - for i := range candidates { - candidate := candidates[i] - if candidate.Episode <= 0 { - // 整季/全集包(无单集号)。剧集完结后站点常只挂全集包, - // 这里记下来作兜底:当单集候选不足以补齐缺失集时启用, - // 否则"补全缺失集"在站点只有全集包时永远匹配为空。 - if stats != nil { - stats.NoEpisodeSkipped++ - } - if candidate.Pack && packFallback == nil { - packFallback = &candidates[i] - if stats != nil { - stats.PackFallbackAvailable = true - } - } - continue - } - season := candidate.Season - if season <= 0 { - season = 1 - } - if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists { - if stats != nil { - stats.ExistingEpisodeSkipped++ - } - continue - } - if local.TotalEpisodes > 0 { - if _, missing := missingSet[candidate.Episode]; !missing { - if stats != nil { - stats.NotMissingEpisodeSkipped++ - } - continue - } - } - onlyMissing = append(onlyMissing, candidate) - } - selected := sortedEpisodeCandidates(onlyMissing) - if len(selected) == 0 && packFallback != nil { - // 没有可用的单集候选,但站点有整季/全集包 → 用包兜底补缺集。 - // 代价是会重下已有集,但用户主动触发补全时这是可接受的。 - if stats != nil { - stats.PackFallbackUsed = true - } - return recordPreparedSelection([]siteSearchCandidate{*packFallback}, stats) - } - return recordPreparedSelection(selected, stats) - } - - for _, candidate := range candidates { - if candidate.Pack { - return recordPreparedSelection([]siteSearchCandidate{candidate}, stats) - } - } - - selected := sortedEpisodeCandidates(candidates) - if len(selected) == 0 { - return recordPreparedSelection(candidates[:1], stats) - } - return recordPreparedSelection(selected, stats) -} - -func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearchSelectionStats) []siteSearchCandidate { - if stats != nil { - stats.Selected = len(candidates) - } - return candidates -} - -func stableRSSItemGUID(title, guid, link, enclosureURL string) string { - parts := []string{"rss", strings.ToLower(strings.TrimSpace(title))} - for _, raw := range []string{guid, enclosureURL, link} { - if key := stableDownloadURLKey(raw); key != "" { - parts = append(parts, key) - return strings.Join(parts, "|") - } - if raw = strings.TrimSpace(raw); raw != "" { - parts = append(parts, strings.ToLower(raw)) - return strings.Join(parts, "|") - } - } - return strings.Join(parts, "|") -} - -func stableSiteSearchGUID(item SearchResult, download string) string { - parts := []string{ - "site", - strings.ToLower(strings.TrimSpace(firstNonEmpty(item.SiteID, item.SiteName))), - strings.ToLower(strings.TrimSpace(item.Category)), - strings.ToLower(strings.TrimSpace(item.Title)), - fmt.Sprintf("%d", item.Size), - } - if key := stableDownloadURLKey(download); key != "" { - parts = append(parts, key) - } - return strings.Join(parts, "|") -} - -func stableDownloadURLKey(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - u, err := url.Parse(raw) - if err != nil { - return strings.ToLower(raw) - } - if strings.EqualFold(u.Scheme, "magnet") { - xt := strings.ToLower(strings.TrimSpace(u.Query().Get("xt"))) - if xt != "" { - return "magnet:" + xt - } - return strings.ToLower(raw) - } - if u.Host == "" { - return strings.ToLower(raw) - } - q := u.Query() - kept := make([]string, 0, 4) - for _, key := range []string{"id", "tid", "torrent", "torrent_id", "torrentid", "hash", "info_hash"} { - if value := strings.TrimSpace(q.Get(key)); value != "" { - kept = append(kept, key+"="+strings.ToLower(value)) - } - } - base := strings.ToLower(strings.TrimRight(u.Host, "/") + "/" + strings.TrimLeft(u.Path, "/")) - if len(kept) > 0 { - return base + "?" + strings.Join(kept, "&") - } - return base -} - -// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效, -// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 -const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample" - -func matchesSubscriptionRules(sub *model.Subscription, title string) bool { - titleFold := strings.ToLower(title) - if containsAnyExcludeToken(titleFold, defaultExcludeWords) { - return false - } - if sub == nil { - return true - } - if sub.ExcludeWords != "" && containsAnyExcludeToken(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(subscriptionSearchResultText(item)) - score := item.Seeders - if sub == nil || !sub.WashEnabled { - if item.Free { - score += 25 - } - return score - } - 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 -} - -// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts" -// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。 -func containsAnyExcludeToken(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 - } - if isASCIIWordToken(token) { - if matchesWordBoundary(titleFold, token) { - return true - } - continue - } - if strings.Contains(titleFold, token) { - return true - } - } - return false -} - -func isASCIIWordToken(token string) bool { - for _, r := range token { - if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) { - return false - } - } - return token != "" -} - -// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。 -func matchesWordBoundary(titleFold, token string) bool { - isWordRune := func(r rune) bool { - return unicode.IsLetter(r) || unicode.IsDigit(r) - } - from := 0 - for { - idx := strings.Index(titleFold[from:], token) - if idx < 0 { - return false - } - start := from + idx - end := start + len(token) - leftOK := start == 0 || !isWordRune(rune(titleFold[start-1])) - rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end])) - if leftOK && rightOK { - return true - } - from = start + 1 - if from >= len(titleFold) { - 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": - return true - default: - return false - } -} - -func isSeriesPackTitle(title string) bool { - title = strings.TrimSpace(title) - if title == "" { - return false - } - if seriesPackRE.MatchString(title) { - return true - } - _, episode := ParseEpisode(title) - return episode == 0 && seasonOnlyRE.MatchString(title) -} diff --git a/internal/service/subscription_rules.go b/internal/service/subscription_rules.go new file mode 100644 index 0000000..c183d9f --- /dev/null +++ b/internal/service/subscription_rules.go @@ -0,0 +1,263 @@ +package service + +import ( + "regexp" + "strings" + "unicode" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +var ( + seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) + seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) +) + +// defaultExcludeWords 是默认过滤的「垃圾版本」排除清单,对所有订阅生效, +// 与用户自定义排除词合并。拉丁词在 containsAnyExcludeToken 里按词边界匹配以避免子串误伤。 +const defaultExcludeWords = "cam,ts,tc,telesync,telecine,hdcam,hdts,枪版,抢先,抢鲜,预告,trailer,sample" + +func matchesSubscriptionRules(sub *model.Subscription, title string) bool { + titleFold := strings.ToLower(title) + if containsAnyExcludeToken(titleFold, defaultExcludeWords) { + return false + } + if sub == nil { + return true + } + if sub.ExcludeWords != "" && containsAnyExcludeToken(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(subscriptionSearchResultText(item)) + score := item.Seeders + if sub == nil || !sub.WashEnabled { + if item.Free { + score += 25 + } + return score + } + 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 +} + +// containsAnyExcludeToken 用于排除词匹配:纯 ASCII 字母数字的词按词边界匹配(避免 "ts" +// 误伤 "tsukihime"、"cam" 误伤 "camp" 之类的子串误判),含 CJK/符号的词仍按子串匹配。 +func containsAnyExcludeToken(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 + } + if isASCIIWordToken(token) { + if matchesWordBoundary(titleFold, token) { + return true + } + continue + } + if strings.Contains(titleFold, token) { + return true + } + } + return false +} + +func isASCIIWordToken(token string) bool { + for _, r := range token { + if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r)) { + return false + } + } + return token != "" +} + +// matchesWordBoundary 判断 token 是否作为独立词出现在 title 中,词边界为「非字母数字」。 +func matchesWordBoundary(titleFold, token string) bool { + isWordRune := func(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) + } + from := 0 + for { + idx := strings.Index(titleFold[from:], token) + if idx < 0 { + return false + } + start := from + idx + end := start + len(token) + leftOK := start == 0 || !isWordRune(rune(titleFold[start-1])) + rightOK := end >= len(titleFold) || !isWordRune(rune(titleFold[end])) + if leftOK && rightOK { + return true + } + from = start + 1 + if from >= len(titleFold) { + 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": + return true + default: + return false + } +} + +func isSeriesPackTitle(title string) bool { + title = strings.TrimSpace(title) + if title == "" { + return false + } + if seriesPackRE.MatchString(title) { + return true + } + _, episode := ParseEpisode(title) + return episode == 0 && seasonOnlyRE.MatchString(title) +}