fix: stabilize library paths and subscription matching

This commit is contained in:
ShukeBta
2026-06-28 12:15:10 +08:00
parent e4abb03ca5
commit a97dc35790
102 changed files with 4582 additions and 1519 deletions
+1 -237
View File
@@ -1,28 +1,11 @@
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"
// defaultCompatibilityExcludeWords 是面向自动订阅的兼容性默认排除清单。
// 仅在用户未真正自定义排除词时启用,避免默认命中 DoVi/H.265/10bit/杜比音轨等版本。
const defaultCompatibilityExcludeWords = "dovi,dv,dolby vision,dolby,杜比视界,杜比,h265,h.265,h-265,h_265,h 265,hevc,x265,10bit,10-bit,10 bit,hi10p,atmos,truehd,ddp,dd+,eac3"
const legacyFrontendExcludeWords = "cam,ts,tc,枪版"
func matchesSubscriptionRules(sub *model.Subscription, title string) bool {
titleFold := strings.ToLower(title)
if containsAnyExcludeToken(titleFold, defaultExcludeWords) {
@@ -58,214 +41,7 @@ func shouldApplyDefaultCompatibilityExcludes(excludeWords string) bool {
}
func normalizeExcludeWords(csv string) string {
parts := make([]string, 0)
for _, token := range strings.FieldsFunc(strings.ToLower(csv), func(r rune) bool {
return r == ',' || r == '/' || r == '|' || r == ';' || r == ','
}) {
token = strings.TrimSpace(token)
if token != "" {
parts = append(parts, token)
}
}
return strings.Join(parts, ",")
}
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
return strings.Join(excludeWordTokens(csv), ",")
}
func isSubscriptionSeriesType(mediaType string) bool {
@@ -276,15 +52,3 @@ func isSubscriptionSeriesType(mediaType string) bool {
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)
}