Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 5 additions & 2 deletions documentation/docs/features/cross-seed/hardlink-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Configure in Cross-Seed → Hardlink Mode → (select instance):
- `flat`: `base/TorrentName--shortHash/...`
- `by-tracker`: `base/<tracker>/TorrentName--shortHash/...`
- `by-instance`: `base/<instance>/TorrentName--shortHash/...`
- **Instance directory name**: Optional override used only with `by-instance`. Leave empty to use the instance name; set it when you want layouts like `movies-xseed` or `series-xseed`.

### Isolation Folders

Expand All @@ -67,7 +68,8 @@ For the `flat` preset, an isolation folder is always used to keep each torrent's
- Single filesystem: `/mnt/data/cross-seed`
- Multiple filesystems: `/mnt/disk1/cross-seed, /mnt/disk2/cross-seed, /mnt/disk3/cross-seed`
5. Choose a directory preset (`flat`, `by-tracker`, `by-instance`).
6. Optionally enable "Fallback to regular mode" if you want failed hardlinks to use regular cross-seed mode instead of failing.
6. If you use `by-instance`, optionally set "Instance directory name" to override the folder name under the shared base directory.
7. Optionally enable "Fallback to regular mode" if you want failed hardlinks to use regular cross-seed mode instead of failing.

## Pause Behavior

Expand Down Expand Up @@ -129,7 +131,8 @@ Reflinks use copy-on-write semantics:
- Single filesystem: `/mnt/data/cross-seed`
- Multiple filesystems: `/mnt/disk1/cross-seed, /mnt/disk2/cross-seed`
5. Choose a directory preset (`flat`, `by-tracker`, `by-instance`).
6. Optionally enable "Fallback to regular mode" if you want failed reflinks to use regular cross-seed mode instead of failing.
6. If you use `by-instance`, optionally set "Instance directory name" to override the folder name under the shared base directory.
7. Optionally enable "Fallback to regular mode" if you want failed reflinks to use regular cross-seed mode instead of failing.

:::note
Hardlink and reflink modes are mutually exclusive—only one can be enabled per instance.
Expand Down
3 changes: 3 additions & 0 deletions documentation/docs/features/cross-seed/link-directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Configured per qBittorrent instance in **Cross-Seed → Hardlink Mode**:

- **Base directory** (`HardlinkBaseDir`): root path where link trees are created.
- **Directory preset** (`HardlinkDirPreset`): controls how trees are grouped below the base directory.
- **Instance directory name** (`LinkDirName`): optional override for the folder name used by the `by-instance` preset.
- **Fallback to regular mode** (`FallbackToRegularMode`): if link-tree creation fails, qui can fall back to “regular mode” instead of skipping/failing.

## Directory Presets
Expand All @@ -33,6 +34,8 @@ qui supports three presets:
- `by-instance`: groups by instance name, then optional isolation folder
- Example: `base/MyInstance/Torrent.Name--abcdef12/...`

When `LinkDirName` is set, `by-instance` uses that value instead of the instance name.

### Tracker Names (by-tracker)

