From 55673b16758451130483a06e47290f55ac9442c3 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:58:40 +0800 Subject: [PATCH] refactor: split subscription run tests --- internal/service/subscription_archive_test.go | 245 +++++++ .../subscription_episode_totals_test.go | 64 ++ internal/service/subscription_run_rss_test.go | 162 +++++ internal/service/subscription_run_test.go | 610 ------------------ .../service/subscription_run_wash_test.go | 176 +++++ 5 files changed, 647 insertions(+), 610 deletions(-) create mode 100644 internal/service/subscription_archive_test.go create mode 100644 internal/service/subscription_episode_totals_test.go create mode 100644 internal/service/subscription_run_rss_test.go delete mode 100644 internal/service/subscription_run_test.go create mode 100644 internal/service/subscription_run_wash_test.go diff --git a/internal/service/subscription_archive_test.go b/internal/service/subscription_archive_test.go new file mode 100644 index 0000000..85ef02e --- /dev/null +++ b/internal/service/subscription_archive_test.go @@ -0,0 +1,245 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) { + rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(` + + + Dune 2021 1080p WEB-DL + dune-1080-web + magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL + +`)) + })) + defer rss.Close() + + var addCalls int32 + var added bool + 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": + if added { + _, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/torrents/add": + added = true + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) + + sub := &model.Subscription{ + Name: "Dune 自动订阅", + FeedURL: rss.URL, + Filter: "Dune 2021", + MediaType: "movie", + SavePath: "/downloads/movies", + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + queued, err := svc.runOne(t.Context(), sub) + if err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("queued = %d, want 1", queued) + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } + active, err := repos.Subscription.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 0 { + t.Fatalf("active subscriptions = %d, want 0 after completion", len(active)) + } + history, err := repos.Subscription.History(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].ArchivedAt == nil { + t.Fatalf("history = %#v, want one archived subscription", history) + } +} + +func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) { + db := newServiceTestDB(t, &model.Subscription{}) + repos := repository.New(db) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) + sub := &model.Subscription{ + Name: "Some Show S01E01 自动订阅", + FeedURL: "site-search://search?keyword=Some%20Show%20S01E01", + Filter: "Some Show S01E01", + MediaType: "tv", + Enabled: true, + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + + if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ + DownloadedEpisodes: 1, + LocalMediaCount: 1, + InLibrary: true, + ExistingEpisodeKeys: map[string]struct{}{ + episodeKey(1, 1): {}, + }, + }); err != nil { + t.Fatal(err) + } + active, err := repos.Subscription.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 0 { + t.Fatalf("active subscriptions = %d, want 0", len(active)) + } + history, err := repos.Subscription.History(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(history) != 1 || history[0].ArchiveReason == "" { + t.Fatalf("history = %#v, want archived single episode", history) + } +} + +func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) { + sub := &model.Subscription{ + Name: "Some Show 自动订阅", + Filter: "Some Show", + MediaType: "tv", + } + availability := LocalAvailability{ + DownloadedEpisodes: 1, + LocalMediaCount: 1, + InLibrary: true, + ExistingEpisodeKeys: map[string]struct{}{ + episodeKey(1, 1): {}, + }, + } + if subscriptionShouldArchive(sub, availability) { + t.Fatal("generic series with unknown total should stay active for incremental episodes") + } +} + +func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) { + db := newServiceTestDB(t, &model.Subscription{}) + repos := repository.New(db) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) + sub := &model.Subscription{ + Name: "Dune 自动订阅", + FeedURL: "site-search://search?keyword=Dune", + Filter: "Dune 2021", + MediaType: "movie", + WashEnabled: true, + Enabled: true, + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + + if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ + DownloadedEpisodes: 1, + LocalMediaCount: 1, + InLibrary: true, + }); err != nil { + t.Fatal(err) + } + active, err := repos.Subscription.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 1 { + t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active)) + } + history, err := repos.Subscription.History(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(history) != 0 { + t.Fatalf("history subscriptions = %d, want 0", len(history)) + } +} + +func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) { + db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}) + repos := repository.New(db) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) + sub := &model.Subscription{ + Name: "南部档案 自动订阅", + FeedURL: "https://rss.example/feed", + Filter: "南部档案", + MediaType: "tv", + TotalEpisodes: 33, + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + archivedAt := time.Now() + if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { + t.Fatal(err) + } + restored, err := svc.Restore(t.Context(), sub.ID) + if err != nil { + t.Fatal(err) + } + if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled { + t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled) + } + if restored.TotalEpisodes != 0 { + t.Fatalf("restored total_episodes = %d, want 0 so it gets recomputed from authoritative metadata", restored.TotalEpisodes) + } + active, err := repos.Subscription.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 1 || active[0].ID != sub.ID { + t.Fatalf("active subscriptions = %#v, want restored subscription", active) + } + history, err := repos.Subscription.History(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(history) != 0 { + t.Fatalf("history subscriptions = %d, want 0 after restore", len(history)) + } + seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") + if err != nil { + t.Fatal(err) + } + if seen != "" { + t.Fatalf("seen state = %q, want cleared", seen) + } +} diff --git a/internal/service/subscription_episode_totals_test.go b/internal/service/subscription_episode_totals_test.go new file mode 100644 index 0000000..c10b17e --- /dev/null +++ b/internal/service/subscription_episode_totals_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestInferSubscriptionTotalEpisodesFromSearchAndRSS(t *testing.T) { + sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} + results := []SearchResult{ + {Title: "Some Show S01E01 1080p"}, + {Title: "Some Show S01E12 1080p"}, + {Title: "Other Show S01E99 1080p"}, + } + if got := inferSearchTotalEpisodes(results, sub); got != 12 { + t.Fatalf("search inferred total = %d, want 12", got) + } + subtitleResults := []SearchResult{ + {Title: "Smoking Behind the Supermarket with You", Subtitle: "躲在超市后门抽烟的两人 S01E12"}, + } + subtitleSub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv"} + if got := inferSearchTotalEpisodes(subtitleResults, subtitleSub); got != 12 { + t.Fatalf("subtitle search inferred total = %d, want 12", got) + } + items := []rssItem{ + {Title: "Some Show S01E02 WEB-DL"}, + {Title: "Some Show S01E10 WEB-DL"}, + } + if got := inferRSSTotalEpisodes(items, sub, compileFilter("Some Show")); got != 10 { + t.Fatalf("rss inferred total = %d, want 10", got) + } +} + +func TestResolveSubscriptionTotalEpisodesPrefersTMDbOverTitleFallback(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search/tv": + _, _ = w.Write([]byte(`{"results":[{"id":42,"name":"Some Show","first_air_date":"2026-01-01"}]}`)) + case "/tv/42": + _, _ = w.Write([]byte(`{"number_of_episodes":13}`)) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + cfg := &config.Config{} + cfg.Secrets.TMDbAPIKey = "test" + cfg.Secrets.TMDbAPIProxy = upstream.URL + tmdb := NewTMDbProvider(cfg, zap.NewNop(), nil) + svc := NewSubscriptionService(cfg, zap.NewNop(), nil, nil, nil, NewHub(zap.NewNop())) + svc.SetScraper(NewScraperService(cfg, zap.NewNop(), nil, tmdb, nil, nil, nil, NewHub(zap.NewNop()))) + + sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} + if got := svc.resolveSubscriptionTotalEpisodes(t.Context(), sub, 10); got != 13 { + t.Fatalf("resolved total = %d, want TMDb total 13", got) + } +} diff --git a/internal/service/subscription_run_rss_test.go b/internal/service/subscription_run_rss_test.go new file mode 100644 index 0000000..5170bc3 --- /dev/null +++ b/internal/service/subscription_run_rss_test.go @@ -0,0 +1,162 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) { + rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(` + + + Some Show S01E01 1080p + episode-1 + magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 + + + Some Show S01E01 1080p + episode-1 + magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 + +`)) + })) + defer rss.Close() + + var addCalls int32 + 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": + if atomic.LoadInt32(&addCalls) > 0 { + _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/torrents/add": + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) + + sub := &model.Subscription{ + Name: "Some Show 自动订阅", + FeedURL: rss.URL, + Filter: "Some Show", + MediaType: "tv", + SavePath: "/downloads/tv", + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + queued, err := svc.runOne(t.Context(), sub) + if err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("queued = %d, want 1", queued) + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } + rows, err := repos.Download.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("download rows = %d, want 1", len(rows)) + } +} + +func TestSubscriptionRunOneSkipsSameEpisodeAddedEarlierInFeed(t *testing.T) { + rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(` + + + Some Show S01E01 1080p + episode-1-a + magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Some+Show+S01E01+1080p + + + Some Show S01E01 WEB-DL + episode-1-b + magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Some+Show+S01E01+WEB-DL + +`)) + })) + defer rss.Close() + + var addCalls int32 + 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": + if atomic.LoadInt32(&addCalls) > 0 { + _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/torrents/add": + atomic.AddInt32(&addCalls, 1) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) + + sub := &model.Subscription{ + Name: "Some Show 自动订阅", + FeedURL: rss.URL, + Filter: "Some Show", + MediaType: "tv", + SavePath: "/downloads/tv", + TotalEpisodes: 12, + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + queued, err := svc.runOne(t.Context(), sub) + if err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("queued = %d, want 1", queued) + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } + rows, err := repos.Download.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("download rows = %d, want 1", len(rows)) + } +} diff --git a/internal/service/subscription_run_test.go b/internal/service/subscription_run_test.go deleted file mode 100644 index 6b80b0e..0000000 --- a/internal/service/subscription_run_test.go +++ /dev/null @@ -1,610 +0,0 @@ -package service - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/ShukeBta/MediaStationGo/internal/config" - "github.com/ShukeBta/MediaStationGo/internal/model" - "github.com/ShukeBta/MediaStationGo/internal/repository" -) - -func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Dune 2021 1080p WEB-DL - dune-1080-web - magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - var added bool - 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": - if added { - _, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - added = true - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: rss.URL, - Filter: "Dune 2021", - MediaType: "movie", - SavePath: "/downloads/movies", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %d, want 0 after completion", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 1 || history[0].ArchivedAt == nil { - t.Fatalf("history = %#v, want one archived subscription", history) - } -} - -func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Some Show S01E01 自动订阅", - FeedURL: "site-search://search?keyword=Some%20Show%20S01E01", - Filter: "Some Show S01E01", - MediaType: "tv", - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - }); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 0 { - t.Fatalf("active subscriptions = %d, want 0", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 1 || history[0].ArchiveReason == "" { - t.Fatalf("history = %#v, want archived single episode", history) - } -} - -func TestSubscriptionArchiveKeepsGenericUnknownTotalSeriesActive(t *testing.T) { - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - Filter: "Some Show", - MediaType: "tv", - } - availability := LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - ExistingEpisodeKeys: map[string]struct{}{ - episodeKey(1, 1): {}, - }, - } - if subscriptionShouldArchive(sub, availability) { - t.Fatal("generic series with unknown total should stay active for incremental episodes") - } -} - -func TestInferSubscriptionTotalEpisodesFromSearchAndRSS(t *testing.T) { - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - results := []SearchResult{ - {Title: "Some Show S01E01 1080p"}, - {Title: "Some Show S01E12 1080p"}, - {Title: "Other Show S01E99 1080p"}, - } - if got := inferSearchTotalEpisodes(results, sub); got != 12 { - t.Fatalf("search inferred total = %d, want 12", got) - } - subtitleResults := []SearchResult{ - {Title: "Smoking Behind the Supermarket with You", Subtitle: "躲在超市后门抽烟的两人 S01E12"}, - } - subtitleSub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv"} - if got := inferSearchTotalEpisodes(subtitleResults, subtitleSub); got != 12 { - t.Fatalf("subtitle search inferred total = %d, want 12", got) - } - items := []rssItem{ - {Title: "Some Show S01E02 WEB-DL"}, - {Title: "Some Show S01E10 WEB-DL"}, - } - if got := inferRSSTotalEpisodes(items, sub, compileFilter("Some Show")); got != 10 { - t.Fatalf("rss inferred total = %d, want 10", got) - } -} - -func TestResolveSubscriptionTotalEpisodesPrefersTMDbOverTitleFallback(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/search/tv": - _, _ = w.Write([]byte(`{"results":[{"id":42,"name":"Some Show","first_air_date":"2026-01-01"}]}`)) - case "/tv/42": - _, _ = w.Write([]byte(`{"number_of_episodes":13}`)) - default: - http.NotFound(w, r) - } - })) - defer upstream.Close() - - cfg := &config.Config{} - cfg.Secrets.TMDbAPIKey = "test" - cfg.Secrets.TMDbAPIProxy = upstream.URL - tmdb := NewTMDbProvider(cfg, zap.NewNop(), nil) - svc := NewSubscriptionService(cfg, zap.NewNop(), nil, nil, nil, NewHub(zap.NewNop())) - svc.SetScraper(NewScraperService(cfg, zap.NewNop(), nil, tmdb, nil, nil, nil, NewHub(zap.NewNop()))) - - sub := &model.Subscription{Name: "Some Show 自动订阅", Filter: "Some Show", MediaType: "tv"} - if got := svc.resolveSubscriptionTotalEpisodes(t.Context(), sub, 10); got != 13 { - t.Fatalf("resolved total = %d, want TMDb total 13", got) - } -} - -func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: "site-search://search?keyword=Dune", - Filter: "Dune 2021", - MediaType: "movie", - WashEnabled: true, - Enabled: true, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - if err := svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{ - DownloadedEpisodes: 1, - LocalMediaCount: 1, - InLibrary: true, - }); err != nil { - t.Fatal(err) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 { - t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active)) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 0 { - t.Fatalf("history subscriptions = %d, want 0", len(history)) - } -} - -func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) { - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}) - repos := repository.New(db) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "南部档案 自动订阅", - FeedURL: "https://rss.example/feed", - Filter: "南部档案", - MediaType: "tv", - TotalEpisodes: 33, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - archivedAt := time.Now() - if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil { - t.Fatal(err) - } - restored, err := svc.Restore(t.Context(), sub.ID) - if err != nil { - t.Fatal(err) - } - if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled { - t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled) - } - if restored.TotalEpisodes != 0 { - t.Fatalf("restored total_episodes = %d, want 0 so it gets recomputed from authoritative metadata", restored.TotalEpisodes) - } - active, err := repos.Subscription.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(active) != 1 || active[0].ID != sub.ID { - t.Fatalf("active subscriptions = %#v, want restored subscription", active) - } - history, err := repos.Subscription.History(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(history) != 0 { - t.Fatalf("history subscriptions = %d, want 0 after restore", len(history)) - } - seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen") - if err != nil { - t.Fatal(err) - } - if seen != "" { - t.Fatalf("seen state = %q, want cleared", seen) - } -} - -func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Some Show S01E01 1080p - episode-1 - magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 - - - Some Show S01E01 1080p - episode-1 - magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=Some+Show+S01E01 - -`)) - })) - defer rss.Close() - - var addCalls int32 - 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": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - FeedURL: rss.URL, - Filter: "Some Show", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } -} - -func TestSubscriptionRunOneSkipsSameEpisodeAddedEarlierInFeed(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Some Show S01E01 1080p - episode-1-a - magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=Some+Show+S01E01+1080p - - - Some Show S01E01 WEB-DL - episode-1-b - magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=Some+Show+S01E01+WEB-DL - -`)) - })) - defer rss.Close() - - var addCalls int32 - 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": - if atomic.LoadInt32(&addCalls) > 0 { - _, _ = w.Write([]byte(`[{"hash":"abc123","name":"Some Show S01E01 1080p","state":"downloading","progress":0.1}]`)) - return - } - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - atomic.AddInt32(&addCalls, 1) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Some Show 自动订阅", - FeedURL: rss.URL, - Filter: "Some Show", - MediaType: "tv", - SavePath: "/downloads/tv", - TotalEpisodes: 12, - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 { - t.Fatalf("download rows = %d, want 1", len(rows)) - } -} - -func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Dune 2021 1080p WEB-DL - dune-1080-web - magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL - - - Dune 2021 2160p UHD BluRay REMUX HDR - dune-2160-remux - magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Dune+2021+2160p+REMUX - - - Dune 2021 720p HDTV - dune-720-hdtv - magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Dune+2021+720p+HDTV - -`)) - })) - defer rss.Close() - - var addCalls int32 - var addedTitles []string - addedHashes := make([]string, 0, 3) - 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": - if len(addedHashes) == 0 { - _, _ = w.Write([]byte(`[]`)) - return - } - var items []string - for _, hash := range addedHashes { - items = append(items, `{"hash":"`+hash+`","name":"Dune 2021","state":"downloading","progress":0.1}`) - } - _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) - case "/api/v2/torrents/add": - call := atomic.AddInt32(&addCalls, 1) - _ = r.ParseMultipartForm(10 << 20) - addedTitles = append(addedTitles, r.FormValue("urls")) - addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - configureTestDefaultQB(t, repos, qb.URL) - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - - sub := &model.Subscription{ - Name: "Dune 自动订阅", - FeedURL: rss.URL, - Filter: "Dune 2021", - MediaType: "movie", - WashEnabled: true, - WashPriority: "resolution", - SavePath: "/downloads/movies", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 1 { - t.Fatalf("queued = %d, want 1 best movie variant", queued) - } - if got := atomic.LoadInt32(&addCalls); got != 1 { - t.Fatalf("qb add calls = %d, want 1", got) - } - if len(addedTitles) != 1 || !strings.Contains(addedTitles[0], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { - t.Fatalf("added %#v, want 2160p REMUX variant only", addedTitles) - } -} - -func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) { - rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/rss+xml") - _, _ = w.Write([]byte(` - - - Deleted Downloader Show S01E01 1080p - deleted-downloader-episode-1 - magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Deleted+Downloader+Show+S01E01 - -`)) - })) - defer rss.Close() - - var qbCalls int32 - qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(&qbCalls, 1) - switch r.URL.Path { - case "/api/v2/auth/login": - _, _ = w.Write([]byte("Ok.")) - case "/api/v2/torrents/info": - _, _ = w.Write([]byte(`[]`)) - case "/api/v2/torrents/add": - _, _ = w.Write([]byte("Ok.")) - default: - http.NotFound(w, r) - } - })) - defer qb.Close() - - db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) - repos := repository.New(db) - client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} - if err := repos.DownloadClient.Create(t.Context(), client); err != nil { - t.Fatal(err) - } - if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { - t.Fatal(err) - } - if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil { - t.Fatal(err) - } - - downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) - svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) - sub := &model.Subscription{ - Name: "Deleted Downloader Show 自动订阅", - FeedURL: rss.URL, - Filter: "Deleted Downloader Show", - MediaType: "tv", - SavePath: "/downloads/tv", - } - if err := repos.Subscription.Create(t.Context(), sub); err != nil { - t.Fatal(err) - } - - queued, err := svc.runOne(t.Context(), sub) - if err != nil { - t.Fatal(err) - } - if queued != 0 { - t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued) - } - if got := atomic.LoadInt32(&qbCalls); got != 0 { - t.Fatalf("qB calls = %d, want 0 after downloader deletion", got) - } - rows, err := repos.Download.List(t.Context()) - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("download rows = %d, want 0", len(rows)) - } -} diff --git a/internal/service/subscription_run_wash_test.go b/internal/service/subscription_run_wash_test.go new file mode 100644 index 0000000..0e9642a --- /dev/null +++ b/internal/service/subscription_run_wash_test.go @@ -0,0 +1,176 @@ +package service + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestSubscriptionRunOneRSSWashQueuesOnlyBestMovieVariant(t *testing.T) { + rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(` + + + Dune 2021 1080p WEB-DL + dune-1080-web + magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL + + + Dune 2021 2160p UHD BluRay REMUX HDR + dune-2160-remux + magnet:?xt=urn:btih:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee&dn=Dune+2021+2160p+REMUX + + + Dune 2021 720p HDTV + dune-720-hdtv + magnet:?xt=urn:btih:ffffffffffffffffffffffffffffffffffffffff&dn=Dune+2021+720p+HDTV + +`)) + })) + defer rss.Close() + + var addCalls int32 + var addedTitles []string + addedHashes := make([]string, 0, 3) + 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": + if len(addedHashes) == 0 { + _, _ = w.Write([]byte(`[]`)) + return + } + var items []string + for _, hash := range addedHashes { + items = append(items, `{"hash":"`+hash+`","name":"Dune 2021","state":"downloading","progress":0.1}`) + } + _, _ = w.Write([]byte(`[` + strings.Join(items, ",") + `]`)) + case "/api/v2/torrents/add": + call := atomic.AddInt32(&addCalls, 1) + _ = r.ParseMultipartForm(10 << 20) + addedTitles = append(addedTitles, r.FormValue("urls")) + addedHashes = append(addedHashes, strings.Repeat(fmt.Sprintf("%x", call), 40)) + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) + + sub := &model.Subscription{ + Name: "Dune 自动订阅", + FeedURL: rss.URL, + Filter: "Dune 2021", + MediaType: "movie", + WashEnabled: true, + WashPriority: "resolution", + SavePath: "/downloads/movies", + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + queued, err := svc.runOne(t.Context(), sub) + if err != nil { + t.Fatal(err) + } + if queued != 1 { + t.Fatalf("queued = %d, want 1 best movie variant", queued) + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } + if len(addedTitles) != 1 || !strings.Contains(addedTitles[0], "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { + t.Fatalf("added %#v, want 2160p REMUX variant only", addedTitles) + } +} + +func TestSubscriptionRunOneDoesNotUseDeletedDownloader(t *testing.T) { + rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(` + + + Deleted Downloader Show S01E01 1080p + deleted-downloader-episode-1 + magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Deleted+Downloader+Show+S01E01 + +`)) + })) + defer rss.Close() + + var qbCalls int32 + qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&qbCalls, 1) + switch r.URL.Path { + case "/api/v2/auth/login": + _, _ = w.Write([]byte("Ok.")) + case "/api/v2/torrents/info": + _, _ = w.Write([]byte(`[]`)) + case "/api/v2/torrents/add": + _, _ = w.Write([]byte("Ok.")) + default: + http.NotFound(w, r) + } + })) + defer qb.Close() + + db := newServiceTestDB(t, &model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}) + repos := repository.New(db) + client := &model.DownloadClient{Name: "qB deleted", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: true, Enabled: true} + if err := repos.DownloadClient.Create(t.Context(), client); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil { + t.Fatal(err) + } + if err := repos.DownloadClient.Delete(t.Context(), client.ID); err != nil { + t.Fatal(err) + } + + downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop())) + sub := &model.Subscription{ + Name: "Deleted Downloader Show 自动订阅", + FeedURL: rss.URL, + Filter: "Deleted Downloader Show", + MediaType: "tv", + SavePath: "/downloads/tv", + } + if err := repos.Subscription.Create(t.Context(), sub); err != nil { + t.Fatal(err) + } + + queued, err := svc.runOne(t.Context(), sub) + if err != nil { + t.Fatal(err) + } + if queued != 0 { + t.Fatalf("queued = %d, want 0 when default downloader was deleted", queued) + } + if got := atomic.LoadInt32(&qbCalls); got != 0 { + t.Fatalf("qB calls = %d, want 0 after downloader deletion", got) + } + rows, err := repos.Download.List(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("download rows = %d, want 0", len(rows)) + } +}