Compare commits

...
3 Commits
14 changed files with 80 additions and 18 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: archive-control-archive
services:
archive-client:
image: sodium/archive-clients:v0.1.0
image: sodium/archive-clients:v0.1.3
user: "1000:1000"
restart: unless-stopped
command: ["--config", "/etc/archive-control/client.toml"]
+1 -1
View File
@@ -2,7 +2,7 @@ name: archive-control-cache
services:
archive-client:
image: sodium/archive-clients:v0.1.0
image: sodium/archive-clients:v0.1.3
user: "1001:1001"
restart: unless-stopped
network_mode: host
+1 -1
View File
@@ -2,7 +2,7 @@ name: archive-control-cache
services:
archive-client:
image: sodium/archive-clients:v0.1.0
image: sodium/archive-clients:v0.1.3
user: "1001:1001"
restart: unless-stopped
network_mode: host
+1 -1
View File
@@ -213,7 +213,7 @@ cache/archive routes according to policy.
```yaml
services:
archive-client:
image: sodium/archive-clients:v0.1.0
image: sodium/archive-clients:v0.1.3
user: "1001:1001"
restart: unless-stopped
command: ["archive-client", "--config", "/etc/archive-control/client.toml"]
+2 -1
View File
@@ -105,7 +105,8 @@ follow-up task.
### Actions
1. Implement qBittorrent cookie authentication and capability adapters for
supported 4.44.6 and 5.x APIs.
supported 4.54.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
content trees, v1/v2 identity validation, torrent export, stopped add,
selection application, recheck monitoring, and entry-only deletion.
+6 -2
View File
@@ -6,7 +6,7 @@ shapes.
## qBittorrent Web API
The implementation targets supported qBittorrent 4.44.6 and 5.x releases and
The implementation targets qBittorrent 4.54.6 and 5.x releases and
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)
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 |
| --- | --- | --- |
| 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 |
| Read file state | `torrents/files` | Normalize indices, paths, selected/skipped, size, progress |
| 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,
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
For each torrent the client derives canonical v1/v2 identity from reliable API
+2 -2
View File
@@ -89,8 +89,8 @@ overlapping selections, reordered/duplicated events, and partial journals.
### Adapter contract tests
Pinned qBittorrent and Syncthing container versions cover every supported API
family. Assertions include stopped add, exact selection, export, full recheck,
Pinned qBittorrent 4.54.6 and 5.x and Syncthing container versions cover every
supported API family. Assertions include stopped add, exact selection, export, full recheck,
download-attempt detection, entry-only delete, folder/device idempotency,
events fallback, need/completion proof, and redacted errors. Partfile adapters
are tested only against explicitly supported qBittorrent/libtorrent fixtures.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "archive-clients"
version = "0.1.0"
version = "0.1.3"
requires-python = ">=3.11"
dependencies = ["protobuf==7.35.1", "websockets==16.0"]
+2
View File
@@ -169,6 +169,8 @@ def _service(value: Any, name: str) -> ServiceConfig:
username = value.get("username")
if username is not None and (not isinstance(username, str) or not username):
raise ConfigError(f"{name}.username must be a non-empty string")
if username is not None:
username = _expand(username)
addresses = value.get("advertised_addresses", [])
if not isinstance(addresses, list) or any(
not isinstance(address, str) or not address for address in addresses
+3 -1
View File
@@ -159,7 +159,9 @@ def _supported_qb_version(version: str) -> bool:
if match is None:
return False
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(
+31 -2
View File
@@ -369,8 +369,8 @@ class SyncthingRouteManager:
raise RoutePathConflict("new route path contains unrelated data")
path.mkdir(parents=True, exist_ok=True)
@staticmethod
def _validate_folder(
self,
folder: dict[str, Any],
api_path: str,
local_device_id: str,
@@ -382,7 +382,15 @@ class SyncthingRouteManager:
for item in raw_devices
if isinstance(item, dict) and isinstance(item.get("deviceID"), str)
} 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")
if folder.get("type") != "sendreceive":
raise RouteSetupError("existing route folder is not sendreceive")
@@ -391,6 +399,27 @@ class SyncthingRouteManager:
if folder.get("paused") is True:
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:
if time.monotonic() >= deadline:
raise RouteSetupTimeout("route setup timed out")
+8 -2
View File
@@ -2,6 +2,7 @@ import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from archive_clients.config import ClientConfig, ConfigError, RootMapping
@@ -18,10 +19,15 @@ class ConfigTests(unittest.TestCase):
(root / "sync").mkdir()
config_path = root / "client.toml"
config_path.write_text(
_config(root, role="cache"), encoding="utf-8"
_config(root, role="cache").replace(
'username = "admin"', 'username = "${QB_USER}"'
),
encoding="utf-8",
)
config = ClientConfig.load(config_path, "archive")
with patch.dict(os.environ, {"QB_USER": "admin"}):
config = ClientConfig.load(config_path, "archive")
self.assertEqual(config.role, "archive")
self.assertEqual(config.qbittorrent.username, "admin")
self.assertEqual(config.read_shared_token(), "token")
self.assertEqual(
config.qbittorrent.roots.api_to_local("/downloads/a/b"),
+3 -3
View File
@@ -43,11 +43,11 @@ class _Opener:
class ServiceProbeTests(unittest.TestCase):
def test_supported_qbittorrent_versions_include_live_4_4_api(self):
for version in ("v4.4.5", "v4.5.5", "v4.6.7", "v5.2.3"):
def test_supported_qbittorrent_versions_require_export_capability(self):
for version in ("v4.5.0", "v4.6.7", "v5.2.3"):
with self.subTest(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):
self.assertFalse(_supported_qb_version(version))
+18
View File
@@ -125,6 +125,24 @@ class SyncthingRouteManagerTests(unittest.TestCase):
self.manager.configure(self.spec, time.monotonic() + 1)
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):
configured = self.manager.configure(self.spec, time.monotonic() + 1)
peer_nonce = configured.local_path / (