diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index 164ed0f..f8109c0 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -3,60 +3,11 @@ package service import ( "path/filepath" "regexp" - "strconv" "strings" "github.com/ShukeBta/MediaStationGo/internal/model" ) -// yearPattern extracts a 4-digit year (1900-2099). -var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`) - -// noiseTokens are stripped before search. -var noiseTokens = []string{ - // 视频规格 - "1080p", "2160p", "4k", "720p", "480p", "uhd", "ds4k", "fhd", - "bd", "bdrip", "brrip", "dvd", "dvdrip", "hdtv", "pdtv", "webdl", - "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", - "x264", "x265", "h264", "h265", "h266", "hevc", "avc", "av1", "vvc", "10bit", "8bit", "hi10p", "hi10", - "hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd", - "dovi", "atmos", "aac", "ac3", "flac", "fps", "hlg", "dv", - "remux", "extended", "uncut", "remastered", "repack", "proper", "internal", - "limited", "imax", "directors-cut", "directors_cut", - "hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio", - - // 流媒体平台 / 字幕组 / 国家版本(动漫常见) - "netflix", "nf", "amzn", "hulu", "disney", "max", "hbo", - "linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj", - "atvp", "appletv", "apple-tv", "tx", "txweb", - "crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease", - "erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv", - "mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio", - - // 中文字幕标记 - "zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc", - "中字", "繁字", "简中", "繁中", "国语", "粤语", "日语", - - // 季数前缀残留 — ParseEpisode 已抽取过 - "season", "264", "265", "aac2", "aac5", -} - -var noiseTokenSet = func() map[string]struct{} { - set := make(map[string]struct{}, len(noiseTokens)+1) - for _, token := range noiseTokens { - set[token] = struct{}{} - } - set["dl"] = struct{}{} - return set -}() - -var releaseBoundaryTokenSet = map[string]struct{}{ - "1080p": {}, "2160p": {}, "4k": {}, "720p": {}, "480p": {}, "uhd": {}, "fhd": {}, - "bd": {}, "bdrip": {}, "brrip": {}, "dvd": {}, "dvdrip": {}, "hdtv": {}, "pdtv": {}, - "webdl": {}, "hdrip": {}, "bluray": {}, "webrip": {}, "web": {}, "remux": {}, - "x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {}, -} - var ( episodeOnlyQueryRE = regexp.MustCompile(`(?i)^\s*(?:e(?:p(?:isode)?)?\s*\d{1,3}|episode\s*\d{1,3}|第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?)\s*$`) episodeTitleQueryRE = regexp.MustCompile(`^\s*第\s*[0-9一二三四五六七八九十百零两]+\s*[集期话話](?:\s*[上下])?\s*[::].+`) @@ -64,93 +15,6 @@ var ( episodeReleaseTitleTagRE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e\d{1,3}(?:[\s._-]|$)`) ) -// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments. -var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) -var multiWordNoise = []*regexp.Regexp{ - regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`), - regexp.MustCompile(`(?i)\bblu[\s._-]*ray\b`), - regexp.MustCompile(`(?i)\bdirectors[\s._-]*cut\b`), - regexp.MustCompile(`(?i)\berai[\s._-]*raws\b`), - regexp.MustCompile(`(?i)\bohys[\s._-]*raws\b`), -} - -// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv" -// into a TMDb-friendly title plus an optional year hint. -func CleanQuery(raw string) (title string, year int) { - base := pathBaseSlash(raw) - if base == "" { - base = strings.TrimSpace(raw) - } - name := strings.TrimSuffix(base, filepath.Ext(base)) - lower := strings.ToLower(name) - - if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 { - if v, err := strconv.Atoi(m[1]); err == nil { - year = v - lower = strings.ReplaceAll(lower, m[1], " ") - } - } - - lower = bracketedTag.ReplaceAllString(lower, " ") - - lower = patSEnE.ReplaceAllString(lower, " ") - lower = patDanglingSE.ReplaceAllString(lower, " ") - lower = patNxE.ReplaceAllString(lower, " ") - lower = patEP.ReplaceAllString(lower, " ") - lower = patCN.ReplaceAllString(lower, " ") - // 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染 - // 搜索查询又导致整理后的目录名重复季信息。 - lower = patSeasonOnly.ReplaceAllString(lower, " ") - lower = patCNSeason.ReplaceAllString(lower, " ") - - for _, pat := range multiWordNoise { - lower = pat.ReplaceAllString(lower, " ") - } - for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×"} { - lower = strings.ReplaceAll(lower, sep, " ") - } - // 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免 - // 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。 - out := make([]string, 0, 8) - seenReleaseBoundary := false - for _, w := range strings.Fields(lower) { - if _, ok := noiseTokenSet[w]; ok { - if _, boundary := releaseBoundaryTokenSet[w]; boundary { - seenReleaseBoundary = true - } - continue - } - if seenReleaseBoundary && isASCIIWord(w) { - continue - } - if len(w) <= 1 { - r := []rune(w) - if len(r) == 1 && r[0] < 128 { - continue - } - } - out = append(out, w) - } - title = strings.TrimSpace(strings.Join(out, " ")) - return title, year -} - -func isASCIIWord(s string) bool { - if s == "" { - return false - } - for _, r := range s { - if r >= 128 { - return false - } - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - continue - } - return false - } - return true -} - func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { seen := map[string]struct{}{} var out []string @@ -187,162 +51,6 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string { return out } -func mediaFolderTitle(mediaPath, libraryRoot string) string { - dir := parentSlashPath(mediaPath) - root := comparableLibraryRoot(libraryRoot) - for depth := 0; depth < 5 && dir != ""; depth++ { - if root != "" && sameSlashPath(dir, root) { - return libraryRootTitle(libraryRoot) - } - base := pathBaseSlash(dir) - if base == "" || base == "." { - return "" - } - if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { - dir = parentSlashPath(dir) - continue - } - if isGenericMediaCategoryFolder(base) { - return "" - } - title, _ := CleanQuery(base) - if title == "" { - title = strings.TrimSpace(base) - } - return strings.TrimSpace(title) - } - return "" -} - -func isTechnicalMediaFolder(name string) bool { - key := strings.ToLower(strings.TrimSpace(name)) - compact := strings.NewReplacer(" ", "", "_", "", ".", "", "-", "").Replace(key) - switch compact { - case "bdmv", "stream", "certificate", "videots", "audiots", - "subs", "subtitles", "subtitle", "sample", "samples", - "extra", "extras", "featurette", "featurettes": - return true - default: - return numberedTechnicalFolder(compact, "disc") || - numberedTechnicalFolder(compact, "disk") || - numberedTechnicalFolder(compact, "cd") || - numberedTechnicalFolder(compact, "dvd") || - numberedTechnicalFolder(compact, "part") - } -} - -func numberedTechnicalFolder(value, prefix string) bool { - if !strings.HasPrefix(value, prefix) || len(value) == len(prefix) { - return false - } - for _, r := range value[len(prefix):] { - if r < '0' || r > '9' { - return false - } - } - return true -} - -func cleanSlashPath(value string) string { - value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) - return strings.TrimRight(value, "/") -} - -func comparableLibraryRoot(libraryRoot string) string { - if info, ok := ParseCloudLibraryMount(libraryRoot); ok { - if strings.TrimSpace(info.DisplayDir) == "" { - return "cloud://" + info.Provider - } - return "cloud://" + info.Provider + "/" + info.DisplayDir - } - return cleanSlashPath(libraryRoot) -} - -func sameSlashPath(a, b string) bool { - return strings.EqualFold(cleanSlashPath(a), cleanSlashPath(b)) -} - -func parentSlashPath(value string) string { - value = cleanSlashPath(value) - if value == "" { - return "" - } - idx := strings.LastIndex(value, "/") - if idx < 0 { - return "" - } - return strings.TrimRight(value[:idx], "/") -} - -func seriesFolderTitle(mediaPath, libraryRoot string) string { - dir := parentSlashPath(mediaPath) - if strictSeasonFolderMatched(pathBaseSlash(dir)) { - dir = parentSlashPath(dir) - } - if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) { - return libraryRootTitle(libraryRoot) - } - base := pathBaseSlash(dir) - if base == "" || base == "." { - return "" - } - if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { - return "" - } - return base -} - -func libraryRootTitle(libraryRoot string) string { - base := "" - if info, ok := ParseCloudLibraryMount(libraryRoot); ok { - base = pathBaseSlash(info.DisplayDir) - } else { - base = pathBaseSlash(libraryRoot) - } - if base == "" || base == "." || isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { - return "" - } - return base -} - -func isGenericMediaCategoryFolder(name string) bool { - key := strings.ToLower(strings.TrimSpace(name)) - key = strings.Trim(key, `\/`) - switch key { - case "", - "电影", "movies", "movie", - "电视剧", "剧集", "tv", "shows", "series", - "动漫", "动画", "anime", "bangumi", - "国产剧", "国剧", "大陆剧", "国产电视剧", - "欧美剧", "欧美电视剧", - "日韩剧", "日剧", "韩剧", - "华语电影", "国产电影", "大陆电影", - "外语电影", "欧美电影", "日韩电影", - "动画电影", "动漫电影", - "国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", - "综艺", "真人秀", - "纪录片", "纪录", - "儿童", "少儿", - "成人", "番号", "9kg", - "未分类", "uncategorized": - return true - default: - return false - } -} - -func strictSeasonFolder(name string) int { - if season, ok := seasonFromDir(name); ok { - return season - } - return 0 -} - -func strictSeasonFolderMatched(name string) bool { - _, ok := seasonFromDir(name) - return ok -} - func titleCandidates(title string) []string { title = strings.Join(strings.Fields(strings.TrimSpace(title)), " ") if title == "" { diff --git a/internal/service/scraper_query_clean.go b/internal/service/scraper_query_clean.go new file mode 100644 index 0000000..d39fdca --- /dev/null +++ b/internal/service/scraper_query_clean.go @@ -0,0 +1,143 @@ +package service + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// yearPattern extracts a 4-digit year (1900-2099). +var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`) + +// noiseTokens are stripped before search. +var noiseTokens = []string{ + // 视频规格 + "1080p", "2160p", "4k", "720p", "480p", "uhd", "ds4k", "fhd", + "bd", "bdrip", "brrip", "dvd", "dvdrip", "hdtv", "pdtv", "webdl", + "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", + "x264", "x265", "h264", "h265", "h266", "hevc", "avc", "av1", "vvc", "10bit", "8bit", "hi10p", "hi10", + "hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd", + "dovi", "atmos", "aac", "ac3", "flac", "fps", "hlg", "dv", + "remux", "extended", "uncut", "remastered", "repack", "proper", "internal", + "limited", "imax", "directors-cut", "directors_cut", + "hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux", "qhstudio", + + // 流媒体平台 / 字幕组 / 国家版本(动漫常见) + "netflix", "nf", "amzn", "hulu", "disney", "max", "hbo", + "linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj", + "atvp", "appletv", "apple-tv", "tx", "txweb", + "crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease", + "erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws", "colortv", + "mweb", "ubweb", "hhweb", "adweb", "chdweb", "kurosawa", "qhstudio", + + // 中文字幕标记 + "zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc", + "中字", "繁字", "简中", "繁中", "国语", "粤语", "日语", + + // 季数前缀残留 — ParseEpisode 已抽取过 + "season", "264", "265", "aac2", "aac5", +} + +var noiseTokenSet = func() map[string]struct{} { + set := make(map[string]struct{}, len(noiseTokens)+1) + for _, token := range noiseTokens { + set[token] = struct{}{} + } + set["dl"] = struct{}{} + return set +}() + +var releaseBoundaryTokenSet = map[string]struct{}{ + "1080p": {}, "2160p": {}, "4k": {}, "720p": {}, "480p": {}, "uhd": {}, "fhd": {}, + "bd": {}, "bdrip": {}, "brrip": {}, "dvd": {}, "dvdrip": {}, "hdtv": {}, "pdtv": {}, + "webdl": {}, "hdrip": {}, "bluray": {}, "webrip": {}, "web": {}, "remux": {}, + "x264": {}, "x265": {}, "h264": {}, "h265": {}, "h266": {}, "hevc": {}, "avc": {}, "av1": {}, "vvc": {}, +} + +// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments. +var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`) +var multiWordNoise = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`), + regexp.MustCompile(`(?i)\bblu[\s._-]*ray\b`), + regexp.MustCompile(`(?i)\bdirectors[\s._-]*cut\b`), + regexp.MustCompile(`(?i)\berai[\s._-]*raws\b`), + regexp.MustCompile(`(?i)\bohys[\s._-]*raws\b`), +} + +// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv" +// into a TMDb-friendly title plus an optional year hint. +func CleanQuery(raw string) (title string, year int) { + base := pathBaseSlash(raw) + if base == "" { + base = strings.TrimSpace(raw) + } + name := strings.TrimSuffix(base, filepath.Ext(base)) + lower := strings.ToLower(name) + + if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 { + if v, err := strconv.Atoi(m[1]); err == nil { + year = v + lower = strings.ReplaceAll(lower, m[1], " ") + } + } + + lower = bracketedTag.ReplaceAllString(lower, " ") + + lower = patSEnE.ReplaceAllString(lower, " ") + lower = patDanglingSE.ReplaceAllString(lower, " ") + lower = patNxE.ReplaceAllString(lower, " ") + lower = patEP.ReplaceAllString(lower, " ") + lower = patCN.ReplaceAllString(lower, " ") + // 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染 + // 搜索查询又导致整理后的目录名重复季信息。 + lower = patSeasonOnly.ReplaceAllString(lower, " ") + lower = patCNSeason.ReplaceAllString(lower, " ") + + for _, pat := range multiWordNoise { + lower = pat.ReplaceAllString(lower, " ") + } + for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×"} { + lower = strings.ReplaceAll(lower, sep, " ") + } + // 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免 + // 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。 + out := make([]string, 0, 8) + seenReleaseBoundary := false + for _, w := range strings.Fields(lower) { + if _, ok := noiseTokenSet[w]; ok { + if _, boundary := releaseBoundaryTokenSet[w]; boundary { + seenReleaseBoundary = true + } + continue + } + if seenReleaseBoundary && isASCIIWord(w) { + continue + } + if len(w) <= 1 { + r := []rune(w) + if len(r) == 1 && r[0] < 128 { + continue + } + } + out = append(out, w) + } + title = strings.TrimSpace(strings.Join(out, " ")) + return title, year +} + +func isASCIIWord(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r >= 128 { + return false + } + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + continue + } + return false + } + return true +} diff --git a/internal/service/scraper_query_paths.go b/internal/service/scraper_query_paths.go new file mode 100644 index 0000000..c308416 --- /dev/null +++ b/internal/service/scraper_query_paths.go @@ -0,0 +1,159 @@ +package service + +import "strings" + +func mediaFolderTitle(mediaPath, libraryRoot string) string { + dir := parentSlashPath(mediaPath) + root := comparableLibraryRoot(libraryRoot) + for depth := 0; depth < 5 && dir != ""; depth++ { + if root != "" && sameSlashPath(dir, root) { + return libraryRootTitle(libraryRoot) + } + base := pathBaseSlash(dir) + if base == "" || base == "." { + return "" + } + if isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { + dir = parentSlashPath(dir) + continue + } + if isGenericMediaCategoryFolder(base) { + return "" + } + title, _ := CleanQuery(base) + if title == "" { + title = strings.TrimSpace(base) + } + return strings.TrimSpace(title) + } + return "" +} + +func isTechnicalMediaFolder(name string) bool { + key := strings.ToLower(strings.TrimSpace(name)) + compact := strings.NewReplacer(" ", "", "_", "", ".", "", "-", "").Replace(key) + switch compact { + case "bdmv", "stream", "certificate", "videots", "audiots", + "subs", "subtitles", "subtitle", "sample", "samples", + "extra", "extras", "featurette", "featurettes": + return true + default: + return numberedTechnicalFolder(compact, "disc") || + numberedTechnicalFolder(compact, "disk") || + numberedTechnicalFolder(compact, "cd") || + numberedTechnicalFolder(compact, "dvd") || + numberedTechnicalFolder(compact, "part") + } +} + +func numberedTechnicalFolder(value, prefix string) bool { + if !strings.HasPrefix(value, prefix) || len(value) == len(prefix) { + return false + } + for _, r := range value[len(prefix):] { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func cleanSlashPath(value string) string { + value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + return strings.TrimRight(value, "/") +} + +func comparableLibraryRoot(libraryRoot string) string { + if info, ok := ParseCloudLibraryMount(libraryRoot); ok { + if strings.TrimSpace(info.DisplayDir) == "" { + return "cloud://" + info.Provider + } + return "cloud://" + info.Provider + "/" + info.DisplayDir + } + return cleanSlashPath(libraryRoot) +} + +func sameSlashPath(a, b string) bool { + return strings.EqualFold(cleanSlashPath(a), cleanSlashPath(b)) +} + +func parentSlashPath(value string) string { + value = cleanSlashPath(value) + if value == "" { + return "" + } + idx := strings.LastIndex(value, "/") + if idx < 0 { + return "" + } + return strings.TrimRight(value[:idx], "/") +} + +func seriesFolderTitle(mediaPath, libraryRoot string) string { + dir := parentSlashPath(mediaPath) + if strictSeasonFolderMatched(pathBaseSlash(dir)) { + dir = parentSlashPath(dir) + } + if root := comparableLibraryRoot(libraryRoot); root != "" && sameSlashPath(dir, root) { + return libraryRootTitle(libraryRoot) + } + base := pathBaseSlash(dir) + if base == "" || base == "." { + return "" + } + if isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { + return "" + } + return base +} + +func libraryRootTitle(libraryRoot string) string { + base := "" + if info, ok := ParseCloudLibraryMount(libraryRoot); ok { + base = pathBaseSlash(info.DisplayDir) + } else { + base = pathBaseSlash(libraryRoot) + } + if base == "" || base == "." || isGenericMediaCategoryFolder(base) || isTechnicalMediaFolder(base) || strictSeasonFolderMatched(base) { + return "" + } + return base +} + +func isGenericMediaCategoryFolder(name string) bool { + key := strings.ToLower(strings.TrimSpace(name)) + key = strings.Trim(key, `\/`) + switch key { + case "", + "电影", "movies", "movie", + "电视剧", "剧集", "tv", "shows", "series", + "动漫", "动画", "anime", "bangumi", + "国产剧", "国剧", "大陆剧", "国产电视剧", + "欧美剧", "欧美电视剧", + "日韩剧", "日剧", "韩剧", + "华语电影", "国产电影", "大陆电影", + "外语电影", "欧美电影", "日韩电影", + "动画电影", "动漫电影", + "国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画", "欧美动漫", "欧美动画", "西方动画", + "综艺", "真人秀", + "纪录片", "纪录", + "儿童", "少儿", + "成人", "番号", "9kg", + "未分类", "uncategorized": + return true + default: + return false + } +} + +func strictSeasonFolder(name string) int { + if season, ok := seasonFromDir(name); ok { + return season + } + return 0 +} + +func strictSeasonFolderMatched(name string) bool { + _, ok := seasonFromDir(name) + return ok +}