mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 12:26:36 +08:00
fix issue 51 library reclassify regressions
This commit is contained in:
@@ -93,30 +93,24 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
}
|
||||
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
|
||||
}
|
||||
rowLimit := limit * 4
|
||||
if rowLimit < 100 {
|
||||
rowLimit = 100
|
||||
}
|
||||
if rowLimit > 500 {
|
||||
rowLimit = 500
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := q.Order("media.created_at desc").Limit(limit).Find(&rows).Error; err != nil {
|
||||
if err := q.Order("media.created_at desc").Limit(rowLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
favs := map[string]bool{}
|
||||
if userID != "" && len(rows) > 0 {
|
||||
mediaIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(row.ID) != "" {
|
||||
mediaIDs = append(mediaIDs, row.ID)
|
||||
}
|
||||
}
|
||||
if len(mediaIDs) == 0 {
|
||||
mediaIDs = []string{"__none__"}
|
||||
}
|
||||
var fr []model.Favorite
|
||||
_ = e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, mediaIDs).Find(&fr).Error
|
||||
for _, f := range fr {
|
||||
favs[f.MediaID] = true
|
||||
}
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
if len(rows) > limit {
|
||||
rows = rows[:limit]
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
out = append(out, e.itemPayload(ctx, &m, favs[m.ID], 0))
|
||||
out, err := e.payloadsForMedia(ctx, rows, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
|
||||
@@ -107,3 +107,61 @@ func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) {
|
||||
t.Fatalf("playback should expose local and cloud versions, got %#v", playSources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyLatestItemsCollapsesMovieVersions(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, media := range []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "dune-1080", CreatedAt: now.Add(time.Minute)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Dune",
|
||||
Year: 2021,
|
||||
TMDbID: 438631,
|
||||
Path: `/media/movies/Dune.2021.1080p.mkv`,
|
||||
Width: 1920,
|
||||
SizeBytes: 100,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dune-2160", CreatedAt: now.Add(2 * time.Minute)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Dune",
|
||||
Year: 2021,
|
||||
TMDbID: 438631,
|
||||
Path: `/media/movies/Dune.2021.2160p.mkv`,
|
||||
Width: 3840,
|
||||
SizeBytes: 200,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "matrix", CreatedAt: now},
|
||||
LibraryID: lib.ID,
|
||||
Title: "The Matrix",
|
||||
Year: 1999,
|
||||
TMDbID: 603,
|
||||
Path: `/media/movies/The.Matrix.1999.mkv`,
|
||||
},
|
||||
} {
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
latest, err := svc.LatestItems(t.Context(), "user-1", lib.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("latest items: %v", err)
|
||||
}
|
||||
if len(latest) != 2 {
|
||||
t.Fatalf("latest items = %#v, want Dune collapsed plus Matrix", latest)
|
||||
}
|
||||
if latest[0]["Id"] != "dune-2160" {
|
||||
t.Fatalf("latest first item = %#v, want best Dune version", latest[0])
|
||||
}
|
||||
sources := latest[0]["MediaSources"].([]map[string]any)
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("collapsed latest item should expose both versions, got %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,3 +417,56 @@ func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) {
|
||||
t.Fatalf("manual cloud match was not saved: title=%q status=%q tmdb=%d", got.Title, got.ScrapeStatus, got.TMDbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManualMovieMatchClearsEpisodeMarkers(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log))
|
||||
|
||||
lib := model.Library{Name: "欧美剧", Path: "/media/tv/euus", Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "错误剧集标题",
|
||||
Path: "/media/tv/euus/Dune/Season 01/Dune - S01E202.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 202,
|
||||
EpisodeTitle: "第 202 集",
|
||||
SeriesID: "series:wrong",
|
||||
TMDbID: 999999,
|
||||
TheTVDBID: "888",
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
|
||||
Source: "manual",
|
||||
MediaType: "movie",
|
||||
Title: "Dune",
|
||||
Year: 2021,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.SeasonNum != 0 || got.EpisodeNum != 0 || got.EpisodeTitle != "" || got.SeriesID != "" {
|
||||
t.Fatalf("episode markers were not cleared: %#v", got)
|
||||
}
|
||||
if got.TMDbID != 0 || got.TheTVDBID != "" {
|
||||
t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (o *OrganizerService) OrganizeLibraryWithOptions(ctx context.Context, libra
|
||||
}
|
||||
res := &OrganizeResult{SourcePath: sourceRoot, DestPath: baseRoot, DryRun: opts.DryRun}
|
||||
for i := range rows {
|
||||
if changed, err := o.reclassifyScannedMedia(ctx, rows[i], *lib, opts.DryRun, res); err != nil {
|
||||
if changed, err := o.reclassifyScannedMedia(ctx, rows[i], *lib, "", opts.DryRun, res); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
||||
continue
|
||||
} else if changed {
|
||||
|
||||
@@ -9,9 +9,15 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, dryRun bool, res *OrganizeResult) (bool, error) {
|
||||
func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, mediaTypeHint string, dryRun bool, res *OrganizeResult) (bool, error) {
|
||||
mediaType := normalizeOrganizeMediaType(lib.Type)
|
||||
if mediaTypeHint != "" {
|
||||
mediaType = mediaTypeHint
|
||||
}
|
||||
metadataMatch := organizeMatchFromMedia(&media)
|
||||
if metadataMatch != nil && mediaTypeHint != "" {
|
||||
metadataMatch.MediaType = mediaTypeHint
|
||||
}
|
||||
if !mediaHasReliableCategoryMetadata(media) {
|
||||
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
||||
if metadataMatch == nil {
|
||||
@@ -68,6 +74,11 @@ func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, medi
|
||||
"library_id": targetLibrary.ID,
|
||||
"series_id": "",
|
||||
}
|
||||
if normalizeOrganizeMediaType(mediaType) == "movie" {
|
||||
updates["season_num"] = 0
|
||||
updates["episode_num"] = 0
|
||||
updates["episode_title"] = ""
|
||||
}
|
||||
applyReclassifyMatchUpdates(updates, metadataMatch)
|
||||
if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -15,9 +15,10 @@ import (
|
||||
// MediaCategoryReclassifyOptions controls the metadata-based category audit.
|
||||
// Empty LibraryIDs means all enabled local libraries.
|
||||
type MediaCategoryReclassifyOptions struct {
|
||||
LibraryIDs []string
|
||||
MediaIDs []string
|
||||
DryRun bool
|
||||
LibraryIDs []string
|
||||
MediaIDs []string
|
||||
MediaTypeHints map[string]string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// ReclassifyMisclassifiedMedia corrects already-scanned local media whose
|
||||
@@ -37,6 +38,7 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
|
||||
for _, id := range filterIDs {
|
||||
filter[id] = struct{}{}
|
||||
}
|
||||
typeHints := normalizeReclassifyMediaTypeHints(opts.MediaTypeHints)
|
||||
libByID := make(map[string]model.Library, len(libraries))
|
||||
for _, lib := range libraries {
|
||||
if !lib.Enabled || strings.TrimSpace(lib.ID) == "" {
|
||||
@@ -67,7 +69,7 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changed, err := o.reclassifyScannedMedia(ctx, rows[i], lib, opts.DryRun, res)
|
||||
changed, err := o.reclassifyScannedMedia(ctx, rows[i], lib, typeHints[rows[i].ID], opts.DryRun, res)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
||||
if o.log != nil {
|
||||
@@ -89,19 +91,37 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media model.Media, lib model.Library, dryRun bool, res *OrganizeResult) (bool, error) {
|
||||
func normalizeReclassifyMediaTypeHints(values map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(values))
|
||||
for id, value := range values {
|
||||
id = strings.TrimSpace(id)
|
||||
mediaType := normalizeOrganizeMediaType(value)
|
||||
if id != "" && mediaType != "" {
|
||||
out[id] = mediaType
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media model.Media, lib model.Library, mediaTypeHint string, dryRun bool, res *OrganizeResult) (bool, error) {
|
||||
if res == nil || !lib.Enabled || strings.TrimSpace(media.Path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
if mount, ok := ParseCloudLibraryMount(lib.Path); ok {
|
||||
return o.reclassifyCloudScannedMedia(ctx, media, lib, mount, dryRun, res)
|
||||
return o.reclassifyCloudScannedMedia(ctx, media, lib, mount, mediaTypeHint, dryRun, res)
|
||||
}
|
||||
if !organizeFileExists(media.Path) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mediaType := normalizeOrganizeMediaType(lib.Type)
|
||||
if mediaTypeHint != "" {
|
||||
mediaType = mediaTypeHint
|
||||
}
|
||||
metadataMatch := organizeMatchFromMedia(&media)
|
||||
if metadataMatch != nil && mediaTypeHint != "" {
|
||||
metadataMatch.MediaType = mediaTypeHint
|
||||
}
|
||||
if !mediaHasReliableCategoryMetadata(media) {
|
||||
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
||||
if metadataMatch == nil {
|
||||
@@ -198,6 +218,11 @@ func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context
|
||||
return true, nil
|
||||
}
|
||||
updates := map[string]any{"library_id": targetLib.ID, "series_id": ""}
|
||||
if normalizeOrganizeMediaType(mediaType) == "movie" {
|
||||
updates["season_num"] = 0
|
||||
updates["episode_num"] = 0
|
||||
updates["episode_title"] = ""
|
||||
}
|
||||
applyReclassifyMatchUpdates(updates, metadataMatch)
|
||||
if err := o.repo.DB.WithContext(ctx).
|
||||
Model(&model.Media{}).
|
||||
|
||||
@@ -262,3 +262,63 @@ func TestReclassifyMisclassifiedMediaRetriesNoMatchMetadata(t *testing.T) {
|
||||
t.Fatalf("row after metadata retry = %#v, want domestic matched metadata", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclassifyMisclassifiedMediaHonorsManualMovieHint(t *testing.T) {
|
||||
repos := newOrganizerTestRepo(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.Organizer.SmartClassify = true
|
||||
|
||||
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, "Dune", "Season 01", "Dune - S01E202.mkv")
|
||||
writeOrgFile(t, wrongPath, "movie")
|
||||
media := model.Media{
|
||||
LibraryID: euusLib.ID,
|
||||
Title: "Dune",
|
||||
OriginalName: "Dune",
|
||||
Path: wrongPath,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 202,
|
||||
TMDbID: 438631,
|
||||
Languages: "en",
|
||||
Countries: "US",
|
||||
Genres: "科幻,冒险",
|
||||
Year: 2021,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||
res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{
|
||||
MediaIDs: []string{media.ID},
|
||||
MediaTypeHints: map[string]string{media.ID: "movie"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reclassify media: %v", err)
|
||||
}
|
||||
want := filepath.Join(foreignMovieLib.Path, "Dune (2021)", "Dune (2021).mkv")
|
||||
if res.Reclassified != 1 {
|
||||
t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors)
|
||||
}
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("manual movie reclassify target missing at %q: %v", want, err)
|
||||
}
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LibraryID != foreignMovieLib.ID || got.Path != want || got.SeasonNum != 0 || got.EpisodeNum != 0 || got.SeriesID != "" {
|
||||
t.Fatalf("row after manual movie hint = %#v, want foreign movie library with episode markers cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
if len(match.Languages) > 0 {
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
}
|
||||
applyScrapeMediaTypeResets(updates, match)
|
||||
|
||||
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Updates(updates).Error; err != nil {
|
||||
@@ -188,3 +189,28 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyScrapeMediaTypeResets(updates map[string]any, match *Match) {
|
||||
if updates == nil || match == nil {
|
||||
return
|
||||
}
|
||||
switch normalizeOrganizeMediaType(match.MediaType) {
|
||||
case "movie", "adult":
|
||||
updates["season_num"] = 0
|
||||
updates["episode_num"] = 0
|
||||
updates["episode_title"] = ""
|
||||
updates["series_id"] = ""
|
||||
if match.TMDbID <= 0 {
|
||||
updates["tm_db_id"] = 0
|
||||
}
|
||||
if match.BangumiID <= 0 {
|
||||
updates["bangumi_id"] = 0
|
||||
}
|
||||
if strings.TrimSpace(match.DoubanID) == "" {
|
||||
updates["douban_id"] = ""
|
||||
}
|
||||
if strings.TrimSpace(match.TheTVDBID) == "" {
|
||||
updates["thetvdb_id"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user