-
-
Notifications
You must be signed in to change notification settings - Fork 105
fix(crossseed): relax discovery matching #1592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from 3 commits
47f975c
065c526
c3cd7ef
5326525
b882e0a
a2837eb
2d83845
2239fe8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -116,30 +116,22 @@ func (k releaseKey) String() string { | |
| return fmt.Sprintf("%d|%d|%d|%d|%d", k.series, k.episode, k.year, k.month, k.day) | ||
| } | ||
|
|
||
| // releasesMatch checks if two releases are related using fuzzy matching. | ||
| // This allows matching similar content that isn't exactly the same. | ||
| // releasesMatch keeps the historical strict identity semantics used by local | ||
| // match views, deduplication, and other places that must avoid widening. | ||
| func (s *Service) releasesMatch(source, candidate *rls.Release, findIndividualEpisodes bool) bool { | ||
| if source == candidate { | ||
| return true | ||
| } | ||
| return s.releasesMatchWithDiscovery(source, candidate, findIndividualEpisodes, false) | ||
| } | ||
|
|
||
| // Title should match closely but not necessarily exactly. | ||
| // Use punctuation-stripping normalization to handle differences like | ||
| // "Bob's Burgers" vs "Bobs.Burgers" (apostrophes lost in dot notation). | ||
| sourceTitleNorm := stringutils.NormalizeForMatching(source.Title) | ||
| candidateTitleNorm := stringutils.NormalizeForMatching(candidate.Title) | ||
| func (s *Service) releasesMatchDiscovery(source, candidate *rls.Release, findIndividualEpisodes bool) bool { | ||
| return s.releasesMatchWithDiscovery(source, candidate, findIndividualEpisodes, true) | ||
| } | ||
|
|
||
| if sourceTitleNorm == "" || candidateTitleNorm == "" { | ||
| return false | ||
| func (s *Service) releasesMatchWithDiscovery(source, candidate *rls.Release, findIndividualEpisodes bool, allowDiscoveryRelaxations bool) bool { | ||
| if source == candidate { | ||
| return true | ||
| } | ||
|
|
||
| // Require exact title match after normalization. | ||
| // | ||
| // This is intentionally strict to avoid false positives between related-but-distinct | ||
| // TV franchises/spinoffs (e.g. "FBI" vs "FBI Most Wanted") where substring matching | ||
| // would incorrectly treat them as the same show. | ||
| if sourceTitleNorm != candidateTitleNorm { | ||
| // Title mismatches are expected for most candidates - don't log to avoid noise | ||
| if !titlesMatch(source.Title, candidate.Title, allowDiscoveryRelaxations) { | ||
| return false | ||
| } | ||
|
|
||
|
|
@@ -214,6 +206,10 @@ func (s *Service) releasesMatch(source, candidate *rls.Release, findIndividualEp | |
| } | ||
| } | ||
|
|
||
| if allowDiscoveryRelaxations { | ||
| return discoveryMetadataMatch(s, source, candidate) | ||
| } | ||
|
|
||
| // Group tags should match for proper cross-seeding compatibility. | ||
| // Different release groups often have different encoding settings and file structures. | ||
| sourceGroup := s.stringNormalizer.Normalize((source.Group)) | ||
|
|
@@ -393,6 +389,120 @@ func (s *Service) releasesMatch(source, candidate *rls.Release, findIndividualEp | |
| return true | ||
| } | ||
|
|
||
| func titlesMatch(sourceTitle, candidateTitle string, allowDiscoveryRelaxations bool) bool { | ||
| sourceTitleNorm := stringutils.NormalizeForMatching(sourceTitle) | ||
| candidateTitleNorm := stringutils.NormalizeForMatching(candidateTitle) | ||
| if sourceTitleNorm == "" || candidateTitleNorm == "" { | ||
| return false | ||
| } | ||
| if sourceTitleNorm == candidateTitleNorm { | ||
| return true | ||
| } | ||
| if !allowDiscoveryRelaxations { | ||
| return false | ||
| } | ||
|
|
||
| sourceDiscovery := normalizeDiscoveryTitle(sourceTitleNorm) | ||
| candidateDiscovery := normalizeDiscoveryTitle(candidateTitleNorm) | ||
| return sourceDiscovery != "" && sourceDiscovery == candidateDiscovery | ||
| } | ||
|
|
||
| func normalizeDiscoveryTitle(title string) string { | ||
| tokens := strings.Fields(title) | ||
| if len(tokens) == 0 { | ||
| return "" | ||
| } | ||
|
|
||
| filtered := tokens[:0] | ||
| for _, token := range tokens { | ||
| switch strings.TrimSpace(strings.ToLower(token)) { | ||
| case "", "us", "uk", "au", "ca", "jp", "kr", "cn", "de", "fr", "es", "it", "se", "no", "fi", "dk", "nl", "be": | ||
| continue | ||
| } | ||
| ignorable := true | ||
| for _, r := range token { | ||
| if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { | ||
| ignorable = false | ||
| break | ||
| } | ||
| } | ||
| if ignorable { | ||
| continue | ||
| } | ||
| filtered = append(filtered, token) | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return strings.Join(filtered, " ") | ||
| } | ||
|
|
||
| func discoveryMetadataMatch(s *Service, source, candidate *rls.Release) bool { | ||
| normalizer := s.stringNormalizer | ||
| if normalizer == nil { | ||
| normalizer = stringutils.DefaultNormalizer | ||
| } | ||
|
|
||
| sourceGroup := normalizer.Normalize(source.Group) | ||
| candidateGroup := normalizer.Normalize(candidate.Group) | ||
| if sourceGroup != "" && candidateGroup != "" { | ||
| if !discoveryReleaseGroupCompatible(normalizer, source.Group, candidate.Group) { | ||
| return false | ||
| } | ||
| } else if !discoveryReleaseGroupCompatible(normalizer, source.Site, candidate.Site) { | ||
| return false | ||
| } | ||
|
|
||
| sourceSource := normalizeSource(source.Source) | ||
| candidateSource := normalizeSource(candidate.Source) | ||
| if !sourcesCompatible(sourceSource, candidateSource) { | ||
| return false | ||
| } | ||
|
|
||
| sourceRes := normalizer.Normalize(source.Resolution) | ||
| candidateRes := normalizer.Normalize(candidate.Resolution) | ||
| if sourceRes != candidateRes { | ||
| isKnownSD := func(res string) bool { | ||
| switch normalizeVariant(res) { | ||
| case "480P", "576P", "SD": | ||
| return true | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| sdFallbackAllowed := (sourceRes == "" && isKnownSD(candidateRes)) || (candidateRes == "" && isKnownSD(sourceRes)) | ||
| if !sdFallbackAllowed { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| sourceVersion := normalizer.Normalize(source.Version) | ||
| candidateVersion := normalizer.Normalize(candidate.Version) | ||
| if (sourceVersion == "") != (candidateVersion == "") { | ||
| return false | ||
| } | ||
| if sourceVersion != "" && candidateVersion != "" && sourceVersion != candidateVersion { | ||
| return false | ||
| } | ||
|
|
||
| return true | ||
|
Comment on lines
+440
to
+475
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Discovery metadata path currently skips CRC ( Strict matching rejects differing/missing 💡 Proposed fix func discoveryMetadataMatch(s *Service, source, candidate *rls.Release, sourceCtx, candidateCtx resolutionMatchContext) bool {
@@
sourceSource := normalizeSource(source.Source)
candidateSource := normalizeSource(candidate.Source)
if !sourcesCompatible(sourceSource, candidateSource) {
return false
}
+
+ sourceSum := normalizer.Normalize(source.Sum)
+ candidateSum := normalizer.Normalize(candidate.Sum)
+ if sourceSum != "" {
+ if candidateSum == "" || sourceSum != candidateSum {
+ return false
+ }
+ }
@@
if !resolutionsCompatible(normalizer, source, candidate, sourceCtx, candidateCtx) {
return false
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // discoveryReleaseGroupCompatible is intentionally permissive for discovery filtering. | ||
| // It uses strings.HasPrefix on normalized values, so "FLUX" and "FLUXUS" are treated | ||
| // as compatible here and stricter downstream file verification decides the final match. | ||
| func discoveryReleaseGroupCompatible(normalizer *stringutils.Normalizer[string, string], sourceValue, candidateValue string) bool { | ||
| if normalizer == nil { | ||
| normalizer = stringutils.DefaultNormalizer | ||
| } | ||
|
|
||
| sourceNorm := normalizer.Normalize(sourceValue) | ||
| candidateNorm := normalizer.Normalize(candidateValue) | ||
| if sourceNorm == "" || candidateNorm == "" { | ||
| return true | ||
| } | ||
|
|
||
| return strings.HasPrefix(sourceNorm, candidateNorm) || strings.HasPrefix(candidateNorm, sourceNorm) | ||
| } | ||
|
|
||
| // joinNormalizedSlice converts a string slice to a normalized uppercase string for comparison. | ||
| // Uppercases and joins elements to ensure consistent comparison regardless of case or order. | ||
| func joinNormalizedSlice(slice []string) string { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| // Copyright (c) 2025-2026, s0up and the autobrr contributors. | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
|
|
||
| package crossseed | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/moistari/rls" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/autobrr/qui/pkg/stringutils" | ||
| ) | ||
|
|
||
| func TestReleasesMatchDiscovery_AllowsMissingGroupWithSameCoreRelease(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svc := &Service{stringNormalizer: stringutils.NewDefaultNormalizer()} | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "Gladiator", | ||
| Year: 2000, | ||
| Group: "UBWEB", | ||
| Source: "WEB-DL", | ||
| Resolution: "2160p", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "Gladiator", | ||
| Year: 2000, | ||
| Source: "WEB-DL", | ||
| Resolution: "2160p", | ||
| } | ||
|
|
||
| require.False(t, svc.releasesMatch(source, candidate, false)) | ||
| require.True(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| } | ||
|
|
||
| func TestReleasesMatchDiscovery_IgnoresBilingualAndRegionTitleNoise(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svc := &Service{stringNormalizer: stringutils.NewDefaultNormalizer()} | ||
|
|
||
| t.Run("bilingual title", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "角斗士 Gladiator", | ||
| Year: 2000, | ||
| Group: "UBWEB", | ||
| Source: "WEB-DL", | ||
| Resolution: "2160p", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "Gladiator", | ||
| Year: 2000, | ||
| Source: "WEB-DL", | ||
| Resolution: "2160p", | ||
| } | ||
|
|
||
| require.False(t, svc.releasesMatch(source, candidate, false)) | ||
| require.True(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| }) | ||
|
|
||
| t.Run("region suffix", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "Doc US", | ||
| Series: 2, | ||
| Episode: 17, | ||
| Group: "Kitsune", | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "Doc", | ||
| Series: 2, | ||
| Episode: 17, | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| } | ||
|
|
||
| require.False(t, svc.releasesMatch(source, candidate, false)) | ||
| require.True(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| }) | ||
| } | ||
|
|
||
| func TestReleasesMatchDiscovery_StillRejectsDistinctSpinoffs(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svc := &Service{stringNormalizer: stringutils.NewDefaultNormalizer()} | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "FBI", | ||
| Series: 1, | ||
| Episode: 1, | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "FBI Most Wanted", | ||
| Series: 1, | ||
| Episode: 1, | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| } | ||
|
|
||
| require.False(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| } | ||
|
|
||
| func TestReleasesMatchDiscovery_KeepsSourceAndVersionBoundaries(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svc := &Service{stringNormalizer: stringutils.NewDefaultNormalizer()} | ||
|
|
||
| t.Run("source mismatch", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "Movie", | ||
| Year: 2025, | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "Movie", | ||
| Year: 2025, | ||
| Source: "BluRay", | ||
| Resolution: "1080p", | ||
| } | ||
|
|
||
| require.False(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| }) | ||
|
|
||
| t.Run("version mismatch", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "Show", | ||
| Series: 1, | ||
| Episode: 4, | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| Version: "v2", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "Show", | ||
| Series: 1, | ||
| Episode: 4, | ||
| Source: "WEB-DL", | ||
| Resolution: "1080p", | ||
| } | ||
|
|
||
| require.False(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| }) | ||
| } | ||
|
|
||
| func TestReleasesMatchDiscovery_UsesDefaultNormalizerFallback(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| svc := &Service{} | ||
|
|
||
| source := &rls.Release{ | ||
| Title: "Gladiator", | ||
| Year: 2000, | ||
| Group: "UBWEB", | ||
| Source: "WEB-DL", | ||
| Resolution: "2160p", | ||
| } | ||
| candidate := &rls.Release{ | ||
| Title: "Gladiator", | ||
| Year: 2000, | ||
| Source: "WEB-DL", | ||
| Resolution: "2160p", | ||
| } | ||
|
|
||
| require.NotPanics(t, func() { | ||
| require.True(t, svc.releasesMatchDiscovery(source, candidate, false)) | ||
| }) | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.