diff --git a/internal/service/download_add.go b/internal/service/download_add.go index 56eacad..d6eea74 100644 --- a/internal/service/download_add.go +++ b/internal/service/download_add.go @@ -183,23 +183,31 @@ func (d *DownloadService) localMediaAvailabilityRows(ctx context.Context, title } func localMediaRowsMatchDownloadTitle(title string, rows []model.Media) bool { - wantSeason, wantEpisode := ParseEpisode(title) - if wantSeason <= 0 { - wantSeason = 1 - } - if wantEpisode <= 0 { + wanted := episodeRefsFromTitle(title) + if len(wanted) == 0 { return true } + existing := map[string]struct{}{} + hasSeriesPack := false for _, row := range rows { rowSeason, rowEpisode := localMediaRowSeasonEpisode(row) - if rowEpisode == wantEpisode && rowSeason == wantSeason { - return true + if rowEpisode > 0 { + existing[episodeKey(rowSeason, rowEpisode)] = struct{}{} + continue } if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) { - return true + hasSeriesPack = true } } - return false + if hasSeriesPack { + return true + } + for _, ref := range wanted { + if _, ok := existing[episodeKey(ref.Season, ref.Episode)]; !ok { + return false + } + } + return true } func localMediaRowSeasonEpisode(row model.Media) (int, int) { @@ -238,7 +246,7 @@ func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title st continue } current := downloadTaskIdentityKey(rows[i].Title) - if current == key || strings.Contains(current, key) || strings.Contains(key, current) { + if downloadTitleCoversRequest(rows[i].Title, title) || current == key { return &rows[i], true } } @@ -255,11 +263,14 @@ func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, title str return false } for _, torrent := range live { + if downloadTitleCoversRequest(torrent.Name, title) { + return true + } current := downloadTaskIdentityKey(torrent.Name) if current == "" { continue } - if current == query || strings.Contains(current, query) || strings.Contains(query, current) { + if current == query { return true } } diff --git a/internal/service/download_add_test.go b/internal/service/download_add_test.go index 9f31f17..f98dd79 100644 --- a/internal/service/download_add_test.go +++ b/internal/service/download_add_test.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -311,6 +312,123 @@ func TestAddDownloadWithMetaSkipsExistingLocalEpisodeBeforeQBAdd(t *testing.T) { } } +func TestAddDownloadWithMetaQueuesEpisodeRangeWhenOnlyPartlyInLibrary(t *testing.T) { + 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": + http.Error(w, "temporary list unavailable", http.StatusInternalServerError) + 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.Media{}, &model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + if err := db.Create(&model.Media{ + Title: "Archives The Nanyang Mystery", + Path: "/media/tv/Archives The Nanyang Mystery/Season 01/Archives The Nanyang Mystery - S01E07.mkv", + SeasonNum: 1, + EpisodeNum: 7, + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ + Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", + }) + if err != nil { + t.Fatalf("AddDownloadWithMeta returned %v, want queued because E08 is missing", err) + } + if task == nil { + t.Fatal("task = nil, want queued task") + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } +} + +func TestAddDownloadWithMetaSkipsEpisodeRangeWhenFullyInLibrary(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}, &model.DownloadTask{}, &model.Setting{}) + repos := repository.New(db) + for _, episode := range []int{7, 8} { + if err := db.Create(&model.Media{ + Title: "Archives The Nanyang Mystery", + Path: filepath.Join("/media/tv/Archives The Nanyang Mystery/Season 01", fmt.Sprintf("Archives The Nanyang Mystery - S01E%02d.mkv", episode)), + SeasonNum: 1, + EpisodeNum: episode, + }).Error; err != nil { + t.Fatal(err) + } + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:babababababababababababababababababababa&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ + Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", + }) + if !errors.Is(err, ErrMediaAlreadyInLibrary) { + t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err) + } + if task != nil { + t.Fatalf("task = %#v, want nil", task) + } +} + +func TestAddDownloadWithMetaDoesNotDedupRangeAgainstSingleEpisodeTask(t *testing.T) { + 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": + http.Error(w, "temporary list unavailable", http.StatusInternalServerError) + 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.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}) + repos := repository.New(db) + configureTestDefaultQB(t, repos, qb.URL) + if err := repos.Download.Create(t.Context(), &model.DownloadTask{ + UserID: "u1", + Source: "qbittorrent", + URL: "https://pt.example/download?id=old", + Title: "Archives The Nanyang Mystery 2026 S01E07 2160p WEB-DL", + SavePath: "/downloads/tv", + Status: "completed", + Progress: 1, + }); err != nil { + t.Fatal(err) + } + + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd&dn=Archives+The+Nanyang+Mystery+2026+S01E07-S01E08", "/downloads", DownloadTaskMeta{ + Title: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p WEB-DL", + }) + if err != nil { + t.Fatalf("AddDownloadWithMeta returned %v, want queued because existing task covers only E07", err) + } + if task == nil { + t.Fatal("task = nil, want queued task") + } + if got := atomic.LoadInt32(&addCalls); got != 1 { + t.Fatalf("qb add calls = %d, want 1", got) + } +} + func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T) { var addCalls int32 var gotSavePath string diff --git a/internal/service/download_identity.go b/internal/service/download_identity.go index 13712d7..295b899 100644 --- a/internal/service/download_identity.go +++ b/internal/service/download_identity.go @@ -66,29 +66,80 @@ func downloadTaskIdentityKey(name string) string { return normalizedDownloadTitleKey(name) } -func downloadMediaIdentityKey(name string) string { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - return "" - } +type downloadMediaIdentity struct { + TitleKey string + Year int + Episodes []episodeRef + Pack bool +} + +func parseDownloadMediaIdentity(name string) downloadMediaIdentity { title, year := CleanQuery(name) titleKey := normalizeAvailabilityComparable(title) if titleKey == "" { titleKey = normalizeAvailabilityComparable(availabilityQuery(name, "")) } + return downloadMediaIdentity{ + TitleKey: titleKey, + Year: year, + Episodes: episodeRefsFromTitle(name), + Pack: isSeriesPackTitle(name), + } +} + +func downloadTitleCoversRequest(existing, requested string) bool { + current := parseDownloadMediaIdentity(existing) + want := parseDownloadMediaIdentity(requested) + if current.TitleKey == "" || want.TitleKey == "" { + currentKey := normalizedDownloadTitleKey(existing) + wantKey := normalizedDownloadTitleKey(requested) + return currentKey != "" && wantKey != "" && (currentKey == wantKey || strings.Contains(currentKey, wantKey) || strings.Contains(wantKey, currentKey)) + } + if current.TitleKey != want.TitleKey { + return false + } + if current.Year > 0 && want.Year > 0 && current.Year != want.Year { + return false + } + if current.Pack && len(current.Episodes) == 0 { + return true + } + if len(current.Episodes) == 0 || len(want.Episodes) == 0 { + return len(current.Episodes) == len(want.Episodes) + } + currentEpisodes := map[string]struct{}{} + for _, ref := range current.Episodes { + currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } + for _, ref := range want.Episodes { + if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { + return false + } + } + return true +} + +func downloadMediaIdentityKey(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return "" + } + identity := parseDownloadMediaIdentity(name) + titleKey := identity.TitleKey if titleKey == "" { return "" } - season, episode := ParseEpisode(name) parts := []string{titleKey} - if year > 0 { - parts = append(parts, fmt.Sprintf("y%d", year)) + if identity.Year > 0 { + parts = append(parts, fmt.Sprintf("y%d", identity.Year)) } - if episode > 0 { - if season <= 0 { - season = 1 + if len(identity.Episodes) > 0 { + first := identity.Episodes[0] + last := identity.Episodes[len(identity.Episodes)-1] + parts = append(parts, fmt.Sprintf("s%02de%03d", first.Season, first.Episode)) + if len(identity.Episodes) > 1 { + parts = append(parts, fmt.Sprintf("to%03d", last.Episode)) } - parts = append(parts, fmt.Sprintf("s%02de%03d", season, episode)) } return strings.Join(parts, "|") } diff --git a/internal/service/downloads.go b/internal/service/downloads.go index 9b55c51..800c8d3 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -206,11 +206,14 @@ func (d *DownloadService) TorrentExistsByName(ctx context.Context, name string) return false } for _, torrent := range live { + if downloadTitleCoversRequest(torrent.Name, name) { + return true + } current := normalizeTorrentName(torrent.Name) if current == "" { continue } - if strings.Contains(current, query) || strings.Contains(query, current) { + if current == query { return true } } diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index dba903a..499ebdb 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -23,10 +23,12 @@ import ( var ( patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) + patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`) patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`) patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) + patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`) @@ -88,6 +90,73 @@ func ParseEpisode(path string) (season, episode int) { return 0, 0 } +type episodeRef struct { + Season int + Episode int +} + +func episodeRefsFromTitle(path string) []episodeRef { + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if refs := parseSEpisodeRange(name); len(refs) > 0 { + return refs + } + if refs := parseCNEpisodeRange(name, path); len(refs) > 0 { + return refs + } + season, episode := ParseEpisode(path) + if episode <= 0 { + return nil + } + if season <= 0 { + season = 1 + } + return []episodeRef{{Season: season, Episode: episode}} +} + +func parseSEpisodeRange(name string) []episodeRef { + m := patSEnERange.FindStringSubmatch(name) + if len(m) < 5 { + return nil + } + season := mustAtoi(m[1]) + endSeason := season + if m[3] != "" { + endSeason = mustAtoi(m[3]) + } + if season <= 0 || endSeason != season { + return nil + } + start := mustAtoi(m[2]) + end := mustAtoi(m[4]) + return buildEpisodeRefRange(season, start, end) +} + +func parseCNEpisodeRange(name, path string) []episodeRef { + m := patCNRange.FindStringSubmatch(name) + if len(m) < 3 { + return nil + } + season, found := seasonFromParents(path) + if !found { + season = 1 + } + return buildEpisodeRefRange(season, mustAtoi(m[1]), mustAtoi(m[2])) +} + +func buildEpisodeRefRange(season, start, end int) []episodeRef { + if season <= 0 { + season = 1 + } + if start <= 0 || end <= 0 || end < start || end-start > 200 { + return nil + } + refs := make([]episodeRef, 0, end-start+1) + for episode := start; episode <= end; episode++ { + refs = append(refs, episodeRef{Season: season, Episode: episode}) + } + return refs +} + func seasonFromParents(path string) (int, bool) { dir := filepath.Dir(path) for i := 0; i < 4; i++ { diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index d344bd2..546754d 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -41,3 +41,30 @@ func TestParseEpisode(t *testing.T) { }) } } + +func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) { + tests := []struct { + name string + want []episodeRef + }{ + { + name: "Archives The Nanyang Mystery 2026 S01E07-S01E08 2160p", + want: []episodeRef{{Season: 1, Episode: 7}, {Season: 1, Episode: 8}}, + }, + { + name: "The Heir 2026 S01E39-E42 WEB-DL", + want: []episodeRef{{Season: 1, Episode: 39}, {Season: 1, Episode: 40}, {Season: 1, Episode: 41}, {Season: 1, Episode: 42}}, + }, + } + for _, tt := range tests { + got := episodeRefsFromTitle(tt.name) + if len(got) != len(tt.want) { + t.Fatalf("episodeRefsFromTitle(%q) len = %d, want %d: %#v", tt.name, len(got), len(tt.want), got) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("episodeRefsFromTitle(%q)[%d] = %#v, want %#v", tt.name, i, got[i], tt.want[i]) + } + } + } +} diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go index bf2a24b..4cafdc2 100644 --- a/internal/service/subscription_availability.go +++ b/internal/service/subscription_availability.go @@ -25,9 +25,13 @@ func (s *SubscriptionService) pendingDownloadAvailability(ctx context.Context, s } root := s.subscriptionBaseSavePath(ctx, sub) if root != "" { - _ = scanDownloadPath(ctx, root, query, func(_ string, season, episode int) bool { + _ = scanDownloadPath(ctx, root, query, func(path string, season, episode int) bool { out.LocalMediaCount++ - if episode > 0 { + if refs := episodeRefsFromTitle(path); len(refs) > 0 { + for _, ref := range refs { + out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } + } else if episode > 0 { out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} } return true @@ -100,9 +104,13 @@ func addAvailabilityTitle(title, query string, out *LocalAvailability) { return } out.LocalMediaCount++ - season, episode := ParseEpisode(title) - if episode > 0 { - out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} + if refs := episodeRefsFromTitle(title); len(refs) > 0 { + if out.ExistingEpisodeKeys == nil { + out.ExistingEpisodeKeys = map[string]struct{}{} + } + for _, ref := range refs { + out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } return } if isSeriesPackTitle(title) { @@ -122,6 +130,22 @@ func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, o return } out.LocalMediaCount++ + refs := episodeRefsFromTitle(title) + if len(refs) == 0 && episode > 0 { + if season <= 0 { + season = 1 + } + refs = []episodeRef{{Season: season, Episode: episode}} + } + if len(refs) > 0 { + if out.ExistingEpisodeKeys == nil { + out.ExistingEpisodeKeys = map[string]struct{}{} + } + for _, ref := range refs { + out.ExistingEpisodeKeys[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } + return + } if episode <= 0 { season, episode = ParseEpisode(title) } @@ -270,13 +294,17 @@ func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub if savePath == "" || query == "" { return false } - wantSeason, wantEpisode := ParseEpisode(title) - if wantSeason <= 0 { - wantSeason = 1 + wanted := episodeRefsFromTitle(title) + if len(wanted) == 0 { + wantSeason, wantEpisode := ParseEpisode(title) + if wantEpisode > 0 { + wanted = []episodeRef{{Season: wantSeason, Episode: wantEpisode}} + } } found := false + foundEpisodes := map[string]struct{}{} _ = scanDownloadPath(ctx, savePath, query, func(path string, season, episode int) bool { - if wantEpisode <= 0 { + if len(wanted) == 0 { found = true return false } @@ -286,11 +314,20 @@ func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub if season <= 0 { season = 1 } - if episodeKey(season, episode) == episodeKey(wantSeason, wantEpisode) { - found = true - return false + if refs := episodeRefsFromTitle(path); len(refs) > 0 { + for _, ref := range refs { + foundEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } + } else { + foundEpisodes[episodeKey(season, episode)] = struct{}{} } - return true + for _, ref := range wanted { + if _, ok := foundEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { + return true + } + } + found = true + return false }) return found } diff --git a/internal/service/subscription_candidate_availability_test.go b/internal/service/subscription_candidate_availability_test.go index 3f8120f..fe126b1 100644 --- a/internal/service/subscription_candidate_availability_test.go +++ b/internal/service/subscription_candidate_availability_test.go @@ -137,6 +137,28 @@ func TestSelectSiteSearchCandidatesIgnoresUnderestimatedLocalTotal(t *testing.T) } } +func TestSelectSiteSearchCandidatesRangeCanCoverMissingEpisodesAfterExistingStart(t *testing.T) { + sub := &model.Subscription{Name: "南部档案 自动订阅", Filter: "南部档案", MediaType: "tv", TotalEpisodes: 33} + results := []SearchResult{ + {Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", SearchKeyword: "南部档案 2026", DownloadURL: "https://pt/download/29-33", Seeders: 100}, + } + existing := map[string]struct{}{episodeKey(1, 29): {}} + availability := LocalAvailability{ + TotalEpisodes: 33, + LocalMediaCount: 1, + MissingEpisodes: []int{30, 31, 32, 33}, + ExistingEpisodeKeys: existing, + } + + got, stats := selectSiteSearchCandidatesWithStats(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Download != "https://pt/download/29-33" { + t.Fatalf("selected %#v, want range candidate because it covers E30-E33", got) + } + if stats.ExistingEpisodeSkipped != 0 || stats.NotMissingEpisodeSkipped != 0 { + t.Fatalf("stats = %#v, range covering missing episodes must not be skipped", stats) + } +} + func TestSelectSiteSearchCandidatesMissingEpisodeCanMatchSubtitleAlias(t *testing.T) { sub := &model.Subscription{Name: "躲在超市后门抽烟的两人 自动订阅", Filter: "躲在超市后门抽烟的两人", MediaType: "tv", TotalEpisodes: 12} results := []SearchResult{ @@ -215,6 +237,33 @@ func TestAddSiteSearchCandidateAvailabilityTracksRelaxedAliasCandidate(t *testin } } +func TestAddSiteSearchCandidateAvailabilityTracksEpisodeRange(t *testing.T) { + availability := LocalAvailability{ + TotalEpisodes: 33, + ExistingEpisodeKeys: map[string]struct{}{}, + MissingEpisodeKeys: map[string]struct{}{}, + } + candidate := siteSearchCandidate{ + Item: SearchResult{ + Title: "Archives The Nanyang Mystery 2026 S01E29-E33 2160p WEB-DL", + DownloadURL: "https://pt/download/29-33", + }, + Download: "https://pt/download/29-33", + GUID: "site|m-team|nanyang-29-33", + Season: 1, + Episode: 29, + Episodes: []int{29, 30, 31, 32, 33}, + Pack: true, + } + + addSiteSearchCandidateAvailability(candidate, &availability) + for episode := 29; episode <= 33; episode++ { + if _, ok := availability.ExistingEpisodeKeys[episodeKey(1, episode)]; !ok { + t.Fatalf("availability missing E%d after range mark: %#v", episode, availability.ExistingEpisodeKeys) + } + } +} + func TestSelectSiteSearchCandidatesDoesNotRelaxQueryForMovies(t *testing.T) { sub := &model.Subscription{Name: "玩具总动员 5 自动订阅", Filter: "玩具总动员 5 2026", MediaType: "movie"} results := []SearchResult{ diff --git a/internal/service/subscription_candidate_collect.go b/internal/service/subscription_candidate_collect.go index 6b764c4..bf9f9f9 100644 --- a/internal/service/subscription_candidate_collect.go +++ b/internal/service/subscription_candidate_collect.go @@ -38,6 +38,7 @@ func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription continue } season, episode := ParseEpisode(matchText) + episodes := episodeNumbersFromRefs(episodeRefsFromTitle(matchText), season) score := subscriptionCandidateScore(sub, item) stats.Prepared++ candidates = append(candidates, siteSearchCandidate{ @@ -46,7 +47,8 @@ func collectSiteSearchCandidates(results []SearchResult, sub *model.Subscription GUID: guid, Season: season, Episode: episode, - Pack: isSeriesPackTitle(item.Title), + Episodes: episodes, + Pack: isSeriesPackTitle(item.Title) || len(episodes) > 1, Score: score, }) } @@ -108,15 +110,46 @@ func selectRSSSubscriptionCandidates(items []rssItem, sub *model.Subscription, f } searchItem := SearchResult{Title: title, DownloadURL: download} season, episode := ParseEpisode(title) + episodes := episodeNumbersFromRefs(episodeRefsFromTitle(title), season) candidates = append(candidates, siteSearchCandidate{ Item: searchItem, Download: download, GUID: guid, Season: season, Episode: episode, - Pack: isSeriesPackTitle(title), + Episodes: episodes, + Pack: isSeriesPackTitle(title) || len(episodes) > 1, Score: subscriptionCandidateScore(sub, searchItem), }) } return selectPreparedSubscriptionCandidates(candidates, sub, local) } + +func episodeNumbersFromRefs(refs []episodeRef, fallbackSeason int) []int { + if len(refs) == 0 { + return nil + } + if fallbackSeason <= 0 { + fallbackSeason = refs[0].Season + } + out := make([]int, 0, len(refs)) + seen := map[int]struct{}{} + for _, ref := range refs { + season := ref.Season + if season <= 0 { + season = 1 + } + if fallbackSeason > 0 && season != fallbackSeason { + continue + } + if ref.Episode <= 0 { + continue + } + if _, ok := seen[ref.Episode]; ok { + continue + } + seen[ref.Episode] = struct{}{} + out = append(out, ref.Episode) + } + return out +} diff --git a/internal/service/subscription_candidate_select.go b/internal/service/subscription_candidate_select.go index 0d6bb82..d14fa62 100644 --- a/internal/service/subscription_candidate_select.go +++ b/internal/service/subscription_candidate_select.go @@ -76,19 +76,17 @@ func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandid if season <= 0 { season = 1 } - if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists { + if candidateEpisodesAllExist(local.ExistingEpisodeKeys, season, candidate) { if stats != nil { stats.ExistingEpisodeSkipped++ } continue } - if trustedTotal > 0 { - if _, missing := missingSet[candidate.Episode]; !missing { - if stats != nil { - stats.NotMissingEpisodeSkipped++ - } - continue + if trustedTotal > 0 && !candidateCoversMissingEpisode(candidate, missingSet) { + if stats != nil { + stats.NotMissingEpisodeSkipped++ } + continue } onlyMissing = append(onlyMissing, candidate) } @@ -117,6 +115,42 @@ func selectPreparedSubscriptionCandidatesWithStats(candidates []siteSearchCandid return recordPreparedSelection(selected, stats) } +func candidateEpisodesAllExist(existing map[string]struct{}, season int, candidate siteSearchCandidate) bool { + episodes := candidateEpisodeNumbers(candidate) + if len(episodes) == 0 { + return false + } + for _, episode := range episodes { + if _, exists := existing[episodeKey(season, episode)]; !exists { + return false + } + } + return true +} + +func candidateCoversMissingEpisode(candidate siteSearchCandidate, missingSet map[int]struct{}) bool { + episodes := candidateEpisodeNumbers(candidate) + if len(episodes) == 0 { + return false + } + for _, episode := range episodes { + if _, missing := missingSet[episode]; missing { + return true + } + } + return false +} + +func candidateEpisodeNumbers(candidate siteSearchCandidate) []int { + if len(candidate.Episodes) > 0 { + return candidate.Episodes + } + if candidate.Episode > 0 { + return []int{candidate.Episode} + } + return nil +} + func recordPreparedSelection(candidates []siteSearchCandidate, stats *siteSearchSelectionStats) []siteSearchCandidate { if stats != nil { stats.Selected = len(candidates) diff --git a/internal/service/subscription_planner.go b/internal/service/subscription_planner.go index 7d2df6d..b1ebf7f 100644 --- a/internal/service/subscription_planner.go +++ b/internal/service/subscription_planner.go @@ -9,6 +9,7 @@ type siteSearchCandidate struct { GUID string Season int Episode int + Episodes []int Pack bool Score int } diff --git a/internal/service/subscription_rules.go b/internal/service/subscription_rules.go index c183d9f..8c94d7b 100644 --- a/internal/service/subscription_rules.go +++ b/internal/service/subscription_rules.go @@ -9,7 +9,7 @@ import ( ) var ( - seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:e)?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) + seriesPackRE = regexp.MustCompile(`(?i)(complete|batch|合集|全集|全\s*\d+\s*[集话話期]|整季|全季|s\d{1,2}\s*(?:complete|batch|pack)|season\s*\d{1,2}\s*(?:complete|batch|pack)|s\d{1,2}e\d{1,3}\s*[-~–—]\s*(?:s\d{1,2})?e?\d{1,3}|第\s*\d+\s*[-~–—]\s*\d+\s*[集话話期])`) seasonOnlyRE = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\s*\d{1,2}(?:[\s._-]|$)|第\s*\d+\s*季`) )