Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 129 additions & 19 deletions internal/services/crossseed/matching.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Discovery metadata path currently skips CRC (Sum) compatibility.

Strict matching rejects differing/missing Sum when source has one, but discovery mode no longer checks it. That widens discovery to files known to be different (especially anime CRC-tagged releases).

💡 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
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/matching.go` around lines 440 - 475,
discoveryMetadataMatch currently omits CRC/Sum checks allowing mismatched
releases to match; after the version checks in discoveryMetadataMatch, normalize
source.Sum and candidate.Sum using the same normalizer (e.g., sourceSum :=
normalizer.Normalize(source.Sum), candidateSum :=
normalizer.Normalize(candidate.Sum)) and enforce the same logic as version: if
(sourceSum == "") != (candidateSum == "") return false; if sourceSum != "" &&
candidateSum != "" && sourceSum != candidateSum return false; this ensures
discovery path respects Sum compatibility like strict matching.

}

// 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 {
Expand Down
180 changes: 180 additions & 0 deletions internal/services/crossseed/matching_discovery_test.go
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))
})
}
6 changes: 3 additions & 3 deletions internal/services/crossseed/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3151,7 +3151,7 @@ func (s *Service) findCandidates(ctx context.Context, req *FindCandidatesRequest
}

// Check if releases are related (quick filter)
if !s.releasesMatch(targetRelease, candidateRelease, req.FindIndividualEpisodes) {
if !s.releasesMatchDiscovery(targetRelease, candidateRelease, req.FindIndividualEpisodes) {
continue
}

Expand Down Expand Up @@ -6419,7 +6419,7 @@ func (s *Service) searchTorrentMatches(ctx context.Context, instanceID int, hash
}

candidateRelease := s.releaseCache.Parse(res.Title)
if !s.releasesMatch(searchRelease, candidateRelease, opts.FindIndividualEpisodes) {
if !s.releasesMatchDiscovery(searchRelease, candidateRelease, opts.FindIndividualEpisodes) {
releaseFilteredCount++
continue
}
Expand Down Expand Up @@ -9776,7 +9776,7 @@ func (s *Service) CheckWebhook(ctx context.Context, req *WebhookCheckRequest) (*
}

// Check if releases match using the configured strict or episode-aware matching.
if !s.releasesMatch(incomingRelease, existingRelease, findIndividualEpisodes) {
if !s.releasesMatchDiscovery(incomingRelease, existingRelease, findIndividualEpisodes) {
continue
}

Expand Down
Loading