mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06: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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
if refreshed, _ := svc.Repo.Media.FindByID(applyCtx, mediaID); refreshed != nil {
|
||||||
media = refreshed
|
media = refreshed
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ func manualScrapeApplyBatchHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
errorsOut = append(errorsOut, id+": "+err.Error())
|
errorsOut = append(errorsOut, id+": "+err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reclassifyMediaAfterScrape(applyCtx, svc, id)
|
reclassifyMediaAfterScrapeWithTypeHints(applyCtx, svc, map[string]string{id: req.Match.MediaType}, id)
|
||||||
applied++
|
applied++
|
||||||
}
|
}
|
||||||
if applied == 0 && len(errorsOut) > 0 {
|
if applied == 0 && len(errorsOut) > 0 {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"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 {
|
func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req createLibraryReq
|
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) {
|
func TestListMediaGroupsMultipleVersionsByDefault(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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
|
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 {
|
type mediaListResponse struct {
|
||||||
Items []service.MediaItem `json:"items"`
|
Items []service.MediaItem `json:"items"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
|||||||
func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||||
|
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||||
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
||||||
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(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 {
|
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 {
|
if svc == nil || svc.Organizer == nil {
|
||||||
return 0
|
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 err != nil {
|
||||||
if svc.Log != nil {
|
if svc.Log != nil {
|
||||||
svc.Log.Warn("scrape reclassify media failed", zap.Strings("media_ids", mediaIDs), zap.Error(err))
|
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))
|
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
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
favs := map[string]bool{}
|
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||||
if userID != "" && len(rows) > 0 {
|
if len(rows) > limit {
|
||||||
mediaIDs := make([]string, 0, len(rows))
|
rows = rows[:limit]
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out := make([]map[string]any, 0, len(rows))
|
out, err := e.payloadsForMedia(ctx, rows, userID)
|
||||||
for _, m := range rows {
|
if err != nil {
|
||||||
out = append(out, e.itemPayload(ctx, &m, favs[m.ID], 0))
|
return nil, err
|
||||||
}
|
}
|
||||||
if e.cache != nil {
|
if e.cache != nil {
|
||||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
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)
|
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)
|
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}
|
res := &OrganizeResult{SourcePath: sourceRoot, DestPath: baseRoot, DryRun: opts.DryRun}
|
||||||
for i := range rows {
|
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()))
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
||||||
continue
|
continue
|
||||||
} else if changed {
|
} else if changed {
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import (
|
|||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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)
|
mediaType := normalizeOrganizeMediaType(lib.Type)
|
||||||
|
if mediaTypeHint != "" {
|
||||||
|
mediaType = mediaTypeHint
|
||||||
|
}
|
||||||
metadataMatch := organizeMatchFromMedia(&media)
|
metadataMatch := organizeMatchFromMedia(&media)
|
||||||
|
if metadataMatch != nil && mediaTypeHint != "" {
|
||||||
|
metadataMatch.MediaType = mediaTypeHint
|
||||||
|
}
|
||||||
if !mediaHasReliableCategoryMetadata(media) {
|
if !mediaHasReliableCategoryMetadata(media) {
|
||||||
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
||||||
if metadataMatch == nil {
|
if metadataMatch == nil {
|
||||||
@@ -68,6 +74,11 @@ func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, medi
|
|||||||
"library_id": targetLibrary.ID,
|
"library_id": targetLibrary.ID,
|
||||||
"series_id": "",
|
"series_id": "",
|
||||||
}
|
}
|
||||||
|
if normalizeOrganizeMediaType(mediaType) == "movie" {
|
||||||
|
updates["season_num"] = 0
|
||||||
|
updates["episode_num"] = 0
|
||||||
|
updates["episode_title"] = ""
|
||||||
|
}
|
||||||
applyReclassifyMatchUpdates(updates, metadataMatch)
|
applyReclassifyMatchUpdates(updates, metadataMatch)
|
||||||
if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil {
|
if err := o.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", media.ID).Updates(updates).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import (
|
|||||||
// MediaCategoryReclassifyOptions controls the metadata-based category audit.
|
// MediaCategoryReclassifyOptions controls the metadata-based category audit.
|
||||||
// Empty LibraryIDs means all enabled local libraries.
|
// Empty LibraryIDs means all enabled local libraries.
|
||||||
type MediaCategoryReclassifyOptions struct {
|
type MediaCategoryReclassifyOptions struct {
|
||||||
LibraryIDs []string
|
LibraryIDs []string
|
||||||
MediaIDs []string
|
MediaIDs []string
|
||||||
DryRun bool
|
MediaTypeHints map[string]string
|
||||||
|
DryRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReclassifyMisclassifiedMedia corrects already-scanned local media whose
|
// ReclassifyMisclassifiedMedia corrects already-scanned local media whose
|
||||||
@@ -37,6 +38,7 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
|
|||||||
for _, id := range filterIDs {
|
for _, id := range filterIDs {
|
||||||
filter[id] = struct{}{}
|
filter[id] = struct{}{}
|
||||||
}
|
}
|
||||||
|
typeHints := normalizeReclassifyMediaTypeHints(opts.MediaTypeHints)
|
||||||
libByID := make(map[string]model.Library, len(libraries))
|
libByID := make(map[string]model.Library, len(libraries))
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
if !lib.Enabled || strings.TrimSpace(lib.ID) == "" {
|
if !lib.Enabled || strings.TrimSpace(lib.ID) == "" {
|
||||||
@@ -67,7 +69,7 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error()))
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
@@ -89,19 +91,37 @@ func (o *OrganizerService) ReclassifyMisclassifiedMedia(ctx context.Context, opt
|
|||||||
return res, err
|
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) == "" {
|
if res == nil || !lib.Enabled || strings.TrimSpace(media.Path) == "" {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
if mount, ok := ParseCloudLibraryMount(lib.Path); ok {
|
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) {
|
if !organizeFileExists(media.Path) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaType := normalizeOrganizeMediaType(lib.Type)
|
mediaType := normalizeOrganizeMediaType(lib.Type)
|
||||||
|
if mediaTypeHint != "" {
|
||||||
|
mediaType = mediaTypeHint
|
||||||
|
}
|
||||||
metadataMatch := organizeMatchFromMedia(&media)
|
metadataMatch := organizeMatchFromMedia(&media)
|
||||||
|
if metadataMatch != nil && mediaTypeHint != "" {
|
||||||
|
metadataMatch.MediaType = mediaTypeHint
|
||||||
|
}
|
||||||
if !mediaHasReliableCategoryMetadata(media) {
|
if !mediaHasReliableCategoryMetadata(media) {
|
||||||
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType)
|
||||||
if metadataMatch == nil {
|
if metadataMatch == nil {
|
||||||
@@ -198,6 +218,11 @@ func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
updates := map[string]any{"library_id": targetLib.ID, "series_id": ""}
|
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)
|
applyReclassifyMatchUpdates(updates, metadataMatch)
|
||||||
if err := o.repo.DB.WithContext(ctx).
|
if err := o.repo.DB.WithContext(ctx).
|
||||||
Model(&model.Media{}).
|
Model(&model.Media{}).
|
||||||
|
|||||||
@@ -262,3 +262,63 @@ func TestReclassifyMisclassifiedMediaRetriesNoMatchMetadata(t *testing.T) {
|
|||||||
t.Fatalf("row after metadata retry = %#v, want domestic matched metadata", got)
|
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 {
|
if len(match.Languages) > 0 {
|
||||||
updates["languages"] = strings.Join(match.Languages, ",")
|
updates["languages"] = strings.Join(match.Languages, ",")
|
||||||
}
|
}
|
||||||
|
applyScrapeMediaTypeResets(updates, match)
|
||||||
|
|
||||||
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
||||||
Updates(updates).Error; err != nil {
|
Updates(updates).Error; err != nil {
|
||||||
@@ -188,3 +189,28 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
|||||||
})
|
})
|
||||||
return nil
|
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),
|
.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) =>
|
create: (name: string, path: string, type: string) =>
|
||||||
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,24 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryID) return
|
if (!libraryID) return
|
||||||
libraryAPI.list().then((all) => {
|
let cancelled = false
|
||||||
const lib = all.find((item) => item.id === libraryID) ?? null
|
setLoading(true)
|
||||||
setLibrary(lib)
|
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])
|
}, [libraryID])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user