mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
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*(?:s\d{1,2})?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)
|
||
}
|