mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 04:26:36 +08:00
fix: correct organize media type classification
This commit is contained in:
@@ -228,6 +228,7 @@ func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string
|
|||||||
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
||||||
match := &Match{
|
match := &Match{
|
||||||
OriginalName: code,
|
OriginalName: code,
|
||||||
|
MediaType: "adult",
|
||||||
NSFW: true,
|
NSFW: true,
|
||||||
Genres: []string{"Adult", source},
|
Genres: []string{"Adult", source},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (b *BangumiProvider) Search(ctx context.Context, query string) (*Match, err
|
|||||||
}
|
}
|
||||||
m := &Match{
|
m := &Match{
|
||||||
BangumiID: r.ID,
|
BangumiID: r.ID,
|
||||||
|
MediaType: "anime",
|
||||||
Title: title,
|
Title: title,
|
||||||
OriginalName: r.Name,
|
OriginalName: r.Name,
|
||||||
Overview: r.Summary,
|
Overview: r.Summary,
|
||||||
@@ -139,6 +140,7 @@ func (b *BangumiProvider) GetSubject(ctx context.Context, bangumiID int) (*Match
|
|||||||
}
|
}
|
||||||
m := &Match{
|
m := &Match{
|
||||||
BangumiID: r.ID,
|
BangumiID: r.ID,
|
||||||
|
MediaType: "anime",
|
||||||
Title: title,
|
Title: title,
|
||||||
OriginalName: r.Name,
|
OriginalName: r.Name,
|
||||||
Overview: r.Summary,
|
Overview: r.Summary,
|
||||||
|
|||||||
@@ -120,8 +120,13 @@ func (d *DoubanProvider) SearchMatch(ctx context.Context, query string) (*Match,
|
|||||||
if err != nil || got == nil {
|
if err != nil || got == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
mediaType := ""
|
||||||
|
if strings.TrimSpace(got.Type) != "" {
|
||||||
|
mediaType = normalizeMediaType(got.Type, got.Title, "")
|
||||||
|
}
|
||||||
match := &Match{
|
match := &Match{
|
||||||
DoubanID: got.DoubanID,
|
DoubanID: got.DoubanID,
|
||||||
|
MediaType: mediaType,
|
||||||
Title: got.Title,
|
Title: got.Title,
|
||||||
PosterURL: got.Img,
|
PosterURL: got.Img,
|
||||||
Rating: got.Rating,
|
Rating: got.Rating,
|
||||||
|
|||||||
@@ -450,6 +450,9 @@ func mergeManualRequestIntoMatch(match *Match, req ManualScrapeRequest) *Match {
|
|||||||
if req.Title != "" {
|
if req.Title != "" {
|
||||||
match.Title = req.Title
|
match.Title = req.Title
|
||||||
}
|
}
|
||||||
|
if mediaType := normalizeOrganizeMediaType(req.MediaType); mediaType != "" {
|
||||||
|
match.MediaType = mediaType
|
||||||
|
}
|
||||||
if req.OriginalName != "" {
|
if req.OriginalName != "" {
|
||||||
match.OriginalName = req.OriginalName
|
match.OriginalName = req.OriginalName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ var (
|
|||||||
classifierEpisodeRE = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,3}\b|第\s*\d+\s*[集期]|(?:^|[\s._-])E\d{1,3}(?:[\s._-]|$)`)
|
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*季`)
|
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
|
||||||
classifierJAVCodeRE = regexp.MustCompile(`(?:^|[\s._\-/\[\]()])[A-Z]{2,6}[-_]?\d{3,5}(?:[\s._\-/\[\]()]|$)`)
|
classifierJAVCodeRE = regexp.MustCompile(`(?:^|[\s._\-/\[\]()])[A-Z]{2,6}[-_]?\d{3,5}(?:[\s._\-/\[\]()]|$)`)
|
||||||
|
classifierMovieRE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:movies?|films?)(?:[^a-z0-9]|$)`)
|
||||||
|
classifierTVRE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:tv|series|shows?|dramas?)(?:[^a-z0-9]|$)`)
|
||||||
|
classifierAnimeRE = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(?:anime|bangumi)(?:[^a-z0-9]|$)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const DownloadSmartClassifySettingKey = "downloads.smart_classify"
|
const DownloadSmartClassifySettingKey = "downloads.smart_classify"
|
||||||
@@ -166,11 +169,11 @@ func normalizeMediaType(mediaType, title, category string) string {
|
|||||||
return "adult"
|
return "adult"
|
||||||
case containsAnyText(raw, "综艺", "真人秀"):
|
case containsAnyText(raw, "综艺", "真人秀"):
|
||||||
return "variety"
|
return "variety"
|
||||||
case containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画", "anime", "bangumi") && !containsAnyText(raw, "动画电影"):
|
case (containsAnyText(raw, "国漫", "日漫", "日番", "动漫", "动画") || classifierAnimeRE.MatchString(raw)) && !containsAnyText(raw, "动画电影"):
|
||||||
return "anime"
|
return "anime"
|
||||||
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集", "tv", "series"):
|
case containsAnyText(raw, "电视剧", "国产剧", "欧美剧", "日韩剧", "日剧", "韩剧", "剧集") || classifierTVRE.MatchString(raw):
|
||||||
return "tv"
|
return "tv"
|
||||||
case containsAnyText(raw, "电影", "movie", "film"):
|
case containsAnyText(raw, "电影") || classifierMovieRE.MatchString(raw):
|
||||||
return "movie"
|
return "movie"
|
||||||
}
|
}
|
||||||
text := strings.ToLower(title + " " + category)
|
text := strings.ToLower(title + " " + category)
|
||||||
@@ -179,13 +182,13 @@ func normalizeMediaType(mediaType, title, category string) string {
|
|||||||
return "adult"
|
return "adult"
|
||||||
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
case containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show"):
|
||||||
return "variety"
|
return "variety"
|
||||||
case strings.Contains(text, "movie") || strings.Contains(text, "电影"):
|
case strings.Contains(text, "电影") || classifierMovieRE.MatchString(text):
|
||||||
return "movie"
|
return "movie"
|
||||||
case strings.Contains(text, "anime") || strings.Contains(text, "bangumi") || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
case classifierAnimeRE.MatchString(text) || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
||||||
return "anime"
|
return "anime"
|
||||||
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
||||||
return "variety"
|
return "variety"
|
||||||
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || strings.Contains(text, "tv") || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
|
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || classifierTVRE.MatchString(text) || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
|
||||||
return "tv"
|
return "tv"
|
||||||
default:
|
default:
|
||||||
return "movie"
|
return "movie"
|
||||||
|
|||||||
@@ -281,6 +281,25 @@ func TestNormalizeMediaTypeAcceptsChineseLibraryTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMediaTypeDoesNotTreatReleaseTokensAsTV(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"They Will Kill You 2026 1080p HDTV x264",
|
||||||
|
"Some Movie 2026 2160p AppleTV WEB-DL",
|
||||||
|
"Some Movie 2026 2160p ATVP WEB-DL",
|
||||||
|
}
|
||||||
|
for _, input := range tests {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
if got := normalizeMediaType("", input, ""); got != "movie" {
|
||||||
|
t.Fatalf("normalizeMediaType(%q) = %q, want movie", input, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := normalizeMediaType("", "The Last of Us", `F:\media\tv\The Last of Us`); got != "tv" {
|
||||||
|
t.Fatalf("standalone tv path token = %q, want tv", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
|
func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
|
||||||
db := newServiceTestDB(t, &model.Setting{})
|
db := newServiceTestDB(t, &model.Setting{})
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
|
func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
|
||||||
@@ -130,6 +131,174 @@ func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryMovieMetadataOverridesWrongTVFolder(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/search/movie":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 1292695,
|
||||||
|
"title": "杀的就是你",
|
||||||
|
"original_title": "They Will Kill You",
|
||||||
|
"original_language": "en",
|
||||||
|
"genre_ids": []int{27, 53},
|
||||||
|
"release_date": "2026-01-16",
|
||||||
|
"vote_average": 6.3,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case r.URL.Path == "/search/tv":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 1198994,
|
||||||
|
"name": "请求救援",
|
||||||
|
"original_name": "They Will Kill You",
|
||||||
|
"original_language": "en",
|
||||||
|
"origin_country": []string{"US"},
|
||||||
|
"genre_ids": []int{18},
|
||||||
|
"first_air_date": "2026-01-01",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcRoot := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(srcRoot, "欧美剧", "They.Will.Kill.You.2026.1080p.HDTV.x264-HiDt.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "movie")
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: srcRoot,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电影", "外语电影", "杀的就是你 (2026)", "杀的就是你 (2026).mkv")
|
||||||
|
if res.Organized != 1 || res.Reclassified != 0 {
|
||||||
|
t.Fatalf("result = %+v, want organized movie only; paths=%v", res, paths)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("movie in wrong TV folder should organize as movie at %q: %v; items=%#v paths=%v", want, err, res.Items, paths)
|
||||||
|
}
|
||||||
|
if len(paths) == 0 || paths[0] != "/search/movie" {
|
||||||
|
t.Fatalf("first metadata search path = %q, want /search/movie; all=%v", firstQuery(paths), paths)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].MediaType != "movie" || res.Items[0].Category != "外语电影" {
|
||||||
|
t.Fatalf("organize item = %#v, want movie/外语电影", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryReclassifiesMovieFromDirtyGeneratedEpisodePath(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/search/movie":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 1198994,
|
||||||
|
"title": "请求救援",
|
||||||
|
"original_title": "Request Rescue",
|
||||||
|
"original_language": "en",
|
||||||
|
"genre_ids": []int{28, 53},
|
||||||
|
"release_date": "2026-02-01",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case r.URL.Path == "/search/tv":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true}
|
||||||
|
foreignMovieLib := model.Library{Name: "外语电影", Path: filepath.Join(dest, "电影", "外语电影"), Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &euusLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.Library.Create(t.Context(), &foreignMovieLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongPath := filepath.Join(euusLib.Path, "请求救援 (2026)", "Season 1", "请求救援 - S01E202-1080p - 第 202 集.mkv")
|
||||||
|
writeOrgFile(t, wrongPath, "movie")
|
||||||
|
if err := repos.DB.Create(&model.Media{
|
||||||
|
LibraryID: euusLib.ID,
|
||||||
|
Title: "请求救援",
|
||||||
|
OriginalName: "请求救援",
|
||||||
|
Path: wrongPath,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 202,
|
||||||
|
TMDbID: 1198994,
|
||||||
|
Year: 2026,
|
||||||
|
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||||
|
BackdropURL: "https://image.tmdb.org/t/p/w1280/backdrop.jpg",
|
||||||
|
Overview: "movie overview",
|
||||||
|
ScrapeStatus: "matched",
|
||||||
|
Container: "mkv",
|
||||||
|
SizeBytes: int64(len("movie")),
|
||||||
|
DurationSec: 7200,
|
||||||
|
VideoCodec: "h264",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: filepath.Dir(filepath.Dir(wrongPath)),
|
||||||
|
DestPath: euusLib.Path,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize dirty generated path: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(foreignMovieLib.Path, "请求救援 (2026)", "请求救援 (2026).mkv")
|
||||||
|
if res.Reclassified != 1 || res.Organized != 0 {
|
||||||
|
t.Fatalf("result = %+v, want one reclassified movie", res)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(wrongPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("wrong generated episode path should be moved away, stat err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("movie target missing at %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "path = ?", want).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.LibraryID != foreignMovieLib.ID || got.SeasonNum != 0 || got.EpisodeNum != 0 {
|
||||||
|
t.Fatalf("row after reclassify = library %q S%dE%d, want movie lib with cleared episode numbers", got.LibraryID, got.SeasonNum, got.EpisodeNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrganizeDirectoryRejectedMetadataKeepsExplicitWesternSourceCategory(t *testing.T) {
|
func TestOrganizeDirectoryRejectedMetadataKeepsExplicitWesternSourceCategory(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, cache map[string]*Match) *Match {
|
func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, cache map[string]*Match) *Match {
|
||||||
seriesLike := isSeriesLibraryType(mediaType) || season > 0 || episode > 0
|
normalizedType := normalizeOrganizeMediaType(mediaType)
|
||||||
|
lookupSeason, lookupEpisode := season, episode
|
||||||
|
if normalizedType == "movie" {
|
||||||
|
lookupSeason, lookupEpisode = 0, 0
|
||||||
|
}
|
||||||
|
seriesLike := isSeriesLibraryType(mediaType) || (normalizedType != "movie" && (season > 0 || episode > 0))
|
||||||
if local, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && local != nil {
|
if local, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && local != nil {
|
||||||
if match := organizeMatchFromLocalMetadata(local); match != nil {
|
if match := organizeMatchFromLocalMetadata(local); match != nil {
|
||||||
return match
|
return match
|
||||||
@@ -35,8 +40,8 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
|||||||
Title: title,
|
Title: title,
|
||||||
Year: year,
|
Year: year,
|
||||||
Path: src,
|
Path: src,
|
||||||
SeasonNum: season,
|
SeasonNum: lookupSeason,
|
||||||
EpisodeNum: episode,
|
EpisodeNum: lookupEpisode,
|
||||||
}
|
}
|
||||||
for _, candidate := range scrapeQueryCandidates(media, lib) {
|
for _, candidate := range scrapeQueryCandidates(media, lib) {
|
||||||
key := organizeMetadataCacheKey(lib.Type, candidate, year)
|
key := organizeMetadataCacheKey(lib.Type, candidate, year)
|
||||||
@@ -59,6 +64,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
|||||||
zap.String("source", src),
|
zap.String("source", src),
|
||||||
zap.String("query", candidate),
|
zap.String("query", candidate),
|
||||||
zap.String("title", match.Title),
|
zap.String("title", match.Title),
|
||||||
|
zap.String("media_type", match.MediaType),
|
||||||
zap.Int("source_year", year),
|
zap.Int("source_year", year),
|
||||||
zap.Int("match_year", match.Year),
|
zap.Int("match_year", match.Year),
|
||||||
zap.Int("tmdb_id", match.TMDbID),
|
zap.Int("tmdb_id", match.TMDbID),
|
||||||
@@ -76,6 +82,7 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
|||||||
zap.String("source", src),
|
zap.String("source", src),
|
||||||
zap.String("query", candidate),
|
zap.String("query", candidate),
|
||||||
zap.String("title", match.Title),
|
zap.String("title", match.Title),
|
||||||
|
zap.String("media_type", match.MediaType),
|
||||||
zap.Int("year", match.Year),
|
zap.Int("year", match.Year),
|
||||||
zap.Int("tmdb_id", match.TMDbID),
|
zap.Int("tmdb_id", match.TMDbID),
|
||||||
zap.Int("bangumi_id", match.BangumiID),
|
zap.Int("bangumi_id", match.BangumiID),
|
||||||
|
|||||||
@@ -210,10 +210,15 @@ func (o *OrganizerService) updateReclassifiedMediaRow(ctx context.Context, oldPa
|
|||||||
if req.Year > 0 {
|
if req.Year > 0 {
|
||||||
updates["year"] = req.Year
|
updates["year"] = req.Year
|
||||||
}
|
}
|
||||||
if req.Season > 0 {
|
if normalizeOrganizeMediaType(req.MediaType) == "movie" {
|
||||||
|
updates["season_num"] = 0
|
||||||
|
updates["episode_num"] = 0
|
||||||
|
} else if req.Season > 0 {
|
||||||
updates["season_num"] = req.Season
|
updates["season_num"] = req.Season
|
||||||
}
|
if req.Episode > 0 {
|
||||||
if req.Episode > 0 {
|
updates["episode_num"] = req.Episode
|
||||||
|
}
|
||||||
|
} else if req.Episode > 0 {
|
||||||
updates["episode_num"] = req.Episode
|
updates["episode_num"] = req.Episode
|
||||||
}
|
}
|
||||||
return o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", oldPath).Updates(updates).Error
|
return o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("path = ?", oldPath).Updates(updates).Error
|
||||||
|
|||||||
@@ -80,7 +80,27 @@ func (o *OrganizerService) buildOrganizeSourceFilePlan(ctx context.Context, req
|
|||||||
forcedType := normalizeOrganizeMediaType(req.MediaTypeOverride)
|
forcedType := normalizeOrganizeMediaType(req.MediaTypeOverride)
|
||||||
inferredType := o.inferMediaTypeForSourceFile(req.Source, identity.Title, identity.Season, identity.Episode)
|
inferredType := o.inferMediaTypeForSourceFile(req.Source, identity.Title, identity.Season, identity.Episode)
|
||||||
layout = applyOrganizeSourceMediaType(layout, forcedType, inferredType)
|
layout = applyOrganizeSourceMediaType(layout, forcedType, inferredType)
|
||||||
metadataMatch := o.lookupOrganizeSourceMetadata(ctx, req, layout.MediaType, &identity)
|
lookupType := organizeSourceMetadataLookupType(req.Source, pathLayout, layout, forcedType, inferredType, identity)
|
||||||
|
metadataMatch := o.lookupOrganizeSourceMetadata(ctx, req, lookupType, &identity)
|
||||||
|
if metadataMatch == nil && lookupType != layout.MediaType {
|
||||||
|
metadataMatch = o.lookupOrganizeSourceMetadata(ctx, req, layout.MediaType, &identity)
|
||||||
|
}
|
||||||
|
if forcedType == "" && metadataMatch != nil {
|
||||||
|
if matchType := normalizeOrganizeMediaType(metadataMatch.MediaType); matchType != "" && matchType != layout.MediaType {
|
||||||
|
if categoryType, _ := o.mediaTypeForDirectoryCategory(layout.Category); categoryType != "" && !sourceCategoryCompatible(matchType, categoryType) {
|
||||||
|
layout.Category = ""
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Info("organize media type corrected by metadata",
|
||||||
|
zap.String("source", req.Source),
|
||||||
|
zap.String("from", layout.MediaType),
|
||||||
|
zap.String("to", matchType),
|
||||||
|
zap.String("title", metadataMatch.Title),
|
||||||
|
zap.Int("tmdb_id", metadataMatch.TMDbID))
|
||||||
|
}
|
||||||
|
layout.MediaType = matchType
|
||||||
|
}
|
||||||
|
}
|
||||||
layout = o.applyOrganizeSourceCategory(ctx, req, pathLayout, layout, forcedType, identity, metadataMatch)
|
layout = o.applyOrganizeSourceCategory(ctx, req, pathLayout, layout, forcedType, identity, metadataMatch)
|
||||||
layoutRoot, targetLibraryID := o.resolveOrganizeSourceLayoutRoot(ctx, req, layout)
|
layoutRoot, targetLibraryID := o.resolveOrganizeSourceLayoutRoot(ctx, req, layout)
|
||||||
target, err := o.buildOrganizeSourceTarget(ctx, req, layout, layoutRoot, identity)
|
target, err := o.buildOrganizeSourceTarget(ctx, req, layout, layoutRoot, identity)
|
||||||
@@ -164,6 +184,47 @@ func applyOrganizeSourceMediaType(layout organizeDirectoryLayout, forcedType, in
|
|||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func organizeSourceMetadataLookupType(src string, pathLayout, layout organizeDirectoryLayout, forcedType, inferredType string, identity organizeSourceIdentity) string {
|
||||||
|
if forcedType != "" {
|
||||||
|
return layout.MediaType
|
||||||
|
}
|
||||||
|
if pathLayout.MediaType != "" && pathLayout.MediaType != "movie" && organizeStandaloneMovieSourceHint(src, identity) {
|
||||||
|
return "movie"
|
||||||
|
}
|
||||||
|
return layout.MediaType
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizeStandaloneMovieSourceHint(src string, identity organizeSourceIdentity) bool {
|
||||||
|
if identity.Season > 0 || identity.Episode > 0 {
|
||||||
|
return organizeEpisodeLooksSourcedFromMovieYear(src, identity)
|
||||||
|
}
|
||||||
|
_, year := CleanQuery(filepath.Base(src))
|
||||||
|
if year <= 0 {
|
||||||
|
year = identity.Year
|
||||||
|
}
|
||||||
|
if year <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.ToLower(strings.Join([]string{filepath.Base(src), identity.Title, identity.ParsedTitle}, " "))
|
||||||
|
return !classifierEpisodeRE.MatchString(text) && !classifierSeasonRE.MatchString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizeEpisodeLooksSourcedFromMovieYear(src string, identity organizeSourceIdentity) bool {
|
||||||
|
year := identity.Year
|
||||||
|
if identity.SourceMedia != nil && identity.SourceMedia.Year > 0 {
|
||||||
|
year = identity.SourceMedia.Year
|
||||||
|
}
|
||||||
|
if year < 1900 || year > 2099 || identity.Season != 1 || identity.Episode != year/10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
showDir := showDirFromEpisodePath(src)
|
||||||
|
if showDir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, folderYear := CleanQuery(filepath.Base(showDir))
|
||||||
|
return folderYear == year
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OrganizerService) lookupOrganizeSourceMetadata(ctx context.Context, req organizeSourceFileRequest, mediaType string, identity *organizeSourceIdentity) *Match {
|
func (o *OrganizerService) lookupOrganizeSourceMetadata(ctx context.Context, req organizeSourceFileRequest, mediaType string, identity *organizeSourceIdentity) *Match {
|
||||||
match := o.lookupOrganizeMetadata(ctx, req.Source, req.SourceRoot, mediaType, identity.Title, identity.Year, identity.Season, identity.Episode, req.MetadataCache)
|
match := o.lookupOrganizeMetadata(ctx, req.Source, req.SourceRoot, mediaType, identity.Title, identity.Year, identity.Season, identity.Episode, req.MetadataCache)
|
||||||
if match == nil && identity.SourceMedia != nil {
|
if match == nil && identity.SourceMedia != nil {
|
||||||
|
|||||||
@@ -145,9 +145,14 @@ func (o *OrganizerService) applyOrganizeMedia(ctx context.Context, req organizeM
|
|||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"path": dst.path,
|
"path": dst.path,
|
||||||
"season_num": m.SeasonNum,
|
}
|
||||||
"episode_num": m.EpisodeNum,
|
if normalizeOrganizeMediaType(dst.mediaType) == "movie" {
|
||||||
|
updates["season_num"] = 0
|
||||||
|
updates["episode_num"] = 0
|
||||||
|
} else {
|
||||||
|
updates["season_num"] = m.SeasonNum
|
||||||
|
updates["episode_num"] = m.EpisodeNum
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(dst.libraryID) != "" {
|
if strings.TrimSpace(dst.libraryID) != "" {
|
||||||
updates["library_id"] = strings.TrimSpace(dst.libraryID)
|
updates["library_id"] = strings.TrimSpace(dst.libraryID)
|
||||||
|
|||||||
@@ -665,6 +665,11 @@ func (s *ScraperService) determineMediaTypeForMedia(lib *model.Library, media *m
|
|||||||
if media != nil && mediaIsEpisodic(media, lib) {
|
if media != nil && mediaIsEpisodic(media, lib) {
|
||||||
return "tv"
|
return "tv"
|
||||||
}
|
}
|
||||||
|
if match != nil {
|
||||||
|
if mediaType := normalizeOrganizeMediaType(match.MediaType); mediaType != "" {
|
||||||
|
return mediaType
|
||||||
|
}
|
||||||
|
}
|
||||||
if lib != nil {
|
if lib != nil {
|
||||||
switch lib.Type {
|
switch lib.Type {
|
||||||
case "tv", "anime", "variety", "show", "shows":
|
case "tv", "anime", "variety", "show", "shows":
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
|
|||||||
r := p.Data[0]
|
r := p.Data[0]
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TheTVDBID: r.ID,
|
TheTVDBID: r.ID,
|
||||||
|
MediaType: "tv",
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
Overview: r.Overview,
|
Overview: r.Overview,
|
||||||
PosterURL: r.Image,
|
PosterURL: r.Image,
|
||||||
@@ -240,6 +241,7 @@ func (t *TheTVDBProvider) GetSeriesMatchByID(ctx context.Context, seriesID strin
|
|||||||
}
|
}
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TheTVDBID: idValue,
|
TheTVDBID: idValue,
|
||||||
|
MediaType: "tv",
|
||||||
Title: out.Data.Name,
|
Title: out.Data.Name,
|
||||||
Overview: out.Data.Overview,
|
Overview: out.Data.Overview,
|
||||||
PosterURL: firstNonEmpty(out.Data.Image, out.Data.ImageURL),
|
PosterURL: firstNonEmpty(out.Data.Image, out.Data.ImageURL),
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ type Match struct {
|
|||||||
BangumiID int `json:"bangumi_id"`
|
BangumiID int `json:"bangumi_id"`
|
||||||
DoubanID string `json:"douban_id,omitempty"`
|
DoubanID string `json:"douban_id,omitempty"`
|
||||||
TheTVDBID string `json:"thetvdb_id,omitempty"`
|
TheTVDBID string `json:"thetvdb_id,omitempty"`
|
||||||
|
MediaType string `json:"media_type,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
OriginalName string `json:"original_name,omitempty"`
|
OriginalName string `json:"original_name,omitempty"`
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
@@ -195,6 +196,7 @@ func (t *TMDbProvider) GetMovieMatch(ctx context.Context, tmdbID int) (*Match, e
|
|||||||
}
|
}
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
|
MediaType: "movie",
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
OriginalName: r.OriginalTitle,
|
OriginalName: r.OriginalTitle,
|
||||||
Overview: r.Overview,
|
Overview: r.Overview,
|
||||||
@@ -264,6 +266,7 @@ func (t *TMDbProvider) GetTVMatch(ctx context.Context, tmdbID int) (*Match, erro
|
|||||||
}
|
}
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
|
MediaType: "tv",
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
OriginalName: r.OriginalName,
|
OriginalName: r.OriginalName,
|
||||||
Overview: r.Overview,
|
Overview: r.Overview,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func (t *TMDbProvider) SearchMovieCandidates(ctx context.Context, query string,
|
|||||||
func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match {
|
func (t *TMDbProvider) movieSearchResultToMatch(r tmdbMovieSearchResult) *Match {
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
|
MediaType: "movie",
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
OriginalName: r.OriginalTitle,
|
OriginalName: r.OriginalTitle,
|
||||||
Overview: r.Overview,
|
Overview: r.Overview,
|
||||||
@@ -161,6 +162,7 @@ func (t *TMDbProvider) SearchTVCandidates(ctx context.Context, query string, yea
|
|||||||
func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
|
func (t *TMDbProvider) tvSearchResultToMatch(r tmdbTVSearchResult) *Match {
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
|
MediaType: "tv",
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
OriginalName: r.OriginalName,
|
OriginalName: r.OriginalName,
|
||||||
Overview: r.Overview,
|
Overview: r.Overview,
|
||||||
|
|||||||
Reference in New Issue
Block a user