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
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
33 changes: 32 additions & 1 deletion internal/api/handlers/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ 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,
Connected: false,
HasDecryptionError: false,
Expand Down Expand Up @@ -255,6 +256,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 +299,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 +381,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 +398,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 +420,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,8 +602,21 @@ func (h *InstancesHandler) CreateInstance(w http.ResponseWriter, r *http.Request
return
}

req.LinkDirName = normalizeOptionalString(req.LinkDirName)

// 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)
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,
)
if err != nil {
log.Error().Err(err).Msg("Failed to create instance")
RespondError(w, http.StatusInternalServerError, "Failed to create instance")
Expand Down Expand Up @@ -663,6 +682,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,6 +737,7 @@ 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,
}
Expand Down Expand Up @@ -755,6 +777,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
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;
112 changes: 112 additions & 0 deletions internal/linkdir/linkdir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) 2025-2026, s0up and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later

package linkdir

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/autobrr/qui/pkg/fsutil"
"github.com/autobrr/qui/pkg/hardlinktree"
"github.com/autobrr/qui/pkg/pathutil"
)

func validateInstanceDirName(name string) error {
switch trimmed := strings.TrimSpace(name); {
case trimmed == "":
return errors.New("instance directory name cannot be empty")
case strings.ContainsAny(trimmed, `/\`):
return fmt.Errorf("instance directory name %q must not contain path separators", trimmed)
case trimmed == "." || trimmed == "..":
return fmt.Errorf("instance directory name %q must not be a traversal segment", trimmed)
default:
return nil
}
}

func groupDestDir(baseDir, groupName, isolationFolder string) string {
groupDir := filepath.Join(baseDir, pathutil.SanitizePathSegment(groupName))
if isolationFolder == "" {
return groupDir
}
return filepath.Join(groupDir, isolationFolder)
}

// EffectiveInstanceDirName returns the configured by-instance directory name.
// Falls back to the instance name when no override is set.
func EffectiveInstanceDirName(instanceName, override string) (string, error) {
name := strings.TrimSpace(override)
if name == "" {
name = instanceName
}
if err := validateInstanceDirName(name); err != nil {
return "", err
}
return name, nil
}

// FindMatchingBaseDir returns the first configured base dir on the same filesystem as sourcePath.
func FindMatchingBaseDir(configuredDirs, sourcePath string) (string, error) {
if strings.TrimSpace(configuredDirs) == "" {
return "", errors.New("base directory not configured")
}

dirs := strings.Split(configuredDirs, ",")
var lastErr error

for _, dir := range dirs {
dir = strings.TrimSpace(dir)
if dir == "" {
continue
}

if err := os.MkdirAll(dir, 0o755); err != nil {
lastErr = fmt.Errorf("failed to create directory %s: %w", dir, err)
continue
}

sameFS, err := fsutil.SameFilesystem(sourcePath, dir)
if err != nil {
lastErr = fmt.Errorf("failed to check filesystem for %s: %w", dir, err)
continue
}
if sameFS {
return dir, nil
}

lastErr = fmt.Errorf("directory %s is on a different filesystem", dir)
}

if lastErr == nil {
lastErr = errors.New("no valid base directories configured")
}
return "", lastErr
}

// BuildDestDir returns the final hardlink/reflink tree root for the configured preset.
func BuildDestDir(baseDir, preset, groupName, torrentHash, torrentName string, candidateFiles []hardlinktree.TorrentFile) (string, error) {
needsIsolation := !hardlinktree.HasCommonRootFolder(candidateFiles)
isolationFolder := ""
if needsIsolation || preset == "flat" || preset == "" {
isolationFolder = pathutil.IsolationFolderName(torrentHash, torrentName)
}

switch preset {
case "by-tracker":
if strings.TrimSpace(groupName) == "" {
groupName = "Unknown"
}
return groupDestDir(baseDir, groupName, isolationFolder), nil
case "by-instance":
if err := validateInstanceDirName(groupName); err != nil {
return "", err
}
return groupDestDir(baseDir, groupName, isolationFolder), nil
default:
return filepath.Join(baseDir, pathutil.IsolationFolderName(torrentHash, torrentName)), nil
}
}
Loading
Loading