mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix issue 51 library reclassify regressions
This commit is contained in:
@@ -67,7 +67,7 @@ func manualScrapeApplyOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
reclassifyMediaAfterScrape(applyCtx, svc, mediaID)
|
||||
reclassifyMediaAfterScrapeWithTypeHints(applyCtx, svc, map[string]string{mediaID: req.MediaType}, mediaID)
|
||||
if refreshed, _ := svc.Repo.Media.FindByID(applyCtx, mediaID); refreshed != nil {
|
||||
media = refreshed
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func manualScrapeApplyBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
errorsOut = append(errorsOut, id+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
reclassifyMediaAfterScrape(applyCtx, svc, id)
|
||||
reclassifyMediaAfterScrapeWithTypeHints(applyCtx, svc, map[string]string{id: req.Match.MediaType}, id)
|
||||
applied++
|
||||
}
|
||||
if applied == 0 && len(errorsOut) > 0 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if lib == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib})
|
||||
if len(libs) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
|
||||
if includeHidden {
|
||||
c.JSON(http.StatusOK, service.NormalizeCloudLibraryDisplayNames(libs)[0])
|
||||
return
|
||||
}
|
||||
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
|
||||
if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, libs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req createLibraryReq
|
||||
|
||||
@@ -95,6 +95,35 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLibraryAllowsEmptyLibrary(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "空媒体库", Path: "/media/empty", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
|
||||
got := requestLibrary(t, svc, "user-1", "user", "/api/libraries/"+lib.ID, lib.ID)
|
||||
if got.ID != lib.ID || got.Name != "空媒体库" {
|
||||
t.Fatalf("library detail = %#v, want empty library detail", got)
|
||||
}
|
||||
media := requestMediaList(t, svc, "/api/libraries/"+lib.ID+"/media", lib.ID)
|
||||
if media.Total != 0 || len(media.Items) != 0 {
|
||||
t.Fatalf("empty library media = %#v, want no items", media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaGroupsMultipleVersionsByDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -252,6 +281,25 @@ func requestLibraries(t *testing.T, svc *service.Container, userID, role, path s
|
||||
return libs
|
||||
}
|
||||
|
||||
func requestLibrary(t *testing.T, svc *service.Container, userID, role, path, libraryID string) model.Library {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Set(middleware.CtxUserRole, role)
|
||||
c.Params = gin.Params{{Key: "id", Value: libraryID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
getLibraryHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
var lib model.Library
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &lib); err != nil {
|
||||
t.Fatalf("decode library: %v", err)
|
||||
}
|
||||
return lib
|
||||
}
|
||||
|
||||
type mediaListResponse struct {
|
||||
Items []service.MediaItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
|
||||
@@ -23,6 +23,7 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
||||
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
|
||||
|
||||
@@ -9,10 +9,17 @@ import (
|
||||
)
|
||||
|
||||
func reclassifyMediaAfterScrape(ctx context.Context, svc *service.Container, mediaIDs ...string) int {
|
||||
return reclassifyMediaAfterScrapeWithTypeHints(ctx, svc, nil, mediaIDs...)
|
||||
}
|
||||
|
||||
func reclassifyMediaAfterScrapeWithTypeHints(ctx context.Context, svc *service.Container, mediaTypeHints map[string]string, mediaIDs ...string) int {
|
||||
if svc == nil || svc.Organizer == nil {
|
||||
return 0
|
||||
}
|
||||
res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{MediaIDs: mediaIDs})
|
||||
res, err := svc.Organizer.ReclassifyMisclassifiedMedia(ctx, service.MediaCategoryReclassifyOptions{
|
||||
MediaIDs: mediaIDs,
|
||||
MediaTypeHints: mediaTypeHints,
|
||||
})
|
||||
if err != nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("scrape reclassify media failed", zap.Strings("media_ids", mediaIDs), zap.Error(err))
|
||||
|
||||
@@ -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"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ export const libraryAPI = {
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
get: (id: string, options?: { includeHidden?: boolean }) =>
|
||||
api
|
||||
.get<Library>(`/libraries/${id}`, {
|
||||
params: options?.includeHidden ? { include_hidden: 1 } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
create: (name: string, path: string, type: string) =>
|
||||
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -27,10 +27,24 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID) return
|
||||
libraryAPI.list().then((all) => {
|
||||
const lib = all.find((item) => item.id === libraryID) ?? null
|
||||
setLibrary(lib)
|
||||
})
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLibrary(null)
|
||||
setItems([])
|
||||
setServerSeriesCards([])
|
||||
setSeriesEpisodeItems([])
|
||||
libraryAPI.get(libraryID)
|
||||
.then((lib) => {
|
||||
if (!cancelled) setLibrary(lib)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setLibrary(null)
|
||||
setLoading(false)
|
||||
toast.error('媒体库不存在或无权限')
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user