Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
115 changes: 76 additions & 39 deletions internal/api/handlers/crossseed.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,29 +30,29 @@ type CrossSeedHandler struct {
}

type automationSettingsRequest struct {
Enabled bool `json:"enabled"`
RunIntervalMinutes int `json:"runIntervalMinutes"`
StartPaused bool `json:"startPaused"`
Category *string `json:"category"`
TargetInstanceIDs []int `json:"targetInstanceIds"`
TargetIndexerIDs []int `json:"targetIndexerIds"`
MaxResultsPerRun int `json:"maxResultsPerRun"` // Deprecated: automation now processes full feeds and ignores this value
FindIndividualEpisodes bool `json:"findIndividualEpisodes"`
SizeMismatchTolerancePercent float64 `json:"sizeMismatchTolerancePercent"`
UseCategoryFromIndexer bool `json:"useCategoryFromIndexer"`
UseCrossCategorySuffix bool `json:"useCrossCategorySuffix"`
UseCustomCategory bool `json:"useCustomCategory"`
CustomCategory string `json:"customCategory"`
RunExternalProgramID *int `json:"runExternalProgramId"`
SkipRecheck bool `json:"skipRecheck"`
Enabled bool `json:"enabled"`
RunIntervalMinutes int `json:"runIntervalMinutes"`
StartPaused bool `json:"startPaused"`
Category *string `json:"category"`
TargetInstanceIDs []int `json:"targetInstanceIds"`
TargetIndexerIDs []int `json:"targetIndexerIds"`
MaxResultsPerRun int `json:"maxResultsPerRun"` // Deprecated: automation now processes full feeds and ignores this value
FindIndividualEpisodes bool `json:"findIndividualEpisodes"`
SizeMismatchTolerancePercent float64 `json:"sizeMismatchTolerancePercent"`
UseCategoryFromIndexer bool `json:"useCategoryFromIndexer"`
UseCrossCategorySuffix bool `json:"useCrossCategorySuffix"`
UseCustomCategory bool `json:"useCustomCategory"`
CustomCategory string `json:"customCategory"`
RunExternalProgramID *int `json:"runExternalProgramId"`
SkipRecheck bool `json:"skipRecheck"`
}

type automationSettingsPatchRequest struct {
Enabled *bool `json:"enabled,omitempty"`
RunIntervalMinutes *int `json:"runIntervalMinutes,omitempty"`
StartPaused *bool `json:"startPaused,omitempty"`
Category optionalString `json:"category"`
TargetInstanceIDs *[]int `json:"targetInstanceIds,omitempty"`
Category optionalString `json:"category"`
TargetInstanceIDs *[]int `json:"targetInstanceIds,omitempty"`
TargetIndexerIDs *[]int `json:"targetIndexerIds,omitempty"`
MaxResultsPerRun *int `json:"maxResultsPerRun,omitempty"` // Deprecated: automation now processes full feeds and ignores this value
// RSS source filtering: filter which local torrents to search when checking RSS feeds
Expand Down Expand Up @@ -329,6 +329,7 @@ func (h *CrossSeedHandler) Routes(r chi.Router) {
r.Route("/torrents", func(r chi.Router) {
r.Get("/{instanceID}/{hash}/analyze", h.AnalyzeTorrentForSearch)
r.Get("/{instanceID}/{hash}/async-status", h.GetAsyncFilteringStatus)
r.Get("/{instanceID}/{hash}/local-matches", h.GetLocalMatches)
r.Post("/{instanceID}/{hash}/search", h.SearchTorrentMatches)
r.Post("/{instanceID}/{hash}/apply", h.ApplyTorrentSearchResults)
})
Expand Down Expand Up @@ -357,6 +358,25 @@ func (h *CrossSeedHandler) Routes(r chi.Router) {
})
}

// parseTorrentParams extracts and validates instanceID and hash from URL parameters.
// Returns instanceID, hash, and ok (false if validation failed and error response was sent).
func parseTorrentParams(w http.ResponseWriter, r *http.Request) (instanceID int, hash string, ok bool) {
instanceIDStr := chi.URLParam(r, "instanceID")
id, err := strconv.Atoi(instanceIDStr)
if err != nil || id <= 0 {
RespondError(w, http.StatusBadRequest, "instanceID must be a positive integer")
return 0, "", false
}

h := strings.TrimSpace(chi.URLParam(r, "hash"))
if h == "" {
RespondError(w, http.StatusBadRequest, "hash is required")
return 0, "", false
}

return id, h, true
}

