diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go index 542d6a5..398e4ae 100644 --- a/internal/service/subscription_availability.go +++ b/internal/service/subscription_availability.go @@ -19,13 +19,13 @@ func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, s if sub != nil { out.TotalEpisodes = sub.TotalEpisodes } - query := availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)) - if query == "" { + queries := subscriptionAvailabilityQueries(sub) + if len(queries) == 0 { return s.finalizePendingAvailability(sub, out) } root := s.subscriptionBaseSavePath(ctx, sub) if root != "" { - _ = scanDownloadPath(ctx, root, query, func(path string, season, episode int) bool { + _ = scanDownloadPathAny(ctx, root, queries, func(path string, season, episode int) bool { out.LocalMediaCount++ if refs := episodeRefsFromTitle(path); len(refs) > 0 { for _, ref := range refs { @@ -37,8 +37,8 @@ func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, s return true }) } - s.addDownloadTaskAvailability(ctx, sub, query, &out) - s.addLiveTorrentAvailability(ctx, query, &out) + s.addDownloadTaskAvailability(ctx, sub, queries, &out) + s.addLiveTorrentAvailability(ctx, queries, &out) return s.finalizePendingAvailability(sub, out) } @@ -58,7 +58,7 @@ func (s *SubscriptionService) EnrichProgress(ctx context.Context, items []model. } } -func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, query string, out *LocalAvailability) { +func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, queries []string, out *LocalAvailability) { if s == nil || s.repo == nil || s.repo.Download == nil || out == nil { return } @@ -82,7 +82,7 @@ func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, s addTrustedAvailabilityTitle(row.Title, 0, 0, false, out) continue } - addAvailabilityTitle(row.Title, query, out) + addAvailabilityTitleAny(row.Title, queries, out) } } @@ -93,7 +93,7 @@ func (s *SubscriptionService) downloadTaskCountsAsPending(ctx context.Context, r return s.downloads.subscriptionDownloadTaskStillLive(ctx, row) } -func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, query string, out *LocalAvailability) { +func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, queries []string, out *LocalAvailability) { if s == nil || s.downloads == nil || s.downloads.qb == nil || out == nil { return } @@ -102,7 +102,7 @@ func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, qu return } for _, torrent := range live { - addAvailabilityTitle(torrent.Name, query, out) + addAvailabilityTitleAny(torrent.Name, queries, out) } } @@ -110,7 +110,7 @@ func addAvailabilityTitle(title, query string, out *LocalAvailability) { if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" { return } - if !strings.Contains(normalizeAvailabilityComparable(title), normalizeAvailabilityComparable(query)) { + if !availabilityTitleMatchesAny(title, []string{query}) { return } out.LocalMediaCount++ @@ -128,6 +128,30 @@ func addAvailabilityTitle(title, query string, out *LocalAvailability) { } } +func addAvailabilityTitleAny(title string, queries []string, out *LocalAvailability) { + if !availabilityTitleMatchesAny(title, queries) { + return + } + addTrustedAvailabilityTitle(title, 0, 0, false, out) +} + +func availabilityTitleMatchesAny(title string, queries []string) bool { + titleKey := normalizeAvailabilityComparable(title) + if titleKey == "" { + return false + } + for _, query := range queries { + queryKey := normalizeAvailabilityComparable(query) + if queryKey == "" { + continue + } + if strings.Contains(titleKey, queryKey) { + return true + } + } + return false +} + func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *LocalAvailability) { addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out) } @@ -232,6 +256,20 @@ func subscriptionFilter(sub *model.Subscription) string { return sub.Filter } +func subscriptionAvailabilityQueries(sub *model.Subscription) []string { + if sub == nil { + return nil + } + values := []string{availabilityQuery(subscriptionName(sub), subscriptionFilter(sub))} + for _, keyword := range siteSearchKeywords(sub) { + values = append(values, cleanAvailabilityTitle(keyword)) + } + if original := cleanAvailabilityTitle(sub.OriginalName); original != "" { + values = append(values, original) + } + return compactUniqueStrings(values...) +} + func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { out := LocalAvailability{ ExistingEpisodeKeys: map[string]struct{}{}, @@ -343,6 +381,10 @@ func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub } func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error { + return scanDownloadPathAny(ctx, root, []string{query}, visit) +} + +func scanDownloadPathAny(ctx context.Context, root string, queries []string, visit func(path string, season, episode int) bool) error { if strings.TrimSpace(root) == "" { return nil } @@ -350,8 +392,7 @@ func scanDownloadPath(ctx context.Context, root, query string, visit func(path s if err != nil || !info.IsDir() { return nil } - normalizedQuery := normalizeAvailabilityComparable(query) - if normalizedQuery == "" { + if len(queries) == 0 { return nil } visited := 0 @@ -375,7 +416,7 @@ func scanDownloadPath(ctx context.Context, root, query string, visit func(path s if visited > 10000 { return filepath.SkipAll } - if !strings.Contains(normalizeAvailabilityComparable(path), normalizedQuery) { + if !availabilityTitleMatchesAny(path, queries) { return nil } season, episode := ParseEpisode(path) diff --git a/internal/service/subscription_pending_availability_test.go b/internal/service/subscription_pending_availability_test.go index 4f6d393..5bb621f 100644 --- a/internal/service/subscription_pending_availability_test.go +++ b/internal/service/subscription_pending_availability_test.go @@ -1,6 +1,8 @@ package service import ( + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -105,6 +107,89 @@ func TestSubscriptionPendingDownloadAvailabilityIncludesQueuedTasks(t *testing.T } } +func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForTasks(t *testing.T) { + db := newServiceTestDB(t, &model.DownloadTask{}) + repos := repository.New(db) + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + Source: "qbittorrent", + URL: "https://pt/download/7-8", + Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", + SavePath: "/downloads/tv", + Status: "queued", + }); err != nil { + t.Fatal(err) + } + svc := NewSubscriptionService(nil, nil, repos, nil, nil, nil) + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + Filter: "南部档案 2026", + OriginalName: "Archives The Nanyang Mystery", + MediaType: "tv", + SavePath: "/downloads/tv", + TotalEpisodes: 33, + } + + availability := svc.pendingDownloadAvailability(t.Context(), sub) + if availability.DownloadedEpisodes != 2 { + t.Fatalf("downloaded episodes = %d, want 2", availability.DownloadedEpisodes) + } + for _, episode := range []int{7, 8} { + if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { + t.Fatalf("missing pending E%02d key: %#v", episode, availability.ExistingEpisodeKeys) + } + } + got := selectSiteSearchCandidates([]SearchResult{ + {Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/7-8", Seeders: 80}, + }, sub, map[string]struct{}{}, availability) + if len(got) != 0 { + t.Fatalf("selected %#v, want existing alias range to satisfy E07-E08", got) + } +} + +func TestSubscriptionPendingDownloadAvailabilityUsesOriginalNameAliasForLiveTorrents(t *testing.T) { + qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/auth/login": + _, _ = w.Write([]byte("Ok.")) + case "/api/v2/torrents/info": + _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL","save_path":"/downloads/tv","state":"downloading","progress":0.3}]`)) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) + repos := repository.New(db) + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + downloads.qb.Configure(QBitConfig{BaseURL: qb.URL, Username: "admin", Password: "admin"}) + svc := NewSubscriptionService(nil, nil, repos, downloads, nil, nil) + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + Filter: "南部档案 2026", + OriginalName: "Archives The Nanyang Mystery", + MediaType: "tv", + SavePath: "/downloads/tv", + TotalEpisodes: 33, + } + + availability := svc.pendingDownloadAvailability(t.Context(), sub) + if availability.DownloadedEpisodes != 5 { + t.Fatalf("downloaded episodes = %d, want 5", availability.DownloadedEpisodes) + } + for _, episode := range []int{29, 30, 31, 32, 33} { + if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { + t.Fatalf("missing live E%02d key: %#v", episode, availability.ExistingEpisodeKeys) + } + } + got := selectSiteSearchCandidates([]SearchResult{ + {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 80}, + }, sub, map[string]struct{}{}, availability) + if len(got) != 0 { + t.Fatalf("selected %#v, want existing live alias range to satisfy E29-E33", got) + } +} + func TestSubscriptionPendingDownloadAvailabilityIncludesLinkedAliasTask(t *testing.T) { db := newServiceTestDB(t, &model.DownloadTask{}) repos := repository.New(db)