Skip to content
Open
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
11 changes: 11 additions & 0 deletions cmd/qui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ func (app *Application) runServer() {
}
instanceCrossSeedCompletionStore := models.NewInstanceCrossSeedCompletionStore(db)
crossSeedBlocklistStore := models.NewCrossSeedBlocklistStore(db)
crossSeedPartialPoolStore := models.NewCrossSeedPartialPoolMemberStore(db)
crossSeedService := crossseed.NewService(
instanceStore,
syncManager,
Expand All @@ -632,6 +633,7 @@ func (app *Application) runServer() {
externalProgramStore,
externalProgramService,
instanceCrossSeedCompletionStore,
crossSeedPartialPoolStore,
trackerCustomizationStore,
notificationService,
cfg.Config.CrossSeedRecoverErroredTorrents,
Expand All @@ -651,6 +653,7 @@ func (app *Application) runServer() {
})

syncManager.SetTorrentAddedHandler(func(ctx context.Context, instanceID int, torrent qbt.Torrent) {
crossSeedService.HandleTorrentAdded(ctx, instanceID, torrent)
notifyTorrentAddedWithDelay(ctx, syncManager, notificationService, instanceID, torrent)
})

Expand Down Expand Up @@ -784,6 +787,14 @@ func (app *Application) runServer() {
defer reconcileCancel()
crossSeedService.ReconcileInterruptedRuns(reconcileCtx)

// Restore active partial-pool members with a separate timeout budget so a slow
// reconcile pass doesn't consume the entire startup window.
restorePoolsCtx, restorePoolsCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer restorePoolsCancel()
if err := crossSeedService.RestoreActivePartialPools(restorePoolsCtx); err != nil {
log.Warn().Err(err).Msg("Failed to restore active cross-seed partial pools")
}

errorChannel := make(chan error)
serverReady := make(chan struct{}, 1)
go func() {
Expand Down
15 changes: 15 additions & 0 deletions documentation/docs/features/cross-seed/hardlink-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ By default, hardlink-added torrents start seeding immediately (since `skip_check
- Hardlinks share disk blocks with the original file but increase the link count. Deleting one link does not necessarily free space until all links are removed.
- Windows support: folder names are sanitized to remove characters Windows forbids. Torrent file paths themselves still need to be valid for your qBittorrent setup.
- Hardlink mode supports extra files when piece-boundary safe. If the incoming torrent contains extra files not present in the matched torrent (e.g., `.nfo`/`.srt` sidecars), hardlink mode will link the content files and trigger a recheck so qBittorrent downloads the extras. If extras share pieces with content (unsafe), the cross-seed is skipped.
- If you enable pooled partial completion in the **Hardlink / Reflink Mode** section of the Rules tab, related hardlink adds against the same matched local source torrent can cooperate temporarily. Hardlink automation only continues when post-recheck missing data is limited to whole missing files. If qBittorrent reports missing bytes inside an already linked file, qui leaves that torrent paused for manual review.
- With pooled partial completion enabled, hardlink mode can still add paused even when no files are immediately reusable, then rely on recheck and the pool to decide whether it can continue automatically.

## Reflink Mode (Alternative)

Expand Down Expand Up @@ -117,6 +119,19 @@ On Linux, check the filesystem type with `df -T /path` (you want `xfs`/`btrfs`,
| Piece-boundary check | Skips if unsafe | Never skips (safe to modify clones) |
| Recheck | Only when extras exist | Only when extras exist |
| Disk usage | Zero (shared blocks) | Starts near-zero; grows as modified |
| Single-file size mismatch | Not supported | Optional normalized-name override |

When pooled partial completion is enabled, reflink members may continue even when a file is only partially complete after recheck, as long as the total missing bytes remain within the configured post-recheck limit.

### Single-File Size Mismatch Override

If you enable **Allow reflink single-file size mismatch** in the **Hardlink / Reflink Mode** section, qui can accept a reflink cross-seed when:

- both torrents contain exactly one file;
- the normalized file names match; and
- the sizes differ but are still within 1%.

qui clones the file into the reflink tree, adds the torrent paused, and queues a recheck. If qBittorrent reaches at least **99%** after recheck, qui resumes it automatically. This override is separate from pooled partial completion.

### Disk Usage Implications

Expand Down
2 changes: 2 additions & 0 deletions documentation/docs/features/cross-seed/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ qui supports three modes for handling files:
- **Hardlink mode** (optional): Creates a hardlinked copy of the matched files laid out exactly as the incoming torrent expects, then adds the torrent pointing at that tree. Avoids rename-alignment entirely.
- **Reflink mode** (optional): Creates copy-on-write clones (reflinks) of the matched files. Allows safe cross-seeding of torrents with extra/missing files because qBittorrent can write/repair the clones without affecting originals.

For managed hardlink/reflink adds that need follow-up recheck handling, the **Hardlink / Reflink Mode** section on the Rules tab includes pooled partial completion controls and a reflink-only single-file size mismatch override.

Disc-based media (Blu-ray/DVD) requires manual verification. See [troubleshooting](troubleshooting#blu-ray-or-dvd-cross-seed-left-paused).

## Prerequisites
Expand Down
14 changes: 14 additions & 0 deletions documentation/docs/features/cross-seed/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,24 @@ Configure matching behavior in the **Rules** tab on the Cross-Seed page.
- **Skip recheck** - When enabled, skips any cross-seed that would require a recheck (alignment needed, extra files, or disc layouts like `BDMV`/`VIDEO_TS`). Applies to all modes including hardlink/reflink.
- **Skip piece boundary safety check** - Enabled by default. When enabled, allows cross-seeds even if extra files share torrent pieces with content files. **Warning:** This may corrupt your existing seeded data if content differs. Uncheck this to enable the safety check, or use reflink mode which safely handles these cases.

Managed-link follow-up settings live in **Hardlink / Reflink Mode** on the same Rules tab:

- **Enable pooled partial completion** - Only shown when at least one instance is using hardlink or reflink mode. Applies only to hardlink/reflink adds that already passed the normal acceptance rules and still need coordination after add time, such as non-exact matches, extra files, or disc layouts. Related partial adds against the same matched local source torrent are coordinated as a shared active pool, and active pool state can be restored while the pool remains active.
- **Max missing bytes after recheck** - Shown when pooled partial completion is enabled. Default `100 MiB`. Used only for pooled reflink automation. If a reflink pool member still has more missing bytes than this after recheck, it stays paused for manual review.
- **Allow reflink single-file size mismatch** - Only shown when at least one instance is using reflink mode. Reflink-only escape hatch for one-file torrents where the normalized file names match and the source file size is already within 1% of the incoming size. qui clones the file, forces a recheck, and auto-resumes once qBittorrent reaches 99%. Larger gaps are rejected before add. This path does not use pooled partial completion.

:::note
Disc layouts (`BDMV`/`VIDEO_TS`) are treated more strictly: they only auto-resume after a full recheck reaches 100%.
:::

:::note
For pooled partial completion, hardlink automation only continues when the post-recheck gap is limited to whole missing files and those files are still piece-boundary safe. If bytes are missing inside an existing linked file, qui leaves the torrent paused for manual review. Reflink can continue with partial-file divergence as long as it stays within the byte limit above.
:::

:::note
Pooled partial completion keeps managed hardlink/reflink adds paused while qBittorrent rechecks them. Once the pool no longer needs coordination, normal resume behavior takes over. If you also enable **Skip recheck**, any add that would have relied on pooled handling is skipped instead.
:::

## Categories

Choose one of three mutually exclusive category modes:
Expand Down
18 changes: 18 additions & 0 deletions documentation/docs/features/cross-seed/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ The incoming torrent has files not present in your matched torrent, and those fi
- Verify the "Size mismatch tolerance" setting in Rules
- Torrents below the auto-resume threshold stay paused for manual review

## Pooled partial completion stayed paused

If **Enable pooled partial completion** is on in **Hardlink / Reflink Mode**, qui may intentionally add hardlink/reflink cross-seeds paused and wait for qBittorrent's recheck result before deciding whether to resume them.

Common reasons they remain paused:
- **Hardlink post-recheck gap is inside an existing linked file**: hardlink automation only continues automatically when the remaining gap is made of whole missing files
- **Reflink post-recheck gap exceeds the configured byte limit**: check **Max missing after recheck (MiB)** in **Hardlink / Reflink Mode**
- **Disc layout (`BDMV`/`VIDEO_TS`)**: these are handled more conservatively and require a full successful recheck
- **Skip recheck is enabled**: pooled handling cannot run if the add would have required a recheck

If the result looks safe in qBittorrent, you can resume manually.

## Reflink single-file size mismatch was skipped or stayed paused

The **Allow reflink single-file size mismatch** option in **Hardlink / Reflink Mode** only applies when both torrents contain exactly one file, the normalized file names match, and the sizes are already within 1%. It does not apply to multi-file torrents.

When it does apply, qui adds the torrent paused, queues a recheck, and only auto-resumes once qBittorrent reaches **99%**. If the size gap is larger than 1%, qui rejects it before add. If it still stays below 99% after recheck, leave it paused for manual review.

## Blu-ray or DVD cross-seed left paused

Torrents containing disc-based media (Blu-ray `BDMV` or DVD `VIDEO_TS` folder structures) are always added paused.
Expand Down
123 changes: 80 additions & 43 deletions internal/api/handlers/crossseed.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,29 @@ type CrossSeedHandler struct {

var infoHashRegex = regexp.MustCompile(`^[a-fA-F0-9]{40}$|^[a-fA-F0-9]{64}$`)

const minMaxMissingBytesAfterRecheck = 1024 * 1024

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"`
UseCrossCategoryAffix bool `json:"useCrossCategoryAffix"`
CategoryAffixMode string `json:"categoryAffixMode"`
CategoryAffix string `json:"categoryAffix"`
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"`
UseCrossCategoryAffix bool `json:"useCrossCategoryAffix"`
CategoryAffixMode string `json:"categoryAffixMode"`
CategoryAffix string `json:"categoryAffix"`
UseCustomCategory bool `json:"useCustomCategory"`
CustomCategory string `json:"customCategory"`
RunExternalProgramID *int `json:"runExternalProgramId"`
SkipRecheck bool `json:"skipRecheck"`
EnablePooledPartialCompletion bool `json:"enablePooledPartialCompletion"`
AllowReflinkSingleFileSizeMismatch bool `json:"allowReflinkSingleFileSizeMismatch"`
MaxMissingBytesAfterRecheck int64 `json:"maxMissingBytesAfterRecheck"`
// Gazelle (OPS/RED) cross-seed settings.
GazelleEnabled bool `json:"gazelleEnabled"`
RedactedAPIKey string `json:"redactedApiKey"`
Expand Down Expand Up @@ -90,12 +95,15 @@ type automationSettingsPatchRequest struct {
WebhookTags *[]string `json:"webhookTags,omitempty"`
InheritSourceTags *bool `json:"inheritSourceTags,omitempty"`
// Skip auto-resume settings per source mode
SkipAutoResumeRSS *bool `json:"skipAutoResumeRss,omitempty"`
SkipAutoResumeSeededSearch *bool `json:"skipAutoResumeSeededSearch,omitempty"`
SkipAutoResumeCompletion *bool `json:"skipAutoResumeCompletion,omitempty"`
SkipAutoResumeWebhook *bool `json:"skipAutoResumeWebhook,omitempty"`
SkipRecheck *bool `json:"skipRecheck,omitempty"`
SkipPieceBoundarySafetyCheck *bool `json:"skipPieceBoundarySafetyCheck,omitempty"`
SkipAutoResumeRSS *bool `json:"skipAutoResumeRss,omitempty"`
SkipAutoResumeSeededSearch *bool `json:"skipAutoResumeSeededSearch,omitempty"`
SkipAutoResumeCompletion *bool `json:"skipAutoResumeCompletion,omitempty"`
SkipAutoResumeWebhook *bool `json:"skipAutoResumeWebhook,omitempty"`
SkipRecheck *bool `json:"skipRecheck,omitempty"`
EnablePooledPartialCompletion *bool `json:"enablePooledPartialCompletion,omitempty"`
AllowReflinkSingleFileSizeMismatch *bool `json:"allowReflinkSingleFileSizeMismatch,omitempty"`
MaxMissingBytesAfterRecheck *int64 `json:"maxMissingBytesAfterRecheck,omitempty"`
SkipPieceBoundarySafetyCheck *bool `json:"skipPieceBoundarySafetyCheck,omitempty"`
// Gazelle (OPS/RED) cross-seed settings.
GazelleEnabled *bool `json:"gazelleEnabled,omitempty"`
RedactedAPIKey *string `json:"redactedApiKey,omitempty"`
Expand Down Expand Up @@ -193,12 +201,23 @@ func (r automationSettingsPatchRequest) isEmpty() bool {
r.SkipAutoResumeCompletion == nil &&
r.SkipAutoResumeWebhook == nil &&
r.SkipRecheck == nil &&
r.EnablePooledPartialCompletion == nil &&
r.AllowReflinkSingleFileSizeMismatch == nil &&
r.MaxMissingBytesAfterRecheck == nil &&
r.SkipPieceBoundarySafetyCheck == nil &&
r.GazelleEnabled == nil &&
r.RedactedAPIKey == nil &&
r.OrpheusAPIKey == nil
}

func validateMaxMissingBytesAfterRecheck(w http.ResponseWriter, value int64) bool {
if value < minMaxMissingBytesAfterRecheck {
RespondError(w, http.StatusBadRequest, "maxMissingBytesAfterRecheck must be one MiB or greater")
return false
}
return true
}

func applyAutomationSettingsPatch(settings *models.CrossSeedAutomationSettings, patch automationSettingsPatchRequest) {
if patch.Enabled != nil {
settings.Enabled = *patch.Enabled
Expand Down Expand Up @@ -315,6 +334,15 @@ func applyAutomationSettingsPatch(settings *models.CrossSeedAutomationSettings,
if patch.SkipRecheck != nil {
settings.SkipRecheck = *patch.SkipRecheck
}
if patch.EnablePooledPartialCompletion != nil {
settings.EnablePooledPartialCompletion = *patch.EnablePooledPartialCompletion
}
if patch.AllowReflinkSingleFileSizeMismatch != nil {
settings.AllowReflinkSingleFileSizeMismatch = *patch.AllowReflinkSingleFileSizeMismatch
}
if patch.MaxMissingBytesAfterRecheck != nil {
settings.MaxMissingBytesAfterRecheck = *patch.MaxMissingBytesAfterRecheck
}
if patch.SkipPieceBoundarySafetyCheck != nil {
settings.SkipPieceBoundarySafetyCheck = *patch.SkipPieceBoundarySafetyCheck
}
Expand Down Expand Up @@ -767,6 +795,9 @@ func (h *CrossSeedHandler) UpdateAutomationSettings(w http.ResponseWriter, r *ht
RespondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if !validateMaxMissingBytesAfterRecheck(w, req.MaxMissingBytesAfterRecheck) {
return
}

category := req.Category
if category != nil {
Expand Down Expand Up @@ -828,26 +859,29 @@ func (h *CrossSeedHandler) UpdateAutomationSettings(w http.ResponseWriter, r *ht
}

settings := &models.CrossSeedAutomationSettings{
Enabled: req.Enabled,
RunIntervalMinutes: req.RunIntervalMinutes,
StartPaused: req.StartPaused,
Category: category,
TargetInstanceIDs: req.TargetInstanceIDs,
TargetIndexerIDs: req.TargetIndexerIDs,
MaxResultsPerRun: req.MaxResultsPerRun,
FindIndividualEpisodes: req.FindIndividualEpisodes,
SizeMismatchTolerancePercent: req.SizeMismatchTolerancePercent,
UseCategoryFromIndexer: req.UseCategoryFromIndexer,
UseCrossCategoryAffix: req.UseCrossCategoryAffix,
CategoryAffixMode: req.CategoryAffixMode,
CategoryAffix: req.CategoryAffix,
UseCustomCategory: req.UseCustomCategory,
CustomCategory: req.CustomCategory,
RunExternalProgramID: req.RunExternalProgramID,
SkipRecheck: req.SkipRecheck,
GazelleEnabled: req.GazelleEnabled,
RedactedAPIKey: strings.TrimSpace(req.RedactedAPIKey),
OrpheusAPIKey: strings.TrimSpace(req.OrpheusAPIKey),
Enabled: req.Enabled,
RunIntervalMinutes: req.RunIntervalMinutes,
StartPaused: req.StartPaused,
Category: category,
TargetInstanceIDs: req.TargetInstanceIDs,
TargetIndexerIDs: req.TargetIndexerIDs,
MaxResultsPerRun: req.MaxResultsPerRun,
FindIndividualEpisodes: req.FindIndividualEpisodes,
SizeMismatchTolerancePercent: req.SizeMismatchTolerancePercent,
UseCategoryFromIndexer: req.UseCategoryFromIndexer,
UseCrossCategoryAffix: req.UseCrossCategoryAffix,
CategoryAffixMode: req.CategoryAffixMode,
CategoryAffix: req.CategoryAffix,
UseCustomCategory: req.UseCustomCategory,
CustomCategory: req.CustomCategory,
RunExternalProgramID: req.RunExternalProgramID,
SkipRecheck: req.SkipRecheck,
EnablePooledPartialCompletion: req.EnablePooledPartialCompletion,
AllowReflinkSingleFileSizeMismatch: req.AllowReflinkSingleFileSizeMismatch,
MaxMissingBytesAfterRecheck: req.MaxMissingBytesAfterRecheck,
GazelleEnabled: req.GazelleEnabled,
RedactedAPIKey: strings.TrimSpace(req.RedactedAPIKey),
OrpheusAPIKey: strings.TrimSpace(req.OrpheusAPIKey),
}

updated, err := h.service.UpdateAutomationSettings(r.Context(), settings)
Expand Down Expand Up @@ -888,6 +922,9 @@ func (h *CrossSeedHandler) PatchAutomationSettings(w http.ResponseWriter, r *htt
RespondError(w, http.StatusBadRequest, "No fields provided to update")
return
}
if req.MaxMissingBytesAfterRecheck != nil && !validateMaxMissingBytesAfterRecheck(w, *req.MaxMissingBytesAfterRecheck) {
return
}

// Log what the API received for debugging source filter issues
if req.RSSSourceCategories != nil || req.RSSSourceExcludeCategories != nil ||
Expand Down
Loading
Loading