Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ id # Shows your UID and GID
- Ensure your credentials are correct
- If using SSH key auth, ensure the key is mounted at `/home/seedsync/.ssh/id_rsa` (read-only is fine)

### Custom Python Path on Remote Server

If your seedbox has Python 3 installed at a non-standard location (e.g. a custom build in your home directory), set **Remote Python Path** in Settings to the full path to the Python 3 binary. For example: `~/python3/bin/python3`. Leave empty to use the default `python3`.

### Remote Shell Not Found

If you see an error about `/bin/bash` not found, SeedSync will attempt to auto-detect the available shell on your remote server. Check the logs for the detected shell path. If detection fails, create a symlink on the remote server:
Expand Down
8 changes: 8 additions & 0 deletions src/angular/src/app/pages/settings/options-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export const OPTIONS_CONTEXT_SERVER: IOptionsContext = {
valuePath: ['lftp', 'remote_path_to_scan_script'],
description: 'Where to install scanner script on remote server',
},
{
type: OptionType.Text,
label: 'Remote Python Path',
valuePath: ['lftp', 'remote_python_path'],
description:
'Path to Python 3 on the remote server. Leave empty to use the default "python3". ' +
'Set this if your seedbox has a custom Python install (e.g. "~/python3/bin/python3").',
},
],
};