For `by-tracker`, qui resolves the folder name using the same fallback chain as cross-seed statistics:
Expand Down
9 changes: 9 additions & 0 deletions documentation/static/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,9 @@ paths:
type: string
enum: [flat, by-tracker, by-instance]
description: Directory organization preset for hardlink mode.
linkDirName:
type: string
description: Optional override for the folder name used by the by-instance hardlink/reflink preset.
responses:
'201':
description: Instance created
Expand Down Expand Up @@ -930,6 +933,9 @@ paths:
type: string
enum: [flat, by-tracker, by-instance]
description: Directory organization preset for hardlink mode.
linkDirName:
type: string
description: Optional override for the folder name used by the by-instance hardlink/reflink preset.
responses:
'200':
description: Instance updated
Expand Down Expand Up @@ -4076,6 +4082,9 @@ components:
type: string
enum: [flat, by-tracker, by-instance]
description: Directory organization preset for hardlink mode.
linkDirName:
type: string
description: Optional override for the folder name used by the by-instance hardlink/reflink preset.
sortOrder:
type: integer
description: Display order preference for the instance.
Expand Down
84 changes: 68 additions & 16 deletions internal/api/handlers/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ func (h *InstancesHandler) buildInstanceResponsesParallel(ctx context.Context, i
UseHardlinks: instances[i].UseHardlinks,
HardlinkBaseDir: instances[i].HardlinkBaseDir,
HardlinkDirPreset: instances[i].HardlinkDirPreset,
LinkDirName: instances[i].LinkDirName,
UseReflinks: instances[i].UseReflinks,
FallbackToRegularMode: instances[i].FallbackToRegularMode,
Connected: false,
HasDecryptionError: false,
SortOrder: instances[i].SortOrder,
Expand Down Expand Up @@ -255,6 +257,7 @@ func (h *InstancesHandler) buildInstanceResponse(ctx context.Context, instance *
UseHardlinks: instance.UseHardlinks,
HardlinkBaseDir: instance.HardlinkBaseDir,
HardlinkDirPreset: instance.HardlinkDirPreset,
LinkDirName: instance.LinkDirName,
UseReflinks: instance.UseReflinks,
FallbackToRegularMode: instance.FallbackToRegularMode,
Connected: healthy,
Expand Down Expand Up @@ -297,6 +300,7 @@ func (h *InstancesHandler) buildQuickInstanceResponse(instance *models.Instance)
UseHardlinks: instance.UseHardlinks,
HardlinkBaseDir: instance.HardlinkBaseDir,
HardlinkDirPreset: instance.HardlinkDirPreset,
LinkDirName: instance.LinkDirName,
UseReflinks: instance.UseReflinks,
FallbackToRegularMode: instance.FallbackToRegularMode,
Connected: false, // Will be updated asynchronously
Expand Down Expand Up @@ -378,6 +382,7 @@ type CreateInstanceRequest struct {
BasicPassword *string `json:"basicPassword,omitempty"`
TLSSkipVerify bool `json:"tlsSkipVerify,omitempty"`
HasLocalFilesystemAccess *bool `json:"hasLocalFilesystemAccess,omitempty"`
LinkDirName *string `json:"linkDirName,omitempty"`
ReannounceSettings *InstanceReannounceSettingsPayload `json:"reannounceSettings,omitempty"`
}

Expand All @@ -394,6 +399,7 @@ type UpdateInstanceRequest struct {
UseHardlinks *bool `json:"useHardlinks,omitempty"`
HardlinkBaseDir *string `json:"hardlinkBaseDir,omitempty"`
HardlinkDirPreset *string `json:"hardlinkDirPreset,omitempty"`
LinkDirName *string `json:"linkDirName,omitempty"`
UseReflinks *bool `json:"useReflinks,omitempty"`
FallbackToRegularMode *bool `json:"fallbackToRegularMode,omitempty"`
ReannounceSettings *InstanceReannounceSettingsPayload `json:"reannounceSettings,omitempty"`
Expand All @@ -415,6 +421,7 @@ type InstanceResponse struct {
UseHardlinks bool `json:"useHardlinks"`
HardlinkBaseDir string `json:"hardlinkBaseDir"`
HardlinkDirPreset string `json:"hardlinkDirPreset"`
LinkDirName string `json:"linkDirName"`
UseReflinks bool `json:"useReflinks"`
FallbackToRegularMode bool `json:"fallbackToRegularMode"`
Connected bool `json:"connected"`
Expand Down Expand Up @@ -596,18 +603,36 @@ func (h *InstancesHandler) CreateInstance(w http.ResponseWriter, r *http.Request
return
}

// Create instance
instance, err := h.instanceStore.Create(r.Context(), req.Name, req.Host, req.Username, req.Password, req.BasicUsername, req.BasicPassword, req.TLSSkipVerify, req.HasLocalFilesystemAccess)
req.LinkDirName = normalizeOptionalString(req.LinkDirName)

settingsPayload := req.ReannounceSettings.toModel(0, nil)

instance, settings, err := h.instanceStore.CreateWithReannounce(
r.Context(),
h.reannounceStore,
req.Name,
req.Host,
req.Username,
req.Password,
req.BasicUsername,
req.BasicPassword,
req.TLSSkipVerify,
req.HasLocalFilesystemAccess,
req.LinkDirName,
settingsPayload,
)
if err != nil {
if errors.Is(err, models.ErrInvalidLinkDirName) {
RespondError(w, http.StatusBadRequest, err.Error())
return
}
log.Error().Err(err).Msg("Failed to create instance")
RespondError(w, http.StatusInternalServerError, "Failed to create instance")
return
}

settings, err := h.persistReannounceSettings(r.Context(), instance.ID, req.ReannounceSettings)
if err != nil {
RespondError(w, http.StatusInternalServerError, "Failed to save reannounce settings")
return
if h.reannounceCache != nil && settings != nil {
h.reannounceCache.Replace(settings)
}

// Return quickly without testing connection
Expand Down Expand Up @@ -663,6 +688,8 @@ func (h *InstancesHandler) UpdateInstance(w http.ResponseWriter, r *http.Request
req.BasicPassword = existingInstance.BasicPasswordEncrypted
}

req.LinkDirName = normalizeOptionalString(req.LinkDirName)

// Validate hardlink/reflink settings
effectiveLocalAccess := existingInstance.HasLocalFilesystemAccess
if req.HasLocalFilesystemAccess != nil {
Expand Down Expand Up @@ -716,15 +743,37 @@ func (h *InstancesHandler) UpdateInstance(w http.ResponseWriter, r *http.Request
UseHardlinks: req.UseHardlinks,
HardlinkBaseDir: req.HardlinkBaseDir,
HardlinkDirPreset: req.HardlinkDirPreset,
LinkDirName: req.LinkDirName,
UseReflinks: req.UseReflinks,
FallbackToRegularMode: req.FallbackToRegularMode,
}
instance, err := h.instanceStore.Update(r.Context(), instanceID, req.Name, req.Host, req.Username, req.Password, req.BasicUsername, req.BasicPassword, updateParams)
var desiredSettings *models.InstanceReannounceSettings
if req.ReannounceSettings != nil {
desiredSettings = req.ReannounceSettings.toModel(instanceID, nil)
}

instance, settings, err := h.instanceStore.UpdateWithReannounce(
r.Context(),
instanceID,
req.Name,
req.Host,
req.Username,
req.Password,
req.BasicUsername,
req.BasicPassword,
updateParams,
h.reannounceStore,
desiredSettings,
)
if err != nil {
if errors.Is(err, models.ErrInstanceNotFound) {
RespondError(w, http.StatusNotFound, "Instance not found")
return
}
if errors.Is(err, models.ErrInvalidLinkDirName) {
RespondError(w, http.StatusBadRequest, err.Error())
return
}
log.Error().Err(err).Msg("Failed to update instance")
RespondError(w, http.StatusInternalServerError, "Failed to update instance")
return
Expand All @@ -733,16 +782,10 @@ func (h *InstancesHandler) UpdateInstance(w http.ResponseWriter, r *http.Request
// Remove old client from pool to force reconnection
h.clientPool.RemoveClient(instanceID)

var settings *models.InstanceReannounceSettings
if req.ReannounceSettings != nil {
var err error
settings, err = h.persistReannounceSettings(r.Context(), instanceID, req.ReannounceSettings)
if err != nil {
RespondError(w, http.StatusInternalServerError, "Failed to save reannounce settings")
return
}
} else {
if req.ReannounceSettings == nil {
settings = h.loadReannounceSettings(r.Context(), instanceID)
} else if h.reannounceCache != nil && settings != nil {
h.reannounceCache.Replace(settings)
}

// Return quickly without testing connection
Expand All @@ -755,6 +798,15 @@ func (h *InstancesHandler) UpdateInstance(w http.ResponseWriter, r *http.Request
RespondJSON(w, http.StatusOK, response)
}

func normalizeOptionalString(value *string) *string {
if value == nil {
return nil
}

trimmed := strings.TrimSpace(*value)
return &trimmed
}

// DeleteInstance deletes an instance
func (h *InstancesHandler) DeleteInstance(w http.ResponseWriter, r *http.Request) {
// Get instance ID from URL
Expand Down
1 change: 1 addition & 0 deletions internal/api/handlers/torrents_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func createInstanceStoreWithInstance(t *testing.T, hasLocalAccess bool) (*models
nil,
false,
&hasLocal,
nil,
)
if err != nil {
torrentsHandlerInstanceFixture.err = err
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handlers/torrents_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func createTorrentFieldTestHarness(t *testing.T, torrentsByInstanceName map[stri
clients := make(map[int]*quiqbt.Client, len(torrentsByInstanceName))

for instanceName, torrents := range torrentsByInstanceName {
instance, createErr := instanceStore.Create(context.Background(), instanceName, "http://localhost:8080", "user", "pass", nil, nil, false, nil)
instance, createErr := instanceStore.Create(context.Background(), instanceName, "http://localhost:8080", "user", "pass", nil, nil, false, nil, nil)
require.NoError(t, createErr)

instanceIDs[instanceName] = instance.ID
Expand Down
1 change: 1 addition & 0 deletions internal/database/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ var expectedSchema = map[string][]columnSpec{
{Name: "use_hardlinks", Type: "BOOLEAN"},
{Name: "hardlink_base_dir", Type: "TEXT"},
{Name: "hardlink_dir_preset", Type: "TEXT"},
{Name: "link_dir_name", Type: "TEXT"},
{Name: "use_reflinks", Type: "BOOLEAN"},
{Name: "fallback_to_regular_mode", Type: "BOOLEAN"},
},
Expand Down
30 changes: 30 additions & 0 deletions internal/database/migrations/067_add_instance_link_dir_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Copyright (c) 2026, s0up and the autobrr contributors.
-- SPDX-License-Identifier: GPL-2.0-or-later

ALTER TABLE instances ADD COLUMN link_dir_name TEXT NOT NULL DEFAULT '';

DROP VIEW IF EXISTS instances_view;
CREATE VIEW instances_view AS
SELECT
i.id,
n.value AS name,
h.value AS host,
u.value AS username,
i.password_encrypted,
bu.value AS basic_username,
i.basic_password_encrypted,
i.tls_skip_verify,
i.sort_order,
i.is_active,
i.has_local_filesystem_access,
i.use_hardlinks,
i.hardlink_base_dir,
i.hardlink_dir_preset,
i.link_dir_name,
i.use_reflinks,
i.fallback_to_regular_mode
FROM instances i
LEFT JOIN string_pool n ON i.name_id = n.id
LEFT JOIN string_pool h ON i.host_id = h.id
LEFT JOIN string_pool u ON i.username_id = u.id
LEFT JOIN string_pool bu ON i.basic_username_id = bu.id;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Copyright (c) 2026, s0up and the autobrr contributors.
-- SPDX-License-Identifier: GPL-2.0-or-later

ALTER TABLE instances
ADD COLUMN link_dir_name TEXT NOT NULL DEFAULT '';

DROP VIEW IF EXISTS instances_view;
CREATE VIEW instances_view AS
SELECT
i.id,
n.value AS name,
h.value AS host,
u.value AS username,
i.password_encrypted,
bu.value AS basic_username,
i.basic_password_encrypted,
i.tls_skip_verify,
i.sort_order,
i.is_active,
i.has_local_filesystem_access,
i.use_hardlinks,
i.hardlink_base_dir,
i.hardlink_dir_preset,
i.link_dir_name,
i.use_reflinks,
i.fallback_to_regular_mode
FROM instances i
LEFT JOIN string_pool n ON i.name_id = n.id
LEFT JOIN string_pool h ON i.host_id = h.id
LEFT JOIN string_pool u ON i.username_id = u.id
LEFT JOIN string_pool bu ON i.basic_username_id = bu.id;
Loading
Loading