fix subscription episode range dedup

This commit is contained in:
ShukeBta
2026-06-26 13:50:53 +08:00
parent b71ff818fa
commit 7d883515d4
12 changed files with 480 additions and 47 deletions
+22 -11
View File
@@ -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
}
}
+118
View File
@@ -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
+63 -12
View File
@@ -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, "|")
}
+4 -1
View File
@@ -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
}
}
+69
View File
@@ -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++ {
+27
View File
@@ -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])
}
}
}
}
+50 -13
View File
@@ -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
}
@@ -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{
@@ -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
}
@@ -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)
+1
View File
@@ -9,6 +9,7 @@ type siteSearchCandidate struct {
GUID string
Season int
Episode int
Episodes []int
Pack bool
Score int
}
+1 -1
View File
@@ -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*季`)
)