fix issue 51 library reclassify regressions

This commit is contained in:
ShukeBta
2026-06-27 00:40:39 +08:00
parent 4c839b3f4e
commit 8d0aa2b589
15 changed files with 371 additions and 35 deletions
+2 -2
View File
@@ -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 {
+32
View File
@@ -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
+48
View File
@@ -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))
+8 -1
View File
@@ -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))
+14 -20
View File
@@ -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)
+58
View File
@@ -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)
}
}
+53
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 {
+12 -1
View File
@@ -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)
}
}
+26
View File
@@ -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"] = ""
}
}
}
+7
View File
@@ -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),
+18 -4
View File
@@ -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(() => {