mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
refactor media classifier helpers
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -2,13 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -195,170 +192,6 @@ func normalizeMediaType(mediaType, title, category string) string {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type sourceCategoryHintDef struct {
|
||||
Key string
|
||||
Fallback string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
var sourceCategoryHints = []sourceCategoryHintDef{
|
||||
{Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"},
|
||||
{Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"},
|
||||
{Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"},
|
||||
{Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"},
|
||||
{Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"},
|
||||
{Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"},
|
||||
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"},
|
||||
{Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv"},
|
||||
{Key: "cn_anime", Fallback: "国漫", MediaType: "anime"},
|
||||
{Key: "jp_anime", Fallback: "日番", MediaType: "anime"},
|
||||
{Key: "euus_anime", Fallback: "欧美动漫", MediaType: "anime"},
|
||||
{Key: "variety", Fallback: "综艺", MediaType: "variety"},
|
||||
{Key: "documentary", Fallback: "纪录片", MediaType: "tv"},
|
||||
{Key: "children", Fallback: "儿童", MediaType: "tv"},
|
||||
{Key: "adult", Fallback: "成人", MediaType: "adult"},
|
||||
{Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"},
|
||||
{Key: "adult_jav", Fallback: "番号", MediaType: "adult"},
|
||||
}
|
||||
|
||||
func sourceCategoryHint(category, mediaType string, categories map[string]string) string {
|
||||
tokens := sourceCategoryTokens(category)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, hint := range sourceCategoryHints {
|
||||
if !sourceCategoryCompatible(mediaType, hint.MediaType) {
|
||||
continue
|
||||
}
|
||||
for _, name := range []string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)} {
|
||||
if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||
return categoryName(categories, hint.Key, hint.Fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sourceCategoryTokens(category string) map[string]struct{} {
|
||||
category = strings.TrimSpace(category)
|
||||
if category == "" {
|
||||
return nil
|
||||
}
|
||||
normalized := strings.NewReplacer("\\", " ", "/", " ", "|", " ", ",", " ", ";", " ").Replace(category)
|
||||
out := map[string]struct{}{
|
||||
strings.ToLower(category): {},
|
||||
}
|
||||
for _, field := range strings.Fields(normalized) {
|
||||
out[strings.ToLower(strings.TrimSpace(field))] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceCategoryCompatible(mediaType, categoryMediaType string) bool {
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
categoryMediaType = strings.ToLower(strings.TrimSpace(categoryMediaType))
|
||||
if mediaType == "" || categoryMediaType == "" || mediaType == categoryMediaType {
|
||||
return true
|
||||
}
|
||||
if mediaType == "tv" && (categoryMediaType == "anime" || categoryMediaType == "variety") {
|
||||
return true
|
||||
}
|
||||
if categoryMediaType == "adult" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *OrganizerService) categoryMap() map[string]string {
|
||||
if o == nil || o.cfg == nil || o.cfg.Organizer.Categories == nil {
|
||||
return nil
|
||||
@@ -383,239 +216,3 @@ func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, me
|
||||
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 {
|
||||
if strings.TrimSpace(mediaType) == "" {
|
||||
text := strings.ToLower(title)
|
||||
switch {
|
||||
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text):
|
||||
return []string{"tv", "anime", "movie"}
|
||||
case containsAnyText(text, "动漫", "动画", "anime", "bangumi"):
|
||||
return []string{"anime", "tv", "movie"}
|
||||
case containsAnyText(text, "电影", "movie", "film"):
|
||||
return []string{"movie", "tv", "anime"}
|
||||
default:
|
||||
return []string{"tv", "movie", "anime"}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
type sourceCategoryHintDef struct {
|
||||
Key string
|
||||
Fallback string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
var sourceCategoryHints = []sourceCategoryHintDef{
|
||||
{Key: "animation_movie", Fallback: "动画电影", MediaType: "movie"},
|
||||
{Key: "chinese_movie", Fallback: "华语电影", MediaType: "movie"},
|
||||
{Key: "jk_movie", Fallback: "日韩电影", MediaType: "movie"},
|
||||
{Key: "euus_movie", Fallback: "欧美电影", MediaType: "movie"},
|
||||
{Key: "foreign_movie", Fallback: "外语电影", MediaType: "movie"},
|
||||
{Key: "domestic_tv", Fallback: "国产剧", MediaType: "tv"},
|
||||
{Key: "euus_tv", Fallback: "欧美剧", MediaType: "tv"},
|
||||
{Key: "jk_tv", Fallback: "日韩剧", MediaType: "tv"},
|
||||
{Key: "cn_anime", Fallback: "国漫", MediaType: "anime"},
|
||||
{Key: "jp_anime", Fallback: "日番", MediaType: "anime"},
|
||||
{Key: "euus_anime", Fallback: "欧美动漫", MediaType: "anime"},
|
||||
{Key: "variety", Fallback: "综艺", MediaType: "variety"},
|
||||
{Key: "documentary", Fallback: "纪录片", MediaType: "tv"},
|
||||
{Key: "children", Fallback: "儿童", MediaType: "tv"},
|
||||
{Key: "adult", Fallback: "成人", MediaType: "adult"},
|
||||
{Key: "adult_9kg", Fallback: "9KG", MediaType: "adult"},
|
||||
{Key: "adult_jav", Fallback: "番号", MediaType: "adult"},
|
||||
}
|
||||
|
||||
func sourceCategoryHint(category, mediaType string, categories map[string]string) string {
|
||||
tokens := sourceCategoryTokens(category)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, hint := range sourceCategoryHints {
|
||||
if !sourceCategoryCompatible(mediaType, hint.MediaType) {
|
||||
continue
|
||||
}
|
||||
for _, name := range []string{hint.Fallback, categoryName(categories, hint.Key, hint.Fallback)} {
|
||||
if _, ok := tokens[strings.ToLower(strings.TrimSpace(name))]; ok {
|
||||
return categoryName(categories, hint.Key, hint.Fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sourceCategoryTokens(category string) map[string]struct{} {
|
||||
category = strings.TrimSpace(category)
|
||||
if category == "" {
|
||||
return nil
|
||||
}
|
||||
normalized := strings.NewReplacer("\\", " ", "/", " ", "|", " ", ",", " ", ";", " ").Replace(category)
|
||||
out := map[string]struct{}{
|
||||
strings.ToLower(category): {},
|
||||
}
|
||||
for _, field := range strings.Fields(normalized) {
|
||||
out[strings.ToLower(strings.TrimSpace(field))] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceCategoryCompatible(mediaType, categoryMediaType string) bool {
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
categoryMediaType = strings.ToLower(strings.TrimSpace(categoryMediaType))
|
||||
if mediaType == "" || categoryMediaType == "" || mediaType == categoryMediaType {
|
||||
return true
|
||||
}
|
||||
if mediaType == "tv" && (categoryMediaType == "anime" || categoryMediaType == "variety") {
|
||||
return true
|
||||
}
|
||||
if categoryMediaType == "adult" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
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 {
|
||||
if strings.TrimSpace(mediaType) == "" {
|
||||
text := strings.ToLower(title)
|
||||
switch {
|
||||
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text):
|
||||
return []string{"tv", "anime", "movie"}
|
||||
case containsAnyText(text, "动漫", "动画", "anime", "bangumi"):
|
||||
return []string{"anime", "tv", "movie"}
|
||||
case containsAnyText(text, "电影", "movie", "film"):
|
||||
return []string{"movie", "tv", "anime"}
|
||||
default:
|
||||
return []string{"tv", "movie", "anime"}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user