mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
refactor: split subscription planner modules
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,21 +1,7 @@
|
|||||||
// Package service — subscription planning and release candidate selection.
|
// Package service — subscription planning and release candidate selection.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import "github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"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*季`)
|
|
||||||
)
|
|
||||||
|
|
||||||
type siteSearchCandidate struct {
|
type siteSearchCandidate struct {
|
||||||
Item SearchResult
|
Item SearchResult
|
||||||
@@ -96,545 +82,3 @@ func selectSiteSearchCandidatesWithStats(results []SearchResult, sub *model.Subs
|
|||||||
selected := selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, &stats)
|
selected := selectPreparedSubscriptionCandidatesWithStats(candidates, sub, local, &stats)
|
||||||
return selected, 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user