Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589fd4a41d | ||
|
|
d951e2e4fc |
@@ -2,7 +2,7 @@ name: archive-control-archive
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.1
|
image: sodium/archive-clients:v0.1.3
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["--config", "/etc/archive-control/client.toml"]
|
command: ["--config", "/etc/archive-control/client.toml"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: archive-control-cache
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.1
|
image: sodium/archive-clients:v0.1.3
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: archive-control-cache
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.1
|
image: sodium/archive-clients:v0.1.3
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ cache/archive routes according to policy.
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
archive-client:
|
archive-client:
|
||||||
image: sodium/archive-clients:v0.1.1
|
image: sodium/archive-clients:v0.1.3
|
||||||
user: "1001:1001"
|
user: "1001:1001"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["archive-client", "--config", "/etc/archive-control/client.toml"]
|
command: ["archive-client", "--config", "/etc/archive-control/client.toml"]
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ follow-up task.
|
|||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
1. Implement qBittorrent cookie authentication and capability adapters for
|
1. Implement qBittorrent cookie authentication and capability adapters for
|
||||||
supported 4.4–4.6 and 5.x APIs.
|
supported 4.5–4.6 and 5.x APIs; reject older releases because they cannot
|
||||||
|
export exact torrent metainfo through the public API.
|
||||||
2. Implement on-demand torrent summaries, scoped hash lookup, lazy complete
|
2. Implement on-demand torrent summaries, scoped hash lookup, lazy complete
|
||||||
content trees, v1/v2 identity validation, torrent export, stopped add,
|
content trees, v1/v2 identity validation, torrent export, stopped add,
|
||||||
selection application, recheck monitoring, and entry-only deletion.
|
selection application, recheck monitoring, and entry-only deletion.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ shapes.
|
|||||||
|
|
||||||
## qBittorrent Web API
|
## qBittorrent Web API
|
||||||
|
|
||||||
The implementation targets supported qBittorrent 4.4–4.6 and 5.x releases and
|
The implementation targets qBittorrent 4.5–4.6 and 5.x releases and
|
||||||
detects the application and Web API versions at startup. The authoritative
|
detects the application and Web API versions at startup. The authoritative
|
||||||
references are the official [5.0 WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29)
|
references are the official [5.0 WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29)
|
||||||
and [4.1-compatible WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-4.1%29).
|
and [4.1-compatible WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-4.1%29).
|
||||||
@@ -16,7 +16,7 @@ and [4.1-compatible WebUI API](https://github.com/qbittorrent/qBittorrent/wiki/W
|
|||||||
| Need | Web API family | Archive Control rule |
|
| Need | Web API family | Archive Control rule |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Authenticate | `auth/login`, cookie session | Log no credentials/cookies; reauthenticate once on expiry |
|
| Authenticate | `auth/login`, cookie session | Log no credentials/cookies; reauthenticate once on expiry |
|
||||||
| Detect compatibility | `app/version`, `app/webapiVersion`, build info | Advertise exact versions and select adapter |
|
| Detect compatibility | `app/version`, `app/webapiVersion`, build info | Require qBittorrent 4.5.0+ because exact metainfo export is mandatory |
|
||||||
| List/lookup | `torrents/info`, properties | On-demand and hash-scoped where possible |
|
| List/lookup | `torrents/info`, properties | On-demand and hash-scoped where possible |
|
||||||
| Read file state | `torrents/files` | Normalize indices, paths, selected/skipped, size, progress |
|
| Read file state | `torrents/files` | Normalize indices, paths, selected/skipped, size, progress |
|
||||||
| Export metainfo | `torrents/export` | Required before staging; fail if exact metainfo unavailable |
|
| Export metainfo | `torrents/export` | Required before staging; fail if exact metainfo unavailable |
|
||||||
@@ -29,6 +29,10 @@ The adapter accounts for terminology/behavior changes such as paused versus
|
|||||||
stopped states. It does not mutate qBittorrent preferences, categories, tags,
|
stopped states. It does not mutate qBittorrent preferences, categories, tags,
|
||||||
limits, queueing defaults, or global save-path behavior.
|
limits, queueing defaults, or global save-path behavior.
|
||||||
|
|
||||||
|
Syncthing route validation normalizes both absolute and `~/` folder-path
|
||||||
|
spellings under the configured sync root before comparing an existing folder;
|
||||||
|
an equivalent pre-existing pair is adopted without rewriting it.
|
||||||
|
|
||||||
### Inventory normalization
|
### Inventory normalization
|
||||||
|
|
||||||
For each torrent the client derives canonical v1/v2 identity from reliable API
|
For each torrent the client derives canonical v1/v2 identity from reliable API
|
||||||
|
|||||||
+2
-2
@@ -89,8 +89,8 @@ overlapping selections, reordered/duplicated events, and partial journals.
|
|||||||
|
|
||||||
### Adapter contract tests
|
### Adapter contract tests
|
||||||
|
|
||||||
Pinned qBittorrent and Syncthing container versions cover every supported API
|
Pinned qBittorrent 4.5–4.6 and 5.x and Syncthing container versions cover every
|
||||||
family. Assertions include stopped add, exact selection, export, full recheck,
|
supported API family. Assertions include stopped add, exact selection, export, full recheck,
|
||||||
download-attempt detection, entry-only delete, folder/device idempotency,
|
download-attempt detection, entry-only delete, folder/device idempotency,
|
||||||
events fallback, need/completion proof, and redacted errors. Partfile adapters
|
events fallback, need/completion proof, and redacted errors. Partfile adapters
|
||||||
are tested only against explicitly supported qBittorrent/libtorrent fixtures.
|
are tested only against explicitly supported qBittorrent/libtorrent fixtures.
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "archive-clients"
|
name = "archive-clients"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
|
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ def _supported_qb_version(version: str) -> bool:
|
|||||||
if match is None:
|
if match is None:
|
||||||
return False
|
return False
|
||||||
major, minor = int(match.group(1)), int(match.group(2))
|
major, minor = int(match.group(1)), int(match.group(2))
|
||||||
return major == 5 or (major == 4 and minor in {4, 5, 6})
|
# torrents/export, required to preserve exact metainfo before staging,
|
||||||
|
# was introduced in qBittorrent 4.5.0.
|
||||||
|
return major == 5 or (major == 4 and minor in {5, 6})
|
||||||
|
|
||||||
|
|
||||||
def _text_get(
|
def _text_get(
|
||||||
|
|||||||
@@ -369,8 +369,8 @@ class SyncthingRouteManager:
|
|||||||
raise RoutePathConflict("new route path contains unrelated data")
|
raise RoutePathConflict("new route path contains unrelated data")
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_folder(
|
def _validate_folder(
|
||||||
|
self,
|
||||||
folder: dict[str, Any],
|
folder: dict[str, Any],
|
||||||
api_path: str,
|
api_path: str,
|
||||||
local_device_id: str,
|
local_device_id: str,
|
||||||
@@ -382,7 +382,15 @@ class SyncthingRouteManager:
|
|||||||
for item in raw_devices
|
for item in raw_devices
|
||||||
if isinstance(item, dict) and isinstance(item.get("deviceID"), str)
|
if isinstance(item, dict) and isinstance(item.get("deviceID"), str)
|
||||||
} if isinstance(raw_devices, list) else set()
|
} if isinstance(raw_devices, list) else set()
|
||||||
if folder.get("path") != api_path:
|
try:
|
||||||
|
configured_api_path = self._normalized_folder_api_path(
|
||||||
|
folder.get("path")
|
||||||
|
)
|
||||||
|
except RoutePathConflict as exc:
|
||||||
|
raise RoutePathConflict(
|
||||||
|
"existing route ID uses a different path"
|
||||||
|
) from exc
|
||||||
|
if configured_api_path != api_path:
|
||||||
raise RoutePathConflict("existing route ID uses a different path")
|
raise RoutePathConflict("existing route ID uses a different path")
|
||||||
if folder.get("type") != "sendreceive":
|
if folder.get("type") != "sendreceive":
|
||||||
raise RouteSetupError("existing route folder is not sendreceive")
|
raise RouteSetupError("existing route folder is not sendreceive")
|
||||||
@@ -391,6 +399,27 @@ class SyncthingRouteManager:
|
|||||||
if folder.get("paused") is True:
|
if folder.get("paused") is True:
|
||||||
raise RouteSetupError("existing route folder is paused")
|
raise RouteSetupError("existing route folder is paused")
|
||||||
|
|
||||||
|
def _normalized_folder_api_path(self, value: Any) -> str:
|
||||||
|
"""Normalize Syncthing's absolute and home-relative path spellings."""
|
||||||
|
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise RoutePathConflict("existing route folder path is invalid")
|
||||||
|
candidate = PurePosixPath(value)
|
||||||
|
if candidate.parts and candidate.parts[0] == "~":
|
||||||
|
candidate = self.config.api_root.joinpath(*candidate.parts[1:])
|
||||||
|
if not candidate.is_absolute() or any(
|
||||||
|
part in {"", ".", ".."} for part in candidate.parts
|
||||||
|
):
|
||||||
|
raise RoutePathConflict("existing route folder path is unsafe")
|
||||||
|
normalized = candidate.as_posix()
|
||||||
|
try:
|
||||||
|
self.config.roots.api_to_local(normalized)
|
||||||
|
except ConfigError as exc:
|
||||||
|
raise RoutePathConflict(
|
||||||
|
"existing route folder path is outside the sync root"
|
||||||
|
) from exc
|
||||||
|
return normalized
|
||||||
|
|
||||||
def _wait(self, deadline: float) -> None:
|
def _wait(self, deadline: float) -> None:
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
raise RouteSetupTimeout("route setup timed out")
|
raise RouteSetupTimeout("route setup timed out")
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ class _Opener:
|
|||||||
|
|
||||||
|
|
||||||
class ServiceProbeTests(unittest.TestCase):
|
class ServiceProbeTests(unittest.TestCase):
|
||||||
def test_supported_qbittorrent_versions_include_live_4_4_api(self):
|
def test_supported_qbittorrent_versions_require_export_capability(self):
|
||||||
for version in ("v4.4.5", "v4.5.5", "v4.6.7", "v5.2.3"):
|
for version in ("v4.5.0", "v4.6.7", "v5.2.3"):
|
||||||
with self.subTest(version=version):
|
with self.subTest(version=version):
|
||||||
self.assertTrue(_supported_qb_version(version))
|
self.assertTrue(_supported_qb_version(version))
|
||||||
for version in ("v4.3.9", "v6.0.0", "invalid"):
|
for version in ("v4.3.9", "v4.4.5", "v6.0.0", "invalid"):
|
||||||
with self.subTest(version=version):
|
with self.subTest(version=version):
|
||||||
self.assertFalse(_supported_qb_version(version))
|
self.assertFalse(_supported_qb_version(version))
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,24 @@ class SyncthingRouteManagerTests(unittest.TestCase):
|
|||||||
self.manager.configure(self.spec, time.monotonic() + 1)
|
self.manager.configure(self.spec, time.monotonic() + 1)
|
||||||
self.assertEqual(self.transport.puts, [])
|
self.assertEqual(self.transport.puts, [])
|
||||||
|
|
||||||
|
def test_existing_home_relative_folder_path_is_accepted(self):
|
||||||
|
self.transport.config["devices"].append({"deviceID": "PEER"})
|
||||||
|
self.transport.config["folders"].append(
|
||||||
|
{
|
||||||
|
"id": "route-1",
|
||||||
|
"path": "~/routes/route-1",
|
||||||
|
"type": "sendreceive",
|
||||||
|
"devices": [{"deviceID": "LOCAL"}, {"deviceID": "PEER"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
configured = self.manager.configure(
|
||||||
|
self.spec, time.monotonic() + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.transport.puts, [])
|
||||||
|
self.assertFalse(configured.local_route.archive_control_created)
|
||||||
|
|
||||||
def test_bidirectional_nonce_and_ack_are_required(self):
|
def test_bidirectional_nonce_and_ack_are_required(self):
|
||||||
configured = self.manager.configure(self.spec, time.monotonic() + 1)
|
configured = self.manager.configure(self.spec, time.monotonic() + 1)
|
||||||
peer_nonce = configured.local_path / (
|
peer_nonce = configured.local_path / (
|
||||||
|
|||||||
Reference in New Issue
Block a user