diff --git a/internal/handler/manual_scrape.go b/internal/handler/manual_scrape.go index 7ed7bfe..d09a657 100644 --- a/internal/handler/manual_scrape.go +++ b/internal/handler/manual_scrape.go @@ -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 { diff --git a/internal/handler/media.go b/internal/handler/media.go index e77b113..f648a6b 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -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 diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 4cb0f09..6874197 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -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"` diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 964b65a..89566b2 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -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)) diff --git a/internal/handler/scrape_reclassify.go b/internal/handler/scrape_reclassify.go index e06dbbf..abfdc2c 100644 --- a/internal/handler/scrape_reclassify.go +++ b/internal/handler/scrape_reclassify.go @@ -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)) diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index b5ddbd1..387dd6a 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -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) diff --git a/internal/service/emby_versions_test.go b/internal/service/emby_versions_test.go index 73874a2..2fb7954 100644 --- a/internal/service/emby_versions_test.go +++ b/internal/service/emby_versions_test.go @@ -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) + } +} diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index b07b3f6..fe83689 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -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) + } +} diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 9da67c0..b162ac7 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -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 { diff --git a/internal/service/organizer_reclassify_cloud.go b/internal/service/organizer_reclassify_cloud.go index 9895b2e..4fb2bbf 100644 --- a/internal/service/organizer_reclassify_cloud.go +++ b/internal/service/organizer_reclassify_cloud.go @@ -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 diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 6b1a90a..c6bce03 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -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{}). diff --git a/internal/service/organizer_reclassify_test.go b/internal/service/organizer_reclassify_test.go index f4296b2..c87fff4 100644 --- a/internal/service/organizer_reclassify_test.go +++ b/internal/service/organizer_reclassify_test.go @@ -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) + } +} diff --git a/internal/service/scraper.go b/internal/service/scraper.go index a13a2da..0ca926a 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -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"] = "" + } + } +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts index e013cb8..8b82e31 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -87,6 +87,13 @@ export const libraryAPI = { }) .then((r) => r.data), + get: (id: string, options?: { includeHidden?: boolean }) => + api + .get(`/libraries/${id}`, { + params: options?.includeHidden ? { include_hidden: 1 } : undefined, + }) + .then((r) => r.data), + create: (name: string, path: string, type: string) => api.post('/libraries', { name, path, type }).then((r) => r.data), diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index 0703655..fb136ca 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -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(() => {