diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index a3c2342..87c2090 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -84,7 +84,7 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { return } enriched := []model.Subscription{*s} - svc.Subscription.EnrichProgress(c.Request.Context(), enriched) + svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) *s = enriched[0] c.JSON(http.StatusCreated, s) } @@ -97,7 +97,7 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - svc.Subscription.EnrichProgress(c.Request.Context(), items) + svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...)) c.JSON(http.StatusOK, gin.H{"items": items}) } @@ -110,7 +110,7 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - svc.Subscription.EnrichProgress(c.Request.Context(), items) + svc.Subscription.EnrichManagementProgress(c.Request.Context(), items) c.JSON(http.StatusOK, gin.H{"items": items}) } } @@ -144,7 +144,7 @@ func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc { return } enriched := []model.Subscription{*sub} - svc.Subscription.EnrichProgress(c.Request.Context(), enriched) + svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched) c.JSON(http.StatusOK, enriched[0]) } } diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go index 97aff39..6462aff 100644 --- a/internal/service/subscription_availability.go +++ b/internal/service/subscription_availability.go @@ -45,22 +45,68 @@ func (s *SubscriptionService) EnrichProgress(ctx context.Context, items []model. SubscriptionLocalAvailability(ctx, s.repo, &items[i]), s.pendingDownloadAvailability(ctx, &items[i]), ) - items[i].DownloadedEpisodes = availability.DownloadedEpisodes - items[i].LocalMediaCount = availability.LocalMediaCount - items[i].MissingEpisodes = availability.MissingEpisodes - items[i].InLibrary = availability.InLibrary - if items[i].TotalEpisodes == 0 { - items[i].TotalEpisodes = availability.TotalEpisodes - } + applySubscriptionAvailability(&items[i], availability) } } -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 { +func (s *SubscriptionService) EnrichManagementProgress(ctx context.Context, items []model.Subscription) { + rows := s.downloadTaskRowsForAvailability(ctx) + for i := range items { + availability := mergeLocalAvailability( + SubscriptionLocalAvailability(ctx, s.repo, &items[i]), + s.pendingDownloadTaskAvailability(ctx, &items[i], rows, false), + ) + applySubscriptionAvailability(&items[i], availability) + } +} + +func applySubscriptionAvailability(sub *model.Subscription, availability LocalAvailability) { + if sub == nil { return } + sub.DownloadedEpisodes = availability.DownloadedEpisodes + sub.LocalMediaCount = availability.LocalMediaCount + sub.MissingEpisodes = availability.MissingEpisodes + sub.InLibrary = availability.InLibrary + if sub.TotalEpisodes == 0 { + sub.TotalEpisodes = availability.TotalEpisodes + } +} + +func (s *SubscriptionService) downloadTaskRowsForAvailability(ctx context.Context) []model.DownloadTask { + if s == nil || s.repo == nil || s.repo.Download == nil { + return nil + } rows, err := s.repo.Download.List(ctx) if err != nil { + return nil + } + return rows +} + +func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, queries []string, out *LocalAvailability) { + rows := s.downloadTaskRowsForAvailability(ctx) + s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, true, out) +} + +func (s *SubscriptionService) pendingDownloadTaskAvailability(ctx context.Context, sub *model.Subscription, rows []model.DownloadTask, verifyLive bool) LocalAvailability { + out := LocalAvailability{ + ExistingEpisodeKeys: map[string]struct{}{}, + MissingEpisodeKeys: map[string]struct{}{}, + } + if sub != nil { + out.TotalEpisodes = sub.TotalEpisodes + } + queries := subscriptionAvailabilityQueries(sub) + if len(queries) == 0 { + return s.finalizePendingAvailability(sub, out) + } + s.addDownloadTaskRowsAvailability(ctx, sub, queries, rows, verifyLive, &out) + return s.finalizePendingAvailability(sub, out) +} + +func (s *SubscriptionService) addDownloadTaskRowsAvailability(ctx context.Context, sub *model.Subscription, queries []string, rows []model.DownloadTask, verifyLive bool, out *LocalAvailability) { + if out == nil { return } baseSavePath := s.subscriptionBaseSavePath(ctx, sub) @@ -68,7 +114,7 @@ func (s *SubscriptionService) addDownloadTaskAvailability(ctx context.Context, s if !downloadTaskBlocksReadd(row.Status) { continue } - if !s.downloadTaskCountsAsPending(ctx, row) { + if verifyLive && !s.downloadTaskCountsAsPending(ctx, row) { continue } linkedToSubscription := sub != nil && strings.TrimSpace(row.SubscriptionID) != "" && row.SubscriptionID == sub.ID diff --git a/internal/service/subscription_availability_test.go b/internal/service/subscription_availability_test.go index 8e64432..5591b3a 100644 --- a/internal/service/subscription_availability_test.go +++ b/internal/service/subscription_availability_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -43,6 +44,49 @@ func TestSubscriptionEnrichProgressIncludesPendingDownloads(t *testing.T) { } } +func TestSubscriptionEnrichManagementProgressSkipsLiveQB(t *testing.T) { + var qbCalls int32 + qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&qbCalls, 1) + http.Error(w, "management enrichment should not call qb", http.StatusInternalServerError) + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.DownloadTask{}, &model.Media{}) + repos := repository.New(db) + sub := model.Subscription{ + Base: model.Base{ID: "sub-spy-family"}, + Name: "间谍过家家 自动订阅", + Filter: "间谍过家家", + MediaType: "tv", + SavePath: "/downloads/tv", + TotalEpisodes: 2, + } + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + SubscriptionID: sub.ID, + Source: "qbittorrent", + URL: "magnet:?xt=urn:btih:5555555555555555555555555555555555555555", + Title: "间谍过家家 S01E01 1080p", + SavePath: "/downloads/tv", + Status: "downloading", + Progress: 0.4, + }); err != nil { + t.Fatal(err) + } + 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) + items := []model.Subscription{sub} + + svc.EnrichManagementProgress(t.Context(), items) + if got := atomic.LoadInt32(&qbCalls); got != 0 { + t.Fatalf("management progress called qb %d times, want 0", got) + } + if items[0].DownloadedEpisodes != 1 || items[0].LocalMediaCount != 1 { + t.Fatalf("unexpected management progress: %+v", items[0]) + } +} + func TestSubscriptionPollIntervalDefaultsAndClampsMinimum(t *testing.T) { if subscriptionStartupDelay != defaultSubscriptionPollInterval { t.Fatalf("startup delay = %v, want default poll interval %v", subscriptionStartupDelay, defaultSubscriptionPollInterval) diff --git a/web/src/pages/SubscriptionsPage.tsx b/web/src/pages/SubscriptionsPage.tsx index 61aa8f4..559ed93 100644 --- a/web/src/pages/SubscriptionsPage.tsx +++ b/web/src/pages/SubscriptionsPage.tsx @@ -16,13 +16,22 @@ export function SubscriptionsPage() { const [editingId, setEditingId] = useState('') const [loading, setLoading] = useState(true) - const refresh = () => - Promise.all([subscriptionsAPI.list(), subscriptionsAPI.history()]) - .then(([active, history]) => { - setItems(active) - setHistoryItems(history) - }) - .finally(() => setLoading(false)) + const refresh = async () => { + setLoading(true) + void subscriptionsAPI + .history() + .then(setHistoryItems) + .catch(() => toast.error('订阅历史加载失败')) + + try { + const active = await subscriptionsAPI.list() + setItems(active) + } catch { + toast.error('订阅列表加载失败') + } finally { + setLoading(false) + } + } useEffect(() => { refresh().catch(() => undefined)