mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
511 lines
16 KiB
Go
511 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
var (
|
|
classifierEpisodeRE = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,3}\b|第\s*\d+\s*[集期]|(?:^|[\s._-])E\d{1,3}(?:[\s._-]|$)`)
|
|
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
|
|
classifierJAVCodeRE = regexp.MustCompile(`(?:^|[\s._\-/\[\]()])[A-Z]{2,6}[-_]?\d{3,5}(?:[\s._\-/\[\]()]|$)`)
|
|
)
|
|
|
|
const DownloadSmartClassifySettingKey = "downloads.smart_classify"
|
|
|
|
type mediaClassifyInput struct {
|
|
MediaType string
|
|
Title string
|
|
Languages []string
|
|
Countries []string
|
|
Genres []string
|
|
Category string
|
|
}
|
|
|
|
func classifyMediaCategory(input mediaClassifyInput, categories map[string]string) string {
|
|
mediaType := normalizeMediaType(input.MediaType, input.Title, input.Category)
|
|
genres := normalizeTokens(input.Genres...)
|
|
countries := normalizeTokens(input.Countries...)
|
|
languages := normalizeTokens(input.Languages...)
|
|
rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")
|
|
text := strings.ToLower(rawText)
|
|
hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0
|
|
|
|
isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO")
|
|
isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫")
|
|
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
|
|
// 动漫的中文译名几乎都是纯汉字(如日本动画「葬送的芙莉莲」),用 containsHan
|
|
// 判中文会把日本动画误判成国漫。动漫只在有元数据或显式中文标记时才算国漫,
|
|
// 否则默认日番(日本动画占绝大多数;未刮削的国漫刮出 origin_country=CN 后仍正确)。
|
|
isChineseAnime := isChineseByMetadata || (!hasMetadata && containsAnyText(text, "华语", "国产", "国漫", "國漫", "国创", "国产动漫", "国产动画"))
|
|
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
|
|
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
|
|
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
|
|
isWesternByMetadata := hasAny(countries,
|
|
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
|
|
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
|
)
|
|
isWestern := isWesternByMetadata
|
|
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
|
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
|
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
|
isAdultText := containsAnyText(text, "adult", "nsfw", "成人", "番号", "jav", "9kg", "uncensored", "无码", "有码") || classifierJAVCodeRE.MatchString(strings.ToUpper(rawText))
|
|
|
|
hasGenre := func(values ...string) bool {
|
|
for _, value := range values {
|
|
if hasAny(genres, strings.ToUpper(value)) {
|
|
return true
|
|
}
|
|
if isDigits(value) {
|
|
continue
|
|
}
|
|
if strings.Contains(text, strings.ToLower(value)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
switch mediaType {
|
|
case "movie":
|
|
if isAdultText {
|
|
return categoryName(categories, "adult", "成人")
|
|
}
|
|
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
|
|
return categoryName(categories, "animation_movie", "动画电影")
|
|
}
|
|
if isChinese {
|
|
return categoryName(categories, "chinese_movie", "华语电影")
|
|
}
|
|
return categoryName(categories, "foreign_movie", "外语电影")
|
|
case "anime":
|
|
if isChineseAnime {
|
|
return categoryName(categories, "cn_anime", "国漫")
|
|
}
|
|
return categoryName(categories, "jp_anime", "日番")
|
|
case "variety":
|
|
return categoryName(categories, "variety", "综艺")
|
|
case "tv":
|
|
if isAdultText {
|
|
return categoryName(categories, "adult", "成人")
|
|
}
|
|
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") || hasVarietyText {
|
|
return categoryName(categories, "variety", "综艺")
|
|
}
|
|
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") || hasDocumentaryText {
|
|
return categoryName(categories, "documentary", "纪录片")
|
|
}
|
|
if hasGenre("10762", "KIDS", "儿童") {
|
|
return categoryName(categories, "children", "儿童")
|
|
}
|
|
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
|
|
if isChineseAnime {
|
|
return categoryName(categories, "cn_anime", "国漫")
|
|
}
|
|
return categoryName(categories, "jp_anime", "日番")
|
|
}
|
|
if isChinese {
|
|
return categoryName(categories, "domestic_tv", "国产剧")
|
|
}
|
|
if isEastAsian {
|
|
return categoryName(categories, "jk_tv", "日韩剧")
|
|
}
|
|
if isWestern {
|
|
return categoryName(categories, "euus_tv", "欧美剧")
|
|
}
|
|
return categoryName(categories, "uncategorized_tv", "未分类")
|
|
case "adult":
|
|
return categoryName(categories, "adult", "成人")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeMediaType(mediaType, title, category string) string {
|
|
raw := strings.ToLower(strings.TrimSpace(mediaType))
|
|
switch raw {
|
|
case "movie", "film":
|
|
return "movie"
|
|
case "tv", "series", "show", "drama":
|
|
return "tv"
|
|
case "anime", "animation":
|
|
return "anime"
|
|
case "variety":
|
|
return "variety"
|
|
case "adult", "nsfw":
|
|
return "adult"
|
|
}
|
|
switch {
|
|
case containsAnyText(raw, "成人", "番号", "jav", "nsfw"):
|
|
return "adult"
|
|
case containsAnyText(raw, "综艺", "真人秀"):
|
|
return "variety"
|
|
case containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(raw, "动画电影"):
|
|
return "anime"
|
|
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
|
|
return "tv"
|
|
case containsAnyText(raw, "电影", "movie", "film"):
|
|
return "movie"
|
|
}
|
|
text := strings.ToLower(title + " " + category)
|
|
switch {
|
|
case strings.Contains(text, "adult") || strings.Contains(text, "nsfw") || strings.Contains(text, "成人") || strings.Contains(text, "番号") || strings.Contains(text, "jav") || strings.Contains(text, "9kg") || classifierJAVCodeRE.MatchString(strings.ToUpper(title+" "+category)):
|
|
return "adult"
|
|
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
|
return "variety"
|
|
case strings.Contains(text, "movie") || strings.Contains(text, "电影"):
|
|
return "movie"
|
|
case strings.Contains(text, "anime") || strings.Contains(text, "bangumi") || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
|
return "anime"
|
|
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
|
return "variety"
|
|
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || strings.Contains(text, "tv") || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
|
|
return "tv"
|
|
default:
|
|
return "movie"
|
|
}
|
|
}
|
|
|
|
func normalizeTokens(values ...string) map[string]struct{} {
|
|
out := make(map[string]struct{})
|
|
for _, value := range values {
|
|
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
|
return r == ',' || r == '/' || r == '|' || r == ';'
|
|
}) {
|
|
part = strings.ToUpper(strings.TrimSpace(part))
|
|
if part != "" {
|
|
out[part] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hasAny(values map[string]struct{}, needles ...string) bool {
|
|
for _, needle := range needles {
|
|
if _, ok := values[strings.ToUpper(needle)]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsAnyText(text string, needles ...string) bool {
|
|
for _, needle := range needles {
|
|
if strings.Contains(text, strings.ToLower(needle)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsHan(text string) bool {
|
|
for _, r := range text {
|
|
if r >= '\u4e00' && r <= '\u9fff' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsJapaneseKana(text string) bool {
|
|
for _, r := range text {
|
|
if (r >= '\u3040' && r <= '\u30ff') || (r >= '\u31f0' && r <= '\u31ff') {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsKoreanHangul(text string) bool {
|
|
for _, r := range text {
|
|
if (r >= '\uac00' && r <= '\ud7af') || (r >= '\u1100' && r <= '\u11ff') || (r >= '\u3130' && r <= '\u318f') {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsLatin(text string) bool {
|
|
for _, r := range text {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isDigits(text string) bool {
|
|
if text == "" {
|
|
return false
|
|
}
|
|
for _, r := range text {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func categoryName(categories map[string]string, key, fallback string) string {
|
|
if categories != nil {
|
|
if name := strings.TrimSpace(categories[key]); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (o *OrganizerService) categoryMap() map[string]string {
|
|
if o == nil || o.cfg == nil || o.cfg.Organizer.Categories == nil {
|
|
return nil
|
|
}
|
|
return o.cfg.Organizer.Categories
|
|
}
|
|
|
|
func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, mediaType string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
if m.Languages == "" && m.Countries == "" && m.Genres == "" && o != nil && o.repo != nil && o.repo.Media != nil {
|
|
if fresh, err := o.repo.Media.FindByID(ctx, m.ID); err == nil && fresh != nil {
|
|
m = fresh
|
|
}
|
|
}
|
|
return classifyMediaCategory(mediaClassifyInput{
|
|
MediaType: mediaType,
|
|
Title: m.Title + " " + m.OriginalName,
|
|
Languages: parseCommaList(m.Languages),
|
|
Countries: parseCommaList(m.Countries),
|
|
Genres: parseCommaList(m.Genres),
|
|
}, o.categoryMap())
|
|
}
|
|
|
|
func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
|
|
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
|
|
category := strings.TrimSpace(sub.MediaCategory)
|
|
if category == "" {
|
|
if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil {
|
|
category = classifyMediaCategory(mediaClassifyInput{
|
|
MediaType: mediaType,
|
|
Title: match.Title + " " + match.OriginalName,
|
|
Languages: match.Languages,
|
|
Countries: match.Countries,
|
|
Genres: match.Genres,
|
|
Category: sourceCategory,
|
|
}, s.categoryMap())
|
|
if s != nil && s.log != nil && category != "" {
|
|
s.log.Info("subscription metadata classified",
|
|
zap.String("title", title),
|
|
zap.String("matched_title", match.Title),
|
|
zap.String("media_type", mediaType),
|
|
zap.String("media_category", category),
|
|
zap.Int("tmdb_id", match.TMDbID),
|
|
zap.Int("bangumi_id", match.BangumiID),
|
|
zap.String("douban_id", match.DoubanID),
|
|
zap.String("thetvdb_id", match.TheTVDBID))
|
|
}
|
|
}
|
|
}
|
|
if category == "" {
|
|
category = classifyMediaCategory(mediaClassifyInput{
|
|
MediaType: mediaType,
|
|
Title: title + " " + sub.Name + " " + sub.Filter,
|
|
Category: sourceCategory,
|
|
}, s.categoryMap())
|
|
}
|
|
return mediaType, category
|
|
}
|
|
|
|
func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match {
|
|
if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() {
|
|
return nil
|
|
}
|
|
queries := subscriptionMetadataQueries(title, sub)
|
|
if len(queries) == 0 {
|
|
return nil
|
|
}
|
|
for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) {
|
|
lib := &model.Library{Type: libType, Enabled: true}
|
|
for _, query := range queries {
|
|
cleaned, year := CleanQuery(query)
|
|
if cleaned == "" {
|
|
cleaned = strings.TrimSpace(query)
|
|
}
|
|
for _, candidate := range titleCandidates(cleaned) {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
match := s.scraper.lookup(ctx, lib, nil, candidate, year)
|
|
if match == nil || strings.TrimSpace(match.Title) == "" {
|
|
continue
|
|
}
|
|
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
|
continue
|
|
}
|
|
return match
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func subscriptionMetadataQueries(title string, sub *model.Subscription) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, 3)
|
|
add := func(value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return
|
|
}
|
|
key := strings.ToLower(value)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
add(title)
|
|
if sub != nil {
|
|
add(sub.Filter)
|
|
add(sub.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func subscriptionMetadataLibraryTypes(mediaType, title string) []string {
|
|
switch normalizeMediaType(mediaType, title, "") {
|
|
case "movie":
|
|
return []string{"movie"}
|
|
case "anime":
|
|
return []string{"anime", "tv"}
|
|
case "tv", "variety":
|
|
return []string{"tv", "anime"}
|
|
default:
|
|
if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) {
|
|
return []string{"tv", "anime"}
|
|
}
|
|
return []string{"movie", "tv", "anime"}
|
|
}
|
|
}
|
|
|
|
func (s *SubscriptionService) categoryMap() map[string]string {
|
|
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
|
|
return nil
|
|
}
|
|
return s.cfg.Organizer.Categories
|
|
}
|
|
|
|
func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string {
|
|
if sub == nil {
|
|
return ""
|
|
}
|
|
base := strings.TrimSpace(sub.SavePath)
|
|
if base == "" {
|
|
base = downloadDefaultSaveRoot(ctx, s.repo)
|
|
}
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
|
return base
|
|
}
|
|
return downloadSavePathCategoryRoot(base, sanitizeFilename(category))
|
|
}
|
|
|
|
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
|
if s != nil && s.repo != nil && s.repo.Setting != nil {
|
|
val, err := s.repo.Setting.Get(ctx, DownloadSmartClassifySettingKey)
|
|
if err == nil && val != "" {
|
|
return parseBoolSetting(val, true)
|
|
}
|
|
val, err = s.repo.Setting.Get(ctx, "organizer.smart_classify")
|
|
if err == nil && parseBoolSetting(val, false) {
|
|
return true
|
|
}
|
|
}
|
|
if s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify {
|
|
return true
|
|
}
|
|
return true
|
|
}
|
|
|
|
func downloadDefaultSaveRoot(ctx context.Context, repo *repository.Container) string {
|
|
if repo != nil && repo.Setting != nil {
|
|
if base, _ := repo.Setting.Get(ctx, "qbittorrent.savepath"); strings.TrimSpace(base) != "" {
|
|
return strings.TrimSpace(base)
|
|
}
|
|
}
|
|
for _, key := range []string{"MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "MEDIASTATION_DOWNLOAD_DIR"} {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func downloadSmartClassifyEnabled(ctx context.Context, repo *repository.Container, organizer *OrganizerService) bool {
|
|
if repo != nil && repo.Setting != nil {
|
|
val, err := repo.Setting.Get(ctx, DownloadSmartClassifySettingKey)
|
|
if err == nil && val != "" {
|
|
return parseBoolSetting(val, true)
|
|
}
|
|
val, err = repo.Setting.Get(ctx, "organizer.smart_classify")
|
|
if err == nil && parseBoolSetting(val, false) {
|
|
return true
|
|
}
|
|
}
|
|
if organizer != nil && organizer.cfg != nil && organizer.cfg.Organizer.SmartClassify {
|
|
return true
|
|
}
|
|
return true
|
|
}
|
|
|
|
func downloadCategoryMap(organizer *OrganizerService) map[string]string {
|
|
if organizer == nil {
|
|
return nil
|
|
}
|
|
return organizer.categoryMap()
|
|
}
|
|
|
|
func downloadSavePathCategoryRoot(root, category string) string {
|
|
root = strings.TrimSpace(root)
|
|
category = strings.TrimSpace(category)
|
|
if root == "" || category == "" {
|
|
return root
|
|
}
|
|
if isWindowsStyleClientPath(root) {
|
|
cleanRoot := strings.ReplaceAll(root, "/", `\`)
|
|
cleanRoot = strings.TrimRight(cleanRoot, `\`)
|
|
if windowsPathBaseEqual(cleanRoot, category) {
|
|
return cleanRoot
|
|
}
|
|
return cleanRoot + `\` + category
|
|
}
|
|
return categoryRoot(root, category)
|
|
}
|
|
|
|
func isWindowsStyleClientPath(path string) bool {
|
|
path = strings.TrimSpace(path)
|
|
return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') ||
|
|
strings.HasPrefix(path, `\\`)
|
|
}
|
|
|
|
func windowsPathBaseEqual(path, base string) bool {
|
|
path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`)
|
|
base = strings.Trim(strings.TrimSpace(base), `\/`)
|
|
if path == "" || base == "" {
|
|
return false
|
|
}
|
|
idx := strings.LastIndex(path, `\`)
|
|
if idx >= 0 {
|
|
path = path[idx+1:]
|
|
}
|
|
return strings.EqualFold(path, base)
|
|
}
|