From 2fbfed3ac901f22b38461eba755dc136d1e6b845 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:43:37 +0800 Subject: [PATCH] split subscription availability helpers --- internal/service/subscription_availability.go | 261 ------------------ .../service/subscription_availability_scan.go | 93 +++++++ .../subscription_availability_titles.go | 182 ++++++++++++ 3 files changed, 275 insertions(+), 261 deletions(-) create mode 100644 internal/service/subscription_availability_scan.go create mode 100644 internal/service/subscription_availability_titles.go diff --git a/internal/service/subscription_availability.go b/internal/service/subscription_availability.go index 2b710f8..97aff39 100644 --- a/internal/service/subscription_availability.go +++ b/internal/service/subscription_availability.go @@ -3,10 +3,7 @@ package service import ( "context" - "os" - "path/filepath" "strings" - "unicode" "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -106,140 +103,6 @@ func (s *SubscriptionService) addLiveTorrentAvailability(ctx context.Context, qu } } -func addAvailabilityTitle(title, query string, out *LocalAvailability) { - if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" { - return - } - if !availabilityTitleMatchesAny(title, []string{query}) { - return - } - out.LocalMediaCount++ - 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) { - out.HasSeriesPack = true - } -} - -func addAvailabilityTitleAny(title string, queries []string, out *LocalAvailability) { - if !availabilityTitleMatchesAny(title, queries) { - return - } - addTrustedAvailabilityTitle(title, 0, 0, false, out) -} - -func availabilityTitleMatchesAny(title string, queries []string) bool { - titleKey := normalizeAvailabilityComparable(title) - if titleKey == "" { - return false - } - for _, query := range queries { - queryKey := normalizeAvailabilityComparable(query) - if queryKey == "" { - continue - } - if strings.Contains(titleKey, queryKey) { - return true - } - } - return false -} - -func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *LocalAvailability) { - addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out) -} - -func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool { - availability := mergeLocalAvailability( - SubscriptionLocalAvailability(ctx, s.repo, sub), - s.pendingDownloadAvailability(ctx, sub), - ) - return candidateAvailableInAvailability(sub, candidate, availability) -} - -func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool { - mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "") - if !isSubscriptionSeriesType(mediaType) { - return availability.LocalMediaCount > 0 || availability.InLibrary - } - episodes := candidateEpisodeNumbers(candidate) - if len(episodes) == 0 { - return availability.HasSeriesPack - } - season := candidate.Season - if season <= 0 { - season = 1 - } - for _, episode := range episodes { - if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok { - return false - } - } - return true -} - -func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) { - if out == nil { - return - } - if strings.TrimSpace(title) == "" && episode <= 0 && !pack { - 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) - } - if episode > 0 { - if out.ExistingEpisodeKeys == nil { - out.ExistingEpisodeKeys = map[string]struct{}{} - } - out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} - return - } - if pack || isSeriesPackTitle(title) { - out.HasSeriesPack = true - } -} - -func sameOrChildPath(pathValue, root string) bool { - pathValue = filepath.Clean(strings.TrimSpace(pathValue)) - root = filepath.Clean(strings.TrimSpace(root)) - if pathValue == "" || root == "" || pathValue == "." || root == "." { - return false - } - if strings.EqualFold(pathValue, root) { - return true - } - rel, err := filepath.Rel(root, pathValue) - if err != nil { - return false - } - return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) -} - func (s *SubscriptionService) finalizePendingAvailability(sub *model.Subscription, out LocalAvailability) LocalAvailability { mediaType := "" if sub != nil { @@ -299,62 +162,6 @@ func subscriptionAvailabilityQueries(sub *model.Subscription) []string { return compactUniqueStrings(values...) } -func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { - out := LocalAvailability{ - ExistingEpisodeKeys: map[string]struct{}{}, - MissingEpisodeKeys: map[string]struct{}{}, - } - for _, value := range values { - if value.TotalEpisodes > out.TotalEpisodes { - out.TotalEpisodes = value.TotalEpisodes - } - out.LocalMediaCount += value.LocalMediaCount - out.InLibrary = out.InLibrary || value.InLibrary - out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack - for key := range value.ExistingEpisodeKeys { - out.ExistingEpisodeKeys[key] = struct{}{} - } - } - out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) - if out.TotalEpisodes > 0 { - out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) - for _, episode := range out.MissingEpisodes { - out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} - } - } - if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 { - out.DownloadedEpisodes = out.LocalMediaCount - if out.TotalEpisodes == 0 { - out.TotalEpisodes = 1 - } - } - return out -} - -// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。 -// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。 -func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool { - if avail.LocalMediaCount == 0 && !avail.HasSeriesPack { - return false - } - if !isSubscriptionSeriesType(subscriptionMediaType(sub)) { - return true - } - if avail.HasSeriesPack { - return true - } - wantSeason, wantEpisode := ParseEpisode(title) - if wantEpisode <= 0 { - // 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。 - return true - } - if wantSeason <= 0 { - wantSeason = 1 - } - _, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)] - return exists -} - func subscriptionMediaType(sub *model.Subscription) string { if sub == nil { return "" @@ -408,71 +215,3 @@ func (s *SubscriptionService) downloadPathHasCandidate(ctx context.Context, sub }) return found } - -func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error { - return scanDownloadPathAny(ctx, root, []string{query}, visit) -} - -func scanDownloadPathAny(ctx context.Context, root string, queries []string, visit func(path string, season, episode int) bool) error { - if strings.TrimSpace(root) == "" { - return nil - } - info, err := os.Stat(root) - if err != nil || !info.IsDir() { - return nil - } - if len(queries) == 0 { - return nil - } - visited := 0 - return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if ctx.Err() != nil { - return ctx.Err() - } - if d.IsDir() { - if path != root && strings.HasPrefix(filepath.Base(path), ".") { - return filepath.SkipDir - } - return nil - } - if !isDownloadMediaPath(path) { - return nil - } - visited++ - if visited > 10000 { - return filepath.SkipAll - } - if !availabilityTitleMatchesAny(path, queries) { - return nil - } - season, episode := ParseEpisode(path) - if !visit(path, season, episode) { - return filepath.SkipAll - } - return nil - }) -} - -func isDownloadMediaPath(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".!qb", ".part", ".aria2", ".crdownload": - path = strings.TrimSuffix(path, filepath.Ext(path)) - ext = strings.ToLower(filepath.Ext(path)) - } - _, ok := videoExtensions[ext] - return ok -} - -func normalizeAvailabilityComparable(value string) string { - var b strings.Builder - for _, r := range strings.ToLower(value) { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - } - } - return b.String() -} diff --git a/internal/service/subscription_availability_scan.go b/internal/service/subscription_availability_scan.go new file mode 100644 index 0000000..a4b910a --- /dev/null +++ b/internal/service/subscription_availability_scan.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" + "unicode" +) + +func sameOrChildPath(pathValue, root string) bool { + pathValue = filepath.Clean(strings.TrimSpace(pathValue)) + root = filepath.Clean(strings.TrimSpace(root)) + if pathValue == "" || root == "" || pathValue == "." || root == "." { + return false + } + if strings.EqualFold(pathValue, root) { + return true + } + rel, err := filepath.Rel(root, pathValue) + if err != nil { + return false + } + return rel != "." && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) +} + +func scanDownloadPath(ctx context.Context, root, query string, visit func(path string, season, episode int) bool) error { + return scanDownloadPathAny(ctx, root, []string{query}, visit) +} + +func scanDownloadPathAny(ctx context.Context, root string, queries []string, visit func(path string, season, episode int) bool) error { + if strings.TrimSpace(root) == "" { + return nil + } + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + return nil + } + if len(queries) == 0 { + return nil + } + visited := 0 + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if d.IsDir() { + if path != root && strings.HasPrefix(filepath.Base(path), ".") { + return filepath.SkipDir + } + return nil + } + if !isDownloadMediaPath(path) { + return nil + } + visited++ + if visited > 10000 { + return filepath.SkipAll + } + if !availabilityTitleMatchesAny(path, queries) { + return nil + } + season, episode := ParseEpisode(path) + if !visit(path, season, episode) { + return filepath.SkipAll + } + return nil + }) +} + +func isDownloadMediaPath(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".!qb", ".part", ".aria2", ".crdownload": + path = strings.TrimSuffix(path, filepath.Ext(path)) + ext = strings.ToLower(filepath.Ext(path)) + } + _, ok := videoExtensions[ext] + return ok +} + +func normalizeAvailabilityComparable(value string) string { + var b strings.Builder + for _, r := range strings.ToLower(value) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/service/subscription_availability_titles.go b/internal/service/subscription_availability_titles.go new file mode 100644 index 0000000..60d495b --- /dev/null +++ b/internal/service/subscription_availability_titles.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func addAvailabilityTitle(title, query string, out *LocalAvailability) { + if out == nil || strings.TrimSpace(title) == "" || strings.TrimSpace(query) == "" { + return + } + if !availabilityTitleMatchesAny(title, []string{query}) { + return + } + out.LocalMediaCount++ + 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) { + out.HasSeriesPack = true + } +} + +func addAvailabilityTitleAny(title string, queries []string, out *LocalAvailability) { + if !availabilityTitleMatchesAny(title, queries) { + return + } + addTrustedAvailabilityTitle(title, 0, 0, false, out) +} + +func availabilityTitleMatchesAny(title string, queries []string) bool { + titleKey := normalizeAvailabilityComparable(title) + if titleKey == "" { + return false + } + for _, query := range queries { + queryKey := normalizeAvailabilityComparable(query) + if queryKey == "" { + continue + } + if strings.Contains(titleKey, queryKey) { + return true + } + } + return false +} + +func addSiteSearchCandidateAvailability(candidate siteSearchCandidate, out *LocalAvailability) { + addTrustedAvailabilityTitle(subscriptionSearchResultText(candidate.Item), candidate.Season, candidate.Episode, candidate.Pack, out) +} + +func (s *SubscriptionService) subscriptionCandidateConfirmedAvailable(ctx context.Context, sub *model.Subscription, candidate siteSearchCandidate) bool { + availability := mergeLocalAvailability( + SubscriptionLocalAvailability(ctx, s.repo, sub), + s.pendingDownloadAvailability(ctx, sub), + ) + return candidateAvailableInAvailability(sub, candidate, availability) +} + +func candidateAvailableInAvailability(sub *model.Subscription, candidate siteSearchCandidate, availability LocalAvailability) bool { + mediaType := normalizeMediaType(subscriptionMediaType(sub), subscriptionName(sub)+" "+subscriptionFilter(sub), "") + if !isSubscriptionSeriesType(mediaType) { + return availability.LocalMediaCount > 0 || availability.InLibrary + } + episodes := candidateEpisodeNumbers(candidate) + if len(episodes) == 0 { + return availability.HasSeriesPack + } + season := candidate.Season + if season <= 0 { + season = 1 + } + for _, episode := range episodes { + if _, ok := availability.ExistingEpisodeKeys[episodeKey(season, episode)]; !ok { + return false + } + } + return true +} + +func addTrustedAvailabilityTitle(title string, season, episode int, pack bool, out *LocalAvailability) { + if out == nil { + return + } + if strings.TrimSpace(title) == "" && episode <= 0 && !pack { + 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) + } + if episode > 0 { + if out.ExistingEpisodeKeys == nil { + out.ExistingEpisodeKeys = map[string]struct{}{} + } + out.ExistingEpisodeKeys[episodeKey(season, episode)] = struct{}{} + return + } + if pack || isSeriesPackTitle(title) { + out.HasSeriesPack = true + } +} + +func mergeLocalAvailability(values ...LocalAvailability) LocalAvailability { + out := LocalAvailability{ + ExistingEpisodeKeys: map[string]struct{}{}, + MissingEpisodeKeys: map[string]struct{}{}, + } + for _, value := range values { + if value.TotalEpisodes > out.TotalEpisodes { + out.TotalEpisodes = value.TotalEpisodes + } + out.LocalMediaCount += value.LocalMediaCount + out.InLibrary = out.InLibrary || value.InLibrary + out.HasSeriesPack = out.HasSeriesPack || value.HasSeriesPack + for key := range value.ExistingEpisodeKeys { + out.ExistingEpisodeKeys[key] = struct{}{} + } + } + out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) + if out.TotalEpisodes > 0 { + out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) + for _, episode := range out.MissingEpisodes { + out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} + } + } + if out.DownloadedEpisodes == 0 && out.LocalMediaCount > 0 { + out.DownloadedEpisodes = out.LocalMediaCount + if out.TotalEpisodes == 0 { + out.TotalEpisodes = 1 + } + } + return out +} + +// subscriptionItemAlreadyAvailable 判断某个订阅条目(按其标题解析出的季/集)是否已在媒体库存在。 +// 电影/无集号条目:媒体库已有该片即视为已存在;剧集条目:对应季集已入库即视为已存在。 +func subscriptionItemAlreadyAvailable(sub *model.Subscription, avail LocalAvailability, title string) bool { + if avail.LocalMediaCount == 0 && !avail.HasSeriesPack { + return false + } + if !isSubscriptionSeriesType(subscriptionMediaType(sub)) { + return true + } + if avail.HasSeriesPack { + return true + } + wantSeason, wantEpisode := ParseEpisode(title) + if wantEpisode <= 0 { + // 整季合集 / 无法解析集号:库里已有内容时保守跳过,避免重复整季下载。 + return true + } + if wantSeason <= 0 { + wantSeason = 1 + } + _, exists := avail.ExistingEpisodeKeys[episodeKey(wantSeason, wantEpisode)] + return exists +}