Expand Down
2 changes: 2 additions & 0 deletions src/python/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ class Lftp(IC):
remote_path = PROP("remote_path", Checkers.string_nonempty, Converters.null)
local_path = PROP("local_path", Checkers.string_nonempty, Converters.null)
remote_path_to_scan_script = PROP("remote_path_to_scan_script", Checkers.string_nonempty, Converters.null)
remote_python_path = PROP("remote_python_path", Checkers.string_allow_empty, Converters.null)
use_ssh_key = PROP("use_ssh_key", Checkers.null, Converters.bool)
num_max_parallel_downloads = PROP("num_max_parallel_downloads", Checkers.int_positive, Converters.int)
num_max_parallel_files_per_download = PROP(
Expand Down Expand Up @@ -287,6 +288,7 @@ def __init__(self):
self.remote_path = None
self.local_path = None
self.remote_path_to_scan_script = None
self.remote_python_path = ""
self.use_ssh_key = None
self.num_max_parallel_downloads = None
self.num_max_parallel_files_per_download = None
Expand Down
1 change: 1 addition & 0 deletions src/python/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ def _create_pair_context(self, pair_id: str | None, name: str, remote_path: str,
remote_path_to_scan=remote_path,
local_path_to_scan_script=self.__context.args.local_path_to_scanfs, # type: ignore[arg-type]
remote_path_to_scan_script=self.__context.config.lftp.remote_path_to_scan_script, # type: ignore[arg-type]
remote_python_path=self.__context.config.lftp.remote_python_path, # type: ignore[arg-type]
)

# Scanner processes
Expand Down
10 changes: 8 additions & 2 deletions src/python/controller/scan/remote_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ def __init__(
remote_path_to_scan: str,
local_path_to_scan_script: str,
remote_path_to_scan_script: str,
remote_python_path: str = "",
):
self.logger = logging.getLogger("RemoteScanner")
self.__remote_path_to_scan = remote_path_to_scan
self.__local_path_to_scan_script = local_path_to_scan_script
self.__remote_path_to_scan_script = remote_path_to_scan_script
self.__remote_python_cmd = (
remote_python_path.strip() if remote_python_path and remote_python_path.strip() else "python3"
)
self.__ssh = Sshcp(host=remote_address, port=remote_port, user=remote_username, password=remote_password)
self.__first_run = True

Expand Down Expand Up @@ -81,14 +85,16 @@ def _run_scanfs_with_retry(self) -> bytes:
# (for $HOME expansion), single quotes otherwise
if self.__remote_path_to_scan.startswith("~"):
return self.__ssh.shell(
"python3 {} {}".format(
"{} {} {}".format(
_escape_remote_path_double(self.__remote_python_cmd),
_escape_remote_path_double(self.__remote_path_to_scan_script),
_escape_remote_path_double(self.__remote_path_to_scan),
)
)
else:
return self.__ssh.shell(
"python3 {} {}".format(
"{} {} {}".format(
_escape_remote_path_single(self.__remote_python_cmd),
_escape_remote_path_single(self.__remote_path_to_scan_script),
_escape_remote_path_single(self.__remote_path_to_scan),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions src/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "I001"] # re-exports, import order is load-order sensitive
"tests/**" = ["W291"] # trailing whitespace in test string literals (lftp output data)
"scan_fs.py" = ["UP006", "UP035", "UP045"] # must use typing.List/Optional for Python 3.5 compat on remote

[tool.ruff.lint.isort]
known-first-party = ["common", "controller", "model", "lftp", "ssh", "web", "system"]
Expand Down
25 changes: 12 additions & 13 deletions src/python/scan_fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@
# This script is uploaded to the remote server and executed standalone via
# `python3 scan_fs.py <path>`. It must NOT import any SeedSync packages.
#
# IMPORTANT: This file runs on the REMOTE server which may have Python 3.8+.
# Use `from __future__ import annotations` so modern type syntax (X | None,
# list[X]) is valid on older Python versions.

from __future__ import annotations
# IMPORTANT: This file runs on the REMOTE server which may have Python 3.5+.
# Do NOT use modern type syntax (X | None, list[X]) or
# `from __future__ import annotations` — use typing imports instead.

import json
import os
import re
import sys
from datetime import datetime
from typing import List, Optional


class SystemFile:
Expand All @@ -29,8 +28,8 @@ def __init__(
name: str,
size: int,
is_dir: bool = False,
time_created: datetime | None = None,
time_modified: datetime | None = None,
time_created: Optional[datetime] = None,
time_modified: Optional[datetime] = None,
):
if size < 0:
raise ValueError("File size must be non-negative")
Expand All @@ -54,10 +53,10 @@ def is_dir(self) -> bool:
return self.__is_dir

@property
def children(self) -> list[SystemFile]:
def children(self) -> "List[SystemFile]":
return self.__children

def add_child(self, file: SystemFile):
def add_child(self, file: "SystemFile"):
if not self.__is_dir:
raise TypeError("Cannot add children to a file")
self.__children.append(file)
Expand Down Expand Up @@ -96,7 +95,7 @@ def add_exclude_prefix(self, prefix: str):
def add_exclude_suffix(self, suffix: str):
self.exclude_suffixes.append(suffix)

def scan(self) -> list[SystemFile]:
def scan(self) -> "List[SystemFile]":
if not os.path.exists(self.path_to_scan):
raise SystemScannerError("Path does not exist: {}".format(self.path_to_scan))
elif not os.path.isdir(self.path_to_scan):
Expand Down Expand Up @@ -133,7 +132,7 @@ def __create_system_file(self, entry) -> SystemFile:
sys_file = SystemFile(file_name, file_size, False, time_created=time_created, time_modified=time_modified)
return sys_file

def __create_children(self, path: str) -> list[SystemFile]:
def __create_children(self, path: str) -> "List[SystemFile]":
children = []
for entry in os.scandir(path):
skip = False
Expand Down Expand Up @@ -187,8 +186,8 @@ def _lftp_status_file_size(status: str) -> int:


if __name__ == "__main__":
if sys.hexversion < 0x03060000:
sys.exit("Python 3.6 or newer is required to run this program.")
if sys.hexversion < 0x03050000:
sys.exit("Python 3.5 or newer is required to run this program.")
Comment on lines +189 to +190
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify no Python 3.6+ specific syntax remains in scan_fs.py
# Check for f-strings (3.6+), walrus operator (3.8+), or union types (3.10+)

echo "Checking for f-strings (Python 3.6+):"
rg -n 'f"[^"]*\{' src/python/scan_fs.py || echo "No f-strings found"
rg -n "f'[^']*\{" src/python/scan_fs.py || echo "No f-strings found"

echo ""
echo "Checking for walrus operator := (Python 3.8+):"
rg -n ':=' src/python/scan_fs.py || echo "No walrus operators found"

echo ""
echo "Checking for union type syntax | (Python 3.10+):"
rg -n '\w+\s*\|\s*\w+' src/python/scan_fs.py | grep -v '#' || echo "No union types found"

Repository: nitrobass24/seedsync

Length of output: 284


🏁 Script executed:

cd src/python && head -200 scan_fs.py | tail -20

Repository: nitrobass24/seedsync

Length of output: 844


Version check must be updated to Python 3.12 to comply with coding guidelines.

The file src/python/scan_fs.py matches the pattern src/python/**/*.py, which requires Python 3.12 per project guidelines. The current check if sys.hexversion < 0x03050000: must be changed to 0x030C0000 (Python 3.12). Update the error message accordingly to "Python 3.12 or newer is required to run this program."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/scan_fs.py` around lines 189 - 190, Update the Python version
guard that currently checks sys.hexversion in scan_fs.py: change the numeric
literal from 0x03050000 to 0x030C0000 and update the sys.exit message string to
"Python 3.12 or newer is required to run this program." so the check in the
top-level version validation uses Python 3.12 as the minimum.


import argparse

Expand Down
2 changes: 2 additions & 0 deletions src/python/tests/unittests/test_common/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ def test_to_file(self):
config.lftp.remote_path = "/remote/server/path"
config.lftp.local_path = "/local/server/path"
config.lftp.remote_path_to_scan_script = "/remote/server/path/to/script"
config.lftp.remote_python_path = "/opt/python/bin/python3"
config.lftp.use_ssh_key = True
config.lftp.num_max_parallel_downloads = 6
config.lftp.num_max_parallel_files_per_download = 7
Expand Down Expand Up @@ -589,6 +590,7 @@ def test_to_file(self):
remote_path = /remote/server/path
local_path = /local/server/path
remote_path_to_scan_script = /remote/server/path/to/script
remote_python_path = /opt/python/bin/python3
use_ssh_key = True
num_max_parallel_downloads = 6
num_max_parallel_files_per_download = 7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ def _make_shell_side_effect(self, responses):
Each entry is either a bytes value to return, or an Exception to raise.
The shell call sequence on first scan is:
1. md5sum check
2. scanfs (possibly retried)
2. directory check (if md5sum doesn't match)
3. scanfs (possibly retried)
"""
self._shell_call_index = 0

Expand Down Expand Up @@ -111,10 +112,11 @@ def mock_ssh_ctor(**kwargs):
def test_installs_scan_script_on_first_scan(self):
scanner = self._make_scanner()

# Call sequence: md5sum (non-matching), scanfs
# Call sequence: md5sum (non-matching), directory check, scanfs
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum - doesn't match, triggers install
b"OK", # directory check
json.dumps([]).encode(), # scanfs
json.dumps([]).encode(), # second scan: scanfs (no install)
]
Expand All @@ -136,6 +138,7 @@ def test_copy_appends_scanfs_name_to_remote_path(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum - doesn't match
b"OK", # directory check
json.dumps([]).encode(), # scanfs
]
)
Expand All @@ -152,13 +155,14 @@ def test_calls_correct_ssh_md5sum_command(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
json.dumps([]).encode(), # scanfs
]
)

scanner.scan()
# 2 calls: md5sum, scanfs
self.assertEqual(2, self.mock_ssh.shell.call_count)
# 3 calls: md5sum, directory check, scanfs
self.assertEqual(3, self.mock_ssh.shell.call_count)
# First call should be the md5sum command
md5sum_call = self.mock_ssh.shell.call_args_list[0]
self.assertEqual(call("md5sum '/remote/path/to/scan/script' | awk '{print $1}' || echo"), md5sum_call)
Expand Down Expand Up @@ -189,6 +193,7 @@ def test_installs_scan_script_on_any_md5sum_output(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"some output from md5sum", # md5sum - doesn't match
b"OK", # directory check
json.dumps([]).encode(), # scanfs
]
)
Expand Down Expand Up @@ -218,14 +223,15 @@ def test_calls_correct_ssh_scan_command(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
json.dumps([]).encode(), # scanfs
]
)

scanner.scan()
# 2 calls: md5sum, scanfs
self.assertEqual(2, self.mock_ssh.shell.call_count)
self.mock_ssh.shell.assert_called_with("python3 '/remote/path/to/scan/script' '/remote/path/to/scan'")
# 3 calls: md5sum, directory check, scanfs
self.assertEqual(3, self.mock_ssh.shell.call_count)
self.mock_ssh.shell.assert_called_with("'python3' '/remote/path/to/scan/script' '/remote/path/to/scan'")

def test_handles_tilde_path_for_shell_expansion(self):
"""Test that paths starting with ~ are converted to $HOME for shell expansion"""
Expand All @@ -234,15 +240,16 @@ def test_handles_tilde_path_for_shell_expansion(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
json.dumps([]).encode(), # scanfs
]
)

scanner.scan()
self.assertEqual(2, self.mock_ssh.shell.call_count)
self.assertEqual(3, self.mock_ssh.shell.call_count)
# When scan path has tilde, both paths use double quotes for consistent quoting
# Tilde is converted to $HOME for shell expansion
self.mock_ssh.shell.assert_called_with('python3 "/remote/path/to/scan/script" "$HOME/data/torrents"')
self.mock_ssh.shell.assert_called_with('"python3" "/remote/path/to/scan/script" "$HOME/data/torrents"')

def test_raises_nonrecoverable_error_on_first_failed_ssh(self):
"""Non-transient errors on first run are non-recoverable (no retry)"""
Expand All @@ -251,6 +258,7 @@ def test_raises_nonrecoverable_error_on_first_failed_ssh(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
SshcpError("an ssh error"), # scanfs fails (non-transient)
]
)
Expand All @@ -267,6 +275,7 @@ def test_raises_recoverable_error_on_subsequent_failed_ssh(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
json.dumps([]).encode(), # first scanfs - success
# second scan: 3 retry attempts all fail
SshcpError("an ssh error"),
Expand All @@ -288,6 +297,7 @@ def test_recovers_from_failed_ssh(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
json.dumps([]).encode(), # first scanfs - success
# second scan: 3 retry attempts all fail
SshcpError("an ssh error"),
Expand Down Expand Up @@ -352,6 +362,7 @@ def test_calls_detect_shell_on_first_scan(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
json.dumps([]).encode(), # scanfs
json.dumps([]).encode(), # second scan: scanfs
]
Expand All @@ -372,6 +383,7 @@ def test_raises_nonrecoverable_error_on_failed_scan(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
SshcpError("SystemScannerError: something failed"), # scanfs - no retry
]
)
Expand All @@ -390,6 +402,7 @@ def test_retries_transient_errors_on_first_run(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
SshcpError("Timed out after 180s"), # scanfs attempt 1 - transient
json.dumps([]).encode(), # scanfs attempt 2 - success
]
Expand All @@ -405,6 +418,7 @@ def test_retries_up_to_max_attempts(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
SshcpError("Timed out after 180s"), # attempt 1
SshcpError("Timed out after 180s"), # attempt 2
SshcpError("Timed out after 180s"), # attempt 3
Expand All @@ -424,6 +438,7 @@ def test_no_retry_on_non_transient_first_run_error(self):
self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum
b"OK", # directory check
SshcpError("Incorrect password"), # scanfs - non-transient
]
)
Expand Down Expand Up @@ -460,13 +475,53 @@ def test_raises_recoverable_error_on_transient_md5sum_failure(self):
scanner.scan()
self.assertTrue(ctx.exception.recoverable)

def test_uses_custom_python_path(self):
"""When remote_python_path is set, uses it instead of 'python3'"""
scanner = RemoteScanner(
remote_address="my remote address",
remote_username="my remote user",
remote_password="my password",
remote_port=1234,
remote_path_to_scan="/remote/path/to/scan",
local_path_to_scan_script=TestRemoteScanner.temp_scan_script,
remote_path_to_scan_script="/remote/path/to/scan/script",
remote_python_path="~/python3/bin/python3",
)

self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"d41d8cd98f00b204e9800998ecf8427e", # md5sum - matches, skip install
json.dumps([]).encode(), # scanfs
]
)

scanner.scan()
self.mock_ssh.shell.assert_called_with(
"'~/python3/bin/python3' '/remote/path/to/scan/script' '/remote/path/to/scan'"
)
Comment on lines +478 to +501
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how tilde is handled in the remote_python_path configuration documentation
rg -n -C3 'remote_python_path' --type=md

Repository: nitrobass24/seedsync

Length of output: 1782


🏁 Script executed:

#!/bin/bash
# First, list files to understand structure
git ls-files "src/python/tests/unittests/test_controller/test_scan/" | head -20

Repository: nitrobass24/seedsync

Length of output: 1885


🏁 Script executed:

#!/bin/bash
# Find the RemoteScanner implementation
find . -type f -name "*.py" | xargs grep -l "class RemoteScanner" | head -5

Repository: nitrobass24/seedsync

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Try to read the test file directly
cat -n "src/python/tests/unittests/test_controller/test_scan/test_remote_scanner.py" | sed -n '478,520p'

Repository: nitrobass24/seedsync

Length of output: 2089


🏁 Script executed:

#!/bin/bash
# Read RemoteScanner implementation - focusing on scan method
cat -n "src/python/controller/scan/remote_scanner.py" | head -150

Repository: nitrobass24/seedsync

Length of output: 7667


🏁 Script executed:

#!/bin/bash
# Search for the scan method and command construction in RemoteScanner
rg -n "def scan" "src/python/controller/scan/remote_scanner.py" -A 50

Repository: nitrobass24/seedsync

Length of output: 2532


🏁 Script executed:

#!/bin/bash
# Check for other tests related to python path with different scan paths
rg -n "remote_python_path" "src/python/tests/unittests/test_controller/test_scan/test_remote_scanner.py" -B 3 -A 3

Repository: nitrobass24/seedsync

Length of output: 1023


🏁 Script executed:

#!/bin/bash
# Check documentation or comments about tilde expansion
rg -n "tilde\|~" "src/python/controller/scan/remote_scanner.py" -B 1 -A 1

Repository: nitrobass24/seedsync

Length of output: 46


Fix: Tilde in remote_python_path won't expand when scan path is absolute.

The implementation determines quoting strategy based only on whether remote_path_to_scan starts with ~ (lines 84-101 in remote_scanner.py). When the scan path is absolute but remote_python_path contains tilde (e.g., ~/bin/python3), single quotes are applied to all arguments. This prevents tilde expansion in the python path, causing execution to fail with "command ~ not found."

The test correctly reflects this behavior, but the behavior is a bug: users configuring remote_python_path="~/python3/bin/python3" with an absolute remote_path will encounter failures. The quoting logic should evaluate tilde expansion independently for each path argument, or consistently use double quotes when any argument requires expansion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/python/tests/unittests/test_controller/test_scan/test_remote_scanner.py`
around lines 478 - 501, The command-building logic in RemoteScanner
(remote_scanner.py) currently chooses quoting based solely on
remote_path_to_scan and thus single-quotes the entire command when the scan path
is absolute, preventing tilde expansion in remote_python_path (e.g.
"~/python3/bin/python3"); modify the code that constructs the shell command (the
Scan flow / scan() or the helper that formats arguments) to decide quoting per
argument: if an argument starts with '~' do not wrap it in single quotes (or
wrap that arg in double quotes) so the shell can expand the tilde, otherwise
keep existing quoting behavior; ensure remote_python_path and
remote_path_to_scan are handled independently when building the final command
string passed to self.mock_ssh.shell.


def test_defaults_to_python3(self):
"""When remote_python_path is empty, defaults to 'python3'"""
scanner = self._make_scanner()

self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"d41d8cd98f00b204e9800998ecf8427e", # md5sum - matches, skip install
json.dumps([]).encode(), # scanfs
]
)

scanner.scan()
self.mock_ssh.shell.assert_called_with("'python3' '/remote/path/to/scan/script' '/remote/path/to/scan'")

def test_raises_recoverable_error_on_transient_copy_failure(self):
"""Transient SSH errors during scanfs copy are recoverable"""
scanner = self._make_scanner()

self.mock_ssh.shell.side_effect = self._make_shell_side_effect(
[
b"", # md5sum - doesn't match, triggers install
b"OK", # directory check
]
)
self.mock_ssh.copy.side_effect = SshcpError("lost connection")
Expand Down
Loading