// AnalyzeTorrentForSearch godoc
// @Summary Analyze torrent for cross-seed search metadata
// @Description Returns metadata about how a torrent would be searched (content type, search type, required categories/capabilities) without performing the actual search
Expand All @@ -370,16 +390,8 @@ func (h *CrossSeedHandler) Routes(r chi.Router) {
// @Security ApiKeyAuth
// @Router /api/cross-seed/torrents/{instanceID}/{hash}/analyze [get]
func (h *CrossSeedHandler) AnalyzeTorrentForSearch(w http.ResponseWriter, r *http.Request) {
instanceIDStr := chi.URLParam(r, "instanceID")
instanceID, err := strconv.Atoi(instanceIDStr)
if err != nil || instanceID <= 0 {
RespondError(w, http.StatusBadRequest, "instanceID must be a positive integer")
return
}

hash := strings.TrimSpace(chi.URLParam(r, "hash"))
if hash == "" {
RespondError(w, http.StatusBadRequest, "hash is required")
instanceID, hash, ok := parseTorrentParams(w, r)
if !ok {
return
}

Expand Down Expand Up @@ -411,16 +423,8 @@ func (h *CrossSeedHandler) AnalyzeTorrentForSearch(w http.ResponseWriter, r *htt
// @Security ApiKeyAuth
// @Router /api/cross-seed/torrents/{instanceID}/{hash}/async-status [get]
func (h *CrossSeedHandler) GetAsyncFilteringStatus(w http.ResponseWriter, r *http.Request) {
instanceIDStr := chi.URLParam(r, "instanceID")
instanceID, err := strconv.Atoi(instanceIDStr)
if err != nil || instanceID <= 0 {
RespondError(w, http.StatusBadRequest, "instanceID must be a positive integer")
return
}

hash := strings.TrimSpace(chi.URLParam(r, "hash"))
if hash == "" {
RespondError(w, http.StatusBadRequest, "hash is required")
instanceID, hash, ok := parseTorrentParams(w, r)
if !ok {
return
}

Expand All @@ -439,6 +443,39 @@ func (h *CrossSeedHandler) GetAsyncFilteringStatus(w http.ResponseWriter, r *htt
RespondJSON(w, http.StatusOK, filteringState)
}

// GetLocalMatches godoc
// @Summary Find existing torrents that match the source torrent across all instances
// @Description Returns torrents from all instances that match the source torrent using proper release metadata parsing (rls library), not fuzzy string matching.
// @Tags cross-seed
// @Produce json
// @Param instanceID path int true "Source instance ID"
// @Param hash path string true "Source torrent hash"
// @Success 200 {object} crossseed.LocalMatchesResponse
// @Failure 400 {object} httphelpers.ErrorResponse
// @Failure 500 {object} httphelpers.ErrorResponse
// @Security ApiKeyAuth
// @Router /api/cross-seed/torrents/{instanceID}/{hash}/local-matches [get]
func (h *CrossSeedHandler) GetLocalMatches(w http.ResponseWriter, r *http.Request) {
instanceID, hash, ok := parseTorrentParams(w, r)
if !ok {
return
}

response, err := h.service.FindLocalMatches(r.Context(), instanceID, hash)
if err != nil {
status := mapCrossSeedErrorStatus(err)
log.Error().
Err(err).
Int("instanceID", instanceID).
Str("hash", hash).
Msg("Failed to find local cross-seed matches")
RespondError(w, status, err.Error())
return
}

RespondJSON(w, http.StatusOK, response)
}

// SearchTorrentMatches godoc
// @Summary Search Torznab indexers for cross-seed matches for a specific torrent
// @Description Uses the seeded torrent's metadata to find compatible releases on the configured Torznab indexers.
Expand Down Expand Up @@ -694,8 +731,8 @@ func (h *CrossSeedHandler) UpdateAutomationSettings(w http.ResponseWriter, r *ht
Enabled: req.Enabled,
RunIntervalMinutes: req.RunIntervalMinutes,
StartPaused: req.StartPaused,
Category: category,
TargetInstanceIDs: req.TargetInstanceIDs,
Category: category,
TargetInstanceIDs: req.TargetInstanceIDs,
TargetIndexerIDs: req.TargetIndexerIDs,
MaxResultsPerRun: req.MaxResultsPerRun,
FindIndividualEpisodes: req.FindIndividualEpisodes,
Expand Down
33 changes: 23 additions & 10 deletions internal/qbittorrent/sync_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,13 @@ type OptimisticTorrentUpdate struct {
// NewSyncManager creates a new sync manager
func NewSyncManager(clientPool *ClientPool) *SyncManager {
sm := &SyncManager{
clientPool: clientPool,
exprCache: ttlcache.New(ttlcache.Options[string, *vm.Program]{}.SetDefaultTTL(5 * time.Minute)),
debouncedSyncTimers: make(map[int]*time.Timer),
syncDebounceDelay: 200 * time.Millisecond,
syncDebounceMinJitter: 10 * time.Millisecond,
fileFetchSem: make(map[int]chan struct{}),
fileFetchMaxConcurrent: 16,
clientPool: clientPool,
exprCache: ttlcache.New(ttlcache.Options[string, *vm.Program]{}.SetDefaultTTL(5 * time.Minute)),
debouncedSyncTimers: make(map[int]*time.Timer),
syncDebounceDelay: 200 * time.Millisecond,
syncDebounceMinJitter: 10 * time.Millisecond,
fileFetchSem: make(map[int]chan struct{}),
fileFetchMaxConcurrent: 16,
trackerHealthCache: make(map[int]*TrackerHealthCounts),
trackerHealthCancel: make(map[int]context.CancelFunc),
trackerHealthRefresh: 60 * time.Second,
Expand Down Expand Up @@ -1170,12 +1170,25 @@ func (sm *SyncManager) GetCachedInstanceTorrents(ctx context.Context, instanceID
return nil, nil
}

// Get cached tracker health counts for this instance
cachedHealth := sm.GetTrackerHealthCounts(instanceID)

views := make([]CrossInstanceTorrentView, len(torrents))
for i, torrent := range torrents {
view := TorrentView{Torrent: torrent}
// First try to determine health from enriched tracker data
if health := sm.determineTrackerHealth(torrent); health != "" {
view.TrackerHealth = health
} else if cachedHealth != nil {
// Fall back to cached hash sets if torrent wasn't enriched
if _, ok := cachedHealth.UnregisteredSet[torrent.Hash]; ok {
view.TrackerHealth = TrackerHealthUnregistered
} else if _, ok := cachedHealth.TrackerDownSet[torrent.Hash]; ok {
view.TrackerHealth = TrackerHealthDown
}
}
views[i] = CrossInstanceTorrentView{
TorrentView: TorrentView{
Torrent: torrent,
},
TorrentView: view,
InstanceID: instance.ID,
InstanceName: instance.Name,
}
Expand Down
30 changes: 30 additions & 0 deletions internal/services/crossseed/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import (
"github.com/autobrr/qui/internal/services/jackett"
)

// Local match type constants for determineLocalMatchType.
const (
matchTypeContentPath = "content_path"
matchTypeName = "name"
matchTypeRelease = "release"
)

// CrossSeedRequest represents a request to cross-seed a torrent
type CrossSeedRequest struct {
// TorrentData is the base64-encoded torrent file
Expand Down Expand Up @@ -282,6 +289,29 @@ type ApplyTorrentSearchResponse struct {
Results []TorrentSearchAddResult `json:"results"`
}

// LocalMatchesResponse contains torrents from all instances that match a source torrent.
type LocalMatchesResponse struct {
Matches []LocalMatch `json:"matches"`
}

// LocalMatch represents a torrent that matches the source across instances.
type LocalMatch struct {
InstanceID int `json:"instance_id"`
InstanceName string `json:"instance_name"`
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Progress float64 `json:"progress"`
SavePath string `json:"save_path"`
ContentPath string `json:"content_path"`
Category string `json:"category"`
Tags string `json:"tags"`
State string `json:"state"`
Tracker string `json:"tracker"`
TrackerHealth string `json:"tracker_health,omitempty"`
MatchType string `json:"match_type"` // "infohash", "content_path", "name", "release"
}
Comment thread
s0up4200 marked this conversation as resolved.

// AsyncIndexerFilteringState represents the state of async indexer filtering operations
type AsyncIndexerFilteringState struct {
sync.RWMutex `json:"-"`
Expand Down
105 changes: 105 additions & 0 deletions internal/services/crossseed/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,111 @@ func (s *Service) HealthCheck(ctx context.Context) error {
return nil
}

// FindLocalMatches finds existing torrents across all instances that match a source torrent.
// Uses proper release metadata parsing (rls library) for matching, not fuzzy string matching.
func (s *Service) FindLocalMatches(ctx context.Context, sourceInstanceID int, sourceHash string) (*LocalMatchesResponse, error) {
// Get all instances
instances, err := s.instanceStore.List(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list instances: %w", err)
}

// Find the source torrent
sourceTorrents, err := s.syncManager.GetTorrents(ctx, sourceInstanceID, qbt.TorrentFilterOptions{
Filter: "all",
Hashes: []string{sourceHash},
})
if err != nil {
return nil, fmt.Errorf("failed to get source torrent: %w", err)
}
if len(sourceTorrents) == 0 {
return nil, ErrTorrentNotFound
}
sourceTorrent := sourceTorrents[0]

// Parse source release for matching
sourceRelease := s.releaseCache.Parse(sourceTorrent.Name)

// Normalize content path for comparison
normalizedContentPath := strings.ToLower(strings.ReplaceAll(sourceTorrent.ContentPath, "\\", "/"))

var matches []LocalMatch

for _, instance := range instances {
// Get all torrents from this instance using cached data
cachedTorrents, err := s.syncManager.GetCachedInstanceTorrents(ctx, instance.ID)
if err != nil {
log.Warn().Err(err).Int("instanceID", instance.ID).Msg("Failed to get cached torrents for instance")
continue
}

for i := range cachedTorrents {
cached := &cachedTorrents[i]
// Skip the exact source torrent
if instance.ID == sourceInstanceID && normalizeHash(cached.Hash) == normalizeHash(sourceHash) {
continue
}

// Determine match type
matchType := s.determineLocalMatchType(
&sourceTorrent, sourceRelease,
cached, normalizedContentPath,
)

if matchType != "" {
matches = append(matches, LocalMatch{
InstanceID: instance.ID,
InstanceName: instance.Name,
Hash: cached.Hash,
Name: cached.Name,
Size: cached.Size,
Progress: cached.Progress,
SavePath: cached.SavePath,
ContentPath: cached.ContentPath,
Category: cached.Category,
Tags: cached.Tags,
State: string(cached.State),
Tracker: cached.Tracker,
TrackerHealth: string(cached.TrackerHealth),
MatchType: matchType,
})
}
}
}

return &LocalMatchesResponse{Matches: matches}, nil
}

// determineLocalMatchType checks if a candidate torrent matches the source.
// Returns the match type ("infohash", "content_path", "name", "release") or empty string if no match.
func (s *Service) determineLocalMatchType(
source *qbt.Torrent,
sourceRelease *rls.Release,
candidate *qbittorrent.CrossInstanceTorrentView,
normalizedContentPath string,
) string {
// Strategy 1: Same content path
candidateContentPath := strings.ToLower(strings.ReplaceAll(candidate.ContentPath, "\\", "/"))
if normalizedContentPath != "" && candidateContentPath != "" && normalizedContentPath == candidateContentPath {
return matchTypeContentPath
}

// Strategy 2: Exact name match
sourceName := strings.ToLower(strings.TrimSpace(source.Name))
candidateName := strings.ToLower(strings.TrimSpace(candidate.Name))
if sourceName != "" && candidateName != "" && sourceName == candidateName {
return matchTypeName
}

// Strategy 3: Release metadata match using rls library
candidateRelease := s.releaseCache.Parse(candidate.Name)
if s.releasesMatch(sourceRelease, candidateRelease, false) {
return matchTypeRelease
}

return ""
}
Comment thread
s0up4200 marked this conversation as resolved.

// ErrAutomationRunning indicates a cross-seed automation run is already in progress.
var ErrAutomationRunning = errors.New("cross-seed automation already running")

Expand Down
Loading
Loading