From bdc3a169f73fb6e6e6ea827ff75e2ac128abf06e Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:36:24 +0800 Subject: [PATCH] fix subscription dedup availability confirmation --- .../service/organizer_reclassify_cloud.go | 175 +++++++++++ .../service/organizer_reclassify_metadata.go | 129 ++++++++ .../service/organizer_reclassify_scanned.go | 284 ------------------ internal/service/subscription.go | 13 +- internal/service/subscription_availability.go | 29 ++ .../service/subscription_availability_test.go | 4 +- ...ubscription_candidate_availability_test.go | 28 ++ .../subscription_site_search_enqueue.go | 22 +- .../subscription_site_search_enqueue_test.go | 4 +- 9 files changed, 394 insertions(+), 294 deletions(-) create mode 100644 internal/service/organizer_reclassify_cloud.go create mode 100644 internal/service/organizer_reclassify_metadata.go diff --git a/internal/service/organizer_reclassify_cloud.go b/internal/service/organizer_reclassify_cloud.go new file mode 100644 index 0000000..9895b2e --- /dev/null +++ b/internal/service/organizer_reclassify_cloud.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "strings" + + "go.uber.org/zap" + + "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) { + mediaType := normalizeOrganizeMediaType(lib.Type) + metadataMatch := organizeMatchFromMedia(&media) + if !mediaHasReliableCategoryMetadata(media) { + metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType) + if metadataMatch == nil { + return false, nil + } + media = mediaWithReclassifyMatch(media, metadataMatch) + } + if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" { + mediaType = matchType + } + category := o.classifyMedia(ctx, &media, mediaType) + if category == "" { + return false, nil + } + if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { + mediaType = impliedType + category = normalizedCategory + } + if mediaType == "" { + mediaType = normalizeOrganizeMediaType(lib.Type) + } + displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category) + if displayDir == "" { + return false, nil + } + if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) { + return false, nil + } + targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun) + if err != nil || !ok { + return false, err + } + if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID { + return false, nil + } + title := sanitizeFilename(strings.TrimSpace(media.Title)) + if title == "" { + title = "Unknown" + } + res.Items = append(res.Items, OrganizePreviewItem{ + Source: media.Path, + Target: targetLibrary.Path, + Action: "reclassify", + Reason: "cloud metadata category library changed", + MediaType: mediaType, + Category: category, + Title: title, + }) + if dryRun { + res.Reclassified++ + return true, nil + } + updates := map[string]any{ + "library_id": targetLibrary.ID, + "series_id": "", + } + 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 + } + if o.log != nil { + o.log.Info("cloud media library reclassified by metadata", + zap.String("media", media.ID), + zap.String("path", media.Path), + zap.String("from_library", lib.ID), + zap.String("to_library", targetLibrary.ID), + zap.String("category", category), + zap.String("media_type", mediaType), + zap.String("display_dir", displayDir)) + } + res.Reclassified++ + return true, nil +} + +func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string { + category = sanitizeFilename(strings.TrimSpace(category)) + if category == "" { + return "" + } + root := o.mediaTypeRootDirForCategory(mediaType, category) + if root == "" { + return "" + } + return strings.Join([]string{root, category}, "/") +} + +func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) { + if o == nil || o.repo == nil || o.repo.Library == nil { + return model.Library{}, false, nil + } + provider = strings.TrimSpace(provider) + displayDir = normalizeCloudMountDir(provider, displayDir) + if provider == "" || displayDir == "" { + return model.Library{}, false, nil + } + if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { + return *existing, true, nil + } + path := BuildCloudAutoCategoryLibraryPath(provider, displayDir) + if path == "" { + return model.Library{}, false, nil + } + name := cloudMountDirBase(displayDir) + if name == "" { + name = displayDir + } + libType := InferCloudMountMediaType(displayDir, name) + if normalizeOrganizeMediaType(libType) == "" { + libType = organizeLibraryModelType(mediaType) + } + lib := model.Library{ + Name: name, + Path: path, + Type: libType, + Enabled: true, + } + if dryRun { + return lib, true, nil + } + if err := o.repo.Library.Create(ctx, &lib); err != nil { + if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { + return *existing, true, nil + } + return model.Library{}, false, err + } + if o.log != nil { + o.log.Info("created cloud metadata reclassify library", + zap.String("library_id", lib.ID), + zap.String("provider", provider), + zap.String("display_dir", displayDir), + zap.String("type", lib.Type)) + } + return lib, true, nil +} + +func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library { + if o == nil || o.repo == nil || o.repo.Library == nil { + return nil + } + libs, err := o.repo.Library.List(ctx) + if err != nil { + if o.log != nil { + o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err)) + } + return nil + } + displayDir = normalizeCloudMountDir(provider, displayDir) + for _, lib := range libs { + if !lib.Enabled { + continue + } + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok || info.Provider != provider { + continue + } + if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir { + return &lib + } + } + return nil +} diff --git a/internal/service/organizer_reclassify_metadata.go b/internal/service/organizer_reclassify_metadata.go new file mode 100644 index 0000000..1a6e034 --- /dev/null +++ b/internal/service/organizer_reclassify_metadata.go @@ -0,0 +1,129 @@ +package service + +import ( + "context" + "strings" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (o *OrganizerService) lookupReclassifyMetadata(ctx context.Context, media model.Media, lib model.Library, mediaType string) *Match { + if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() { + return nil + } + title := strings.TrimSpace(media.Title) + if title == "" { + title, _ = CleanQuery(media.Path) + } + for _, typ := range reclassifyMetadataLookupTypes(mediaType, media) { + if match := o.lookupOrganizeMetadata(ctx, media.Path, lib.Path, typ, title, media.Year, media.SeasonNum, media.EpisodeNum, nil); match != nil { + if o.log != nil { + o.log.Info("metadata category reclassify filled missing metadata", + zap.String("media", media.ID), + zap.String("path", media.Path), + zap.String("title", match.Title), + zap.String("media_type", typ), + zap.Int("tmdb_id", match.TMDbID), + zap.Int("bangumi_id", match.BangumiID), + zap.String("douban_id", match.DoubanID), + zap.String("thetvdb_id", match.TheTVDBID)) + } + return match + } + } + return nil +} + +func reclassifyMetadataLookupTypes(mediaType string, media model.Media) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 3) + add := func(value string) { + value = normalizeOrganizeMediaType(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + out = append(out, value) + } + add(mediaType) + if media.SeasonNum > 0 || media.EpisodeNum > 0 { + add("tv") + add("anime") + } + switch normalizeOrganizeMediaType(mediaType) { + case "tv": + add("anime") + add("movie") + case "anime": + add("tv") + add("movie") + case "movie", "": + add("tv") + add("anime") + add("movie") + default: + add("tv") + add("movie") + } + return out +} + +func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media { + if match == nil { + return media + } + if value := strings.TrimSpace(match.Title); value != "" { + media.Title = value + } + if value := strings.TrimSpace(match.OriginalName); value != "" { + media.OriginalName = value + } + if match.Year > 0 { + media.Year = match.Year + } + if match.TMDbID > 0 { + media.TMDbID = match.TMDbID + } + if match.BangumiID > 0 { + media.BangumiID = match.BangumiID + } + if value := strings.TrimSpace(match.DoubanID); value != "" { + media.DoubanID = value + } + if value := strings.TrimSpace(match.TheTVDBID); value != "" { + media.TheTVDBID = value + } + if len(match.Languages) > 0 { + media.Languages = strings.Join(match.Languages, ",") + } + if len(match.Countries) > 0 { + media.Countries = strings.Join(match.Countries, ",") + } + if len(match.Genres) > 0 { + media.Genres = strings.Join(match.Genres, ",") + } + if match.NSFW { + media.NSFW = true + } + media.ScrapeStatus = "matched" + return media +} + +func metadataMatchMediaType(match *Match) string { + if match == nil { + return "" + } + return match.MediaType +} + +func mediaHasReliableCategoryMetadata(media model.Media) bool { + return media.NSFW || + strings.TrimSpace(media.Languages) != "" || + strings.TrimSpace(media.Countries) != "" || + strings.TrimSpace(media.Genres) != "" +} diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index b7f999c..6b1a90a 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -183,171 +183,6 @@ func (o *OrganizerService) reclassifyScannedMedia(ctx context.Context, media mod }) } -func (o *OrganizerService) reclassifyCloudScannedMedia(ctx context.Context, media model.Media, lib model.Library, mount CloudMountInfo, dryRun bool, res *OrganizeResult) (bool, error) { - mediaType := normalizeOrganizeMediaType(lib.Type) - metadataMatch := organizeMatchFromMedia(&media) - if !mediaHasReliableCategoryMetadata(media) { - metadataMatch = o.lookupReclassifyMetadata(ctx, media, lib, mediaType) - if metadataMatch == nil { - return false, nil - } - media = mediaWithReclassifyMatch(media, metadataMatch) - } - if matchType := normalizeOrganizeMediaType(metadataMatchMediaType(metadataMatch)); matchType != "" { - mediaType = matchType - } - category := o.classifyMedia(ctx, &media, mediaType) - if category == "" { - return false, nil - } - if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(category); impliedType != "" { - mediaType = impliedType - category = normalizedCategory - } - if mediaType == "" { - mediaType = normalizeOrganizeMediaType(lib.Type) - } - displayDir := o.cloudReclassifyCategoryDisplayDir(mediaType, category) - if displayDir == "" { - return false, nil - } - if normalizeCloudMountDir(mount.Provider, mount.DisplayDir) == normalizeCloudMountDir(mount.Provider, displayDir) { - return false, nil - } - targetLibrary, ok, err := o.ensureCloudReclassifyLibrary(ctx, mount.Provider, displayDir, mediaType, dryRun) - if err != nil || !ok { - return false, err - } - if strings.TrimSpace(targetLibrary.ID) != "" && targetLibrary.ID == lib.ID { - return false, nil - } - title := sanitizeFilename(strings.TrimSpace(media.Title)) - if title == "" { - title = "Unknown" - } - res.Items = append(res.Items, OrganizePreviewItem{ - Source: media.Path, - Target: targetLibrary.Path, - Action: "reclassify", - Reason: "cloud metadata category library changed", - MediaType: mediaType, - Category: category, - Title: title, - }) - if dryRun { - res.Reclassified++ - return true, nil - } - updates := map[string]any{ - "library_id": targetLibrary.ID, - "series_id": "", - } - 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 - } - if o.log != nil { - o.log.Info("cloud media library reclassified by metadata", - zap.String("media", media.ID), - zap.String("path", media.Path), - zap.String("from_library", lib.ID), - zap.String("to_library", targetLibrary.ID), - zap.String("category", category), - zap.String("media_type", mediaType), - zap.String("display_dir", displayDir)) - } - res.Reclassified++ - return true, nil -} - -func (o *OrganizerService) cloudReclassifyCategoryDisplayDir(mediaType, category string) string { - category = sanitizeFilename(strings.TrimSpace(category)) - if category == "" { - return "" - } - root := o.mediaTypeRootDirForCategory(mediaType, category) - if root == "" { - return "" - } - return strings.Join([]string{root, category}, "/") -} - -func (o *OrganizerService) ensureCloudReclassifyLibrary(ctx context.Context, provider, displayDir, mediaType string, dryRun bool) (model.Library, bool, error) { - if o == nil || o.repo == nil || o.repo.Library == nil { - return model.Library{}, false, nil - } - provider = strings.TrimSpace(provider) - displayDir = normalizeCloudMountDir(provider, displayDir) - if provider == "" || displayDir == "" { - return model.Library{}, false, nil - } - if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { - return *existing, true, nil - } - path := BuildCloudAutoCategoryLibraryPath(provider, displayDir) - if path == "" { - return model.Library{}, false, nil - } - name := cloudMountDirBase(displayDir) - if name == "" { - name = displayDir - } - libType := InferCloudMountMediaType(displayDir, name) - if normalizeOrganizeMediaType(libType) == "" { - libType = organizeLibraryModelType(mediaType) - } - lib := model.Library{ - Name: name, - Path: path, - Type: libType, - Enabled: true, - } - if dryRun { - return lib, true, nil - } - if err := o.repo.Library.Create(ctx, &lib); err != nil { - if existing := o.findCloudReclassifyLibrary(ctx, provider, displayDir); existing != nil { - return *existing, true, nil - } - return model.Library{}, false, err - } - if o.log != nil { - o.log.Info("created cloud metadata reclassify library", - zap.String("library_id", lib.ID), - zap.String("provider", provider), - zap.String("display_dir", displayDir), - zap.String("type", lib.Type)) - } - return lib, true, nil -} - -func (o *OrganizerService) findCloudReclassifyLibrary(ctx context.Context, provider, displayDir string) *model.Library { - if o == nil || o.repo == nil || o.repo.Library == nil { - return nil - } - libs, err := o.repo.Library.List(ctx) - if err != nil { - if o.log != nil { - o.log.Warn("list cloud libraries for metadata reclassify failed", zap.Error(err)) - } - return nil - } - displayDir = normalizeCloudMountDir(provider, displayDir) - for _, lib := range libs { - if !lib.Enabled { - continue - } - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok || info.Provider != provider { - continue - } - if normalizeCloudMountDir(provider, info.DisplayDir) == displayDir { - return &lib - } - } - return nil -} - func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context, media model.Media, oldLib, targetLib model.Library, category, mediaType string, dryRun bool, res *OrganizeResult, metadataMatch *Match) (bool, error) { res.Items = append(res.Items, OrganizePreviewItem{ Source: media.Path, @@ -382,122 +217,3 @@ func (o *OrganizerService) reclassifyScannedMediaLibraryOnly(ctx context.Context res.Reclassified++ return true, nil } - -func (o *OrganizerService) lookupReclassifyMetadata(ctx context.Context, media model.Media, lib model.Library, mediaType string) *Match { - if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() { - return nil - } - title := strings.TrimSpace(media.Title) - if title == "" { - title, _ = CleanQuery(media.Path) - } - for _, typ := range reclassifyMetadataLookupTypes(mediaType, media) { - if match := o.lookupOrganizeMetadata(ctx, media.Path, lib.Path, typ, title, media.Year, media.SeasonNum, media.EpisodeNum, nil); match != nil { - if o.log != nil { - o.log.Info("metadata category reclassify filled missing metadata", - zap.String("media", media.ID), - zap.String("path", media.Path), - zap.String("title", match.Title), - zap.String("media_type", typ), - zap.Int("tmdb_id", match.TMDbID), - zap.Int("bangumi_id", match.BangumiID), - zap.String("douban_id", match.DoubanID), - zap.String("thetvdb_id", match.TheTVDBID)) - } - return match - } - } - return nil -} - -func reclassifyMetadataLookupTypes(mediaType string, media model.Media) []string { - seen := map[string]struct{}{} - out := make([]string, 0, 3) - add := func(value string) { - value = normalizeOrganizeMediaType(value) - if value == "" { - return - } - if _, ok := seen[value]; ok { - return - } - seen[value] = struct{}{} - out = append(out, value) - } - add(mediaType) - if media.SeasonNum > 0 || media.EpisodeNum > 0 { - add("tv") - add("anime") - } - switch normalizeOrganizeMediaType(mediaType) { - case "tv": - add("anime") - add("movie") - case "anime": - add("tv") - add("movie") - case "movie", "": - add("tv") - add("anime") - add("movie") - default: - add("tv") - add("movie") - } - return out -} - -func mediaWithReclassifyMatch(media model.Media, match *Match) model.Media { - if match == nil { - return media - } - if value := strings.TrimSpace(match.Title); value != "" { - media.Title = value - } - if value := strings.TrimSpace(match.OriginalName); value != "" { - media.OriginalName = value - } - if match.Year > 0 { - media.Year = match.Year - } - if match.TMDbID > 0 { - media.TMDbID = match.TMDbID - } - if match.BangumiID > 0 { - media.BangumiID = match.BangumiID - } - if value := strings.TrimSpace(match.DoubanID); value != "" { - media.DoubanID = value - } - if value := strings.TrimSpace(match.TheTVDBID); value != "" { - media.TheTVDBID = value - } - if len(match.Languages) > 0 { - media.Languages = strings.Join(match.Languages, ",") - } - if len(match.Countries) > 0 { - media.Countries = strings.Join(match.Countries, ",") - } - if len(match.Genres) > 0 { - media.Genres = strings.Join(match.Genres, ",") - } - if match.NSFW { - media.NSFW = true - } - media.ScrapeStatus = "matched" - return media -} - -func metadataMatchMediaType(match *Match) string { - if match == nil { - return "" - } - return match.MediaType -} - -func mediaHasReliableCategoryMetadata(media model.Media) bool { - return media.NSFW || - strings.TrimSpace(media.Languages) != "" || - strings.TrimSpace(media.Countries) != "" || - strings.TrimSpace(media.Genres) != "" -} diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 5209559..f0ae941 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -315,8 +315,17 @@ func (s *SubscriptionService) enqueueRSSSubscriptionCandidate(ctx context.Contex AllowExistingLibrary: sub.WashEnabled, }); err != nil { if IsDownloadDedupError(err) { - state.markCandidateAvailable(candidate) - state.markSeen(candidate.GUID) + if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) { + state.markCandidateAvailable(candidate) + return false + } + if s.log != nil { + s.log.Info("subscription dedup candidate not confirmed available", + zap.String("title", item.Title), + zap.String("media_type", mediaType), + zap.String("media_category", mediaCategory), + zap.String("save_path", savePath)) + } return false } s.log.Warn("subscription enqueue failed", diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go index 398e4ae..2b710f8 100644 --- a/internal/service/subscription_availability.go +++ b/internal/service/subscription_availability.go @@ -156,6 +156,35 @@ func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *Loca addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out) } +func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool { + availability := mergeLocalAvailability( + SubscriptionLocalAvailability(ctx, s.repo, sub), + s.pendingDownloadAvailability(ctx, sub), + ) + return candidateAvailableInAvailability(sub, candidate, availability) +} + +func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool { + mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "") + if !isSubscriptionSeriesType(mediaType) { + return availability.LocalMediaCount > 0 || availability.InLibrary + } + episodes := candidateEpisodeNumbers(candidate) + if len(episodes) == 0 { + return availability.HasSeriesPack + } + season := candidate.Season + if season <= 0 { + season = 1 + } + for _, episode := range episodes { + if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok { + return false + } + } + return true +} + func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) { if out == nil { return diff --git a/internal/service/subscription_availability_test.go b/internal/service/subscription_availability_test.go index 70d495c..8e64432 100644 --- a/internal/service/subscription_availability_test.go +++ b/internal/service/subscription_availability_test.go @@ -286,7 +286,7 @@ func TestSiteSearchDownloadDedupMarksCandidateAvailable(t *testing.T) { t.Fatalf("deduped candidate should mark E%d available: %#v", episode, state.Availability.ExistingEpisodeKeys) } } - if len(state.Seen) != 1 || state.Seen[0] != "site|mteam|nanyang-7-8" { - t.Fatalf("deduped candidate should be marked seen: %#v", state.Seen) + if len(state.Seen) != 0 { + t.Fatalf("deduped candidate should stay retryable instead of being marked seen: %#v", state.Seen) } } diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go index 831fee1..33711d0 100644 --- a/internal/service/subscription_candidate_availability_test.go +++ b/internal/service/subscription_candidate_availability_test.go @@ -264,6 +264,34 @@ func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) { } } +func TestCandidateAvailableInAvailabilityRequiresFullRangeCoverage(t *testing.T) { + sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33} + candidate := siteSearchCandidate{ + Item: SearchResult{ + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + DownloadURL: "https://pt/download/29-33", + }, + Season: 1, + Episode: 29, + Episodes: []int{29, 30, 31, 32, 33}, + Pack: true, + } + availability := LocalAvailability{ + TotalEpisodes: 33, + ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 29): {}, episodeKey(1, 30): {}}, + } + + if candidateAvailableInAvailability(sub, candidate, availability) { + t.Fatal("partial range availability must not confirm a deduped subscription candidate") + } + for episode := 31; episode <= 33; episode++ { + availability.ExistingEpisodeKeys[episodeKey(1, episode)] = struct{}{} + } + if !candidateAvailableInAvailability(sub, candidate, availability) { + t.Fatal("complete range availability should confirm a deduped subscription candidate") + } +} + func TestShouldSkipExistingTorrentKeepsSeriesRangeCandidate(t *testing.T) { svc := &SubscriptionService{downloads: &DownloadService{}} candidate := siteSearchCandidate{ diff --git a/internal/service/subscription_site_search_enqueue.go b/internal/service/subscription_site_search_enqueue.go index 63519dc..e002a10 100644 --- a/internal/service/subscription_site_search_enqueue.go +++ b/internal/service/subscription_site_search_enqueue.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "go.uber.org/zap" @@ -44,6 +45,7 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, matchText, item.Category) if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) { state.markCandidateAvailable(candidate) + state.markCandidateSeen(candidate.GUID) s.logSiteSearchCandidateSkipped(sub, state, candidate, "existing_torrent", mediaType, "", "") return "", nil } @@ -52,6 +54,7 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory) if s.downloadPathHasCandidate(ctx, sub, matchText, savePath) { state.markCandidateAvailable(candidate) + state.markCandidateSeen(candidate.GUID) s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_path_has_candidate", mediaType, mediaCategory, savePath) return "", nil } @@ -68,8 +71,12 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su AllowExistingLibrary: sub.WashEnabled, }); err != nil { if IsDownloadDedupError(err) { - state.markCandidateAvailable(candidate) - s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath) + if s.subscriptionCandidateConfirmedAvailable(ctx, sub, candidate) { + state.markCandidateAvailable(candidate) + s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup", mediaType, mediaCategory, savePath) + return "", nil + } + s.logSiteSearchCandidateSkipped(sub, state, candidate, "download_dedup_unconfirmed", mediaType, mediaCategory, savePath) return "", nil } s.logSiteSearchEnqueueFailed(sub, state, candidate, mediaType, mediaCategory, savePath, err) @@ -77,15 +84,22 @@ func (s *SubscriptionService) enqueueSiteSearchCandidate(ctx context.Context, su } state.markCandidateAvailable(candidate) + state.markCandidateSeen(candidate.GUID) s.logSiteSearchCandidateQueued(sub, state, candidate, mediaType, mediaCategory, savePath) return item.Title, nil } func (state *siteSearchRunState) markCandidateAvailable(candidate siteSearchCandidate) { addSiteSearchCandidateAvailability(candidate, &state.Availability) - state.Seen = append(state.Seen, candidate.GUID) +} + +func (state *siteSearchRunState) markCandidateSeen(guid string) { + if strings.TrimSpace(guid) == "" { + return + } + state.Seen = append(state.Seen, guid) if state.SeenSet != nil { - state.SeenSet[candidate.GUID] = struct{}{} + state.SeenSet[guid] = struct{}{} } } diff --git a/internal/service/subscription_site_search_enqueue_test.go b/internal/service/subscription_site_search_enqueue_test.go index 151a1af..c43e41b 100644 --- a/internal/service/subscription_site_search_enqueue_test.go +++ b/internal/service/subscription_site_search_enqueue_test.go @@ -71,8 +71,8 @@ func TestEnqueueSiteSearchDedupMarksEnglishRangeAvailableForChineseSubscription( t.Fatalf("availability missing E%d after dedup range: %#v", episode, state.Availability.ExistingEpisodeKeys) } } - if _, ok := state.SeenSet[candidate.GUID]; !ok { - t.Fatalf("seen set missing candidate guid after dedup") + if _, ok := state.SeenSet[candidate.GUID]; ok { + t.Fatalf("deduped candidate should not be marked seen before the next availability scan") } }