mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
fix subscription episode range dedup
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "|")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,7 @@ type siteSearchCandidate struct {
|
||||
GUID string
|
||||
Season int
|
||||
Episode int
|
||||
Episodes []int
|
||||
Pack bool
|
||||
Score int
|
||||
}
|
||||
|
||||
@@ -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*季`)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user