"""Normalize qBittorrent observations into the stable protocol model.""" from __future__ import annotations import hashlib import json from dataclasses import dataclass from datetime import datetime, timezone from pathlib import PurePosixPath from typing import Any from archive_clients.bencode import Metainfo, decode_metainfo from archive_control.v1 import resource_pb2 class ResourceError(ValueError): pass @dataclass(frozen=True) class NormalizedResource: """A normalized qB observation for protocol data and local file work. ``save_path`` is qBittorrent's API-visible per-torrent content root. It is deliberately local-only: clients resolve it through their qB path mapping immediately before filesystem work, and it is never serialized in inventory, placements, or control protocol messages. """ summary: resource_pb2.ResourceSummary files: tuple[resource_pb2.TorrentFile, ...] metainfo: Metainfo metainfo_bytes: bytes = b"" # qBittorrent's API-visible save path is intentionally local-only. It # must never become part of inventory or placement protocol messages. save_path: PurePosixPath | None = None def build_content_tree( files: tuple[resource_pb2.TorrentFile, ...], available_file_indices: set[int], ) -> list[resource_pb2.ContentTreeEntry]: directories: dict[str, set[int]] = {} entries: list[resource_pb2.ContentTreeEntry] = [] by_index = {file.file_index: file for file in files} for file in files: path = PurePosixPath(file.canonical_path) for parent in path.parents: if parent == PurePosixPath("."): continue directories.setdefault(parent.as_posix(), set()).add(file.file_index) entry = resource_pb2.ContentTreeEntry( canonical_path=file.canonical_path, parent_path=( "" if path.parent == PurePosixPath(".") else path.parent.as_posix() ), display_name=path.name, type=( resource_pb2.CONTENT_ENTRY_TYPE_PADDING_FILE if file.padding else resource_pb2.CONTENT_ENTRY_TYPE_FILE ), file_index=file.file_index, ) entry.file.CopyFrom(file) if file.file_index in available_file_indices: _set_selection(entry.available_file_indices, [file.file_index]) entry.available_logical_bytes = file.logical_bytes entry.available_file_count = 1 entries.append(entry) for path_string, descendant_indices in directories.items(): path = PurePosixPath(path_string) available = sorted(descendant_indices & available_file_indices) entry = resource_pb2.ContentTreeEntry( canonical_path=path_string, parent_path=( "" if path.parent == PurePosixPath(".") else path.parent.as_posix() ), display_name=path.name, type=resource_pb2.CONTENT_ENTRY_TYPE_DIRECTORY, available_logical_bytes=sum( by_index[index].logical_bytes for index in available ), available_file_count=len(available), ) _set_selection(entry.available_file_indices, available) entries.append(entry) return sorted( entries, key=lambda entry: ( tuple(PurePosixPath(entry.canonical_path).parts), entry.type != resource_pb2.CONTENT_ENTRY_TYPE_DIRECTORY, ), ) def normalize_resource( torrent: dict[str, Any], raw_files: list[dict[str, Any]], metainfo_bytes: bytes, observed_at: datetime | None = None, ) -> NormalizedResource: metainfo = decode_metainfo(metainfo_bytes) # qBittorrent/libtorrent may omit torrent padding files from # /torrents/files while keeping them in the exported metainfo. visible_metainfo_files = tuple( item for item in metainfo.files if not item.padding ) if len(raw_files) == len(metainfo.files): matched_metainfo_files = metainfo.files elif len(raw_files) == len(visible_metainfo_files): matched_metainfo_files = visible_metainfo_files else: raise ResourceError("qBittorrent and metainfo file counts differ") files = [] canonical = True for expected_index, (raw, meta_file) in enumerate( zip(raw_files, matched_metainfo_files, strict=True) ): index = _integer(raw.get("index"), "file index") if index != expected_index: raise ResourceError("qBittorrent file indices are not contiguous") path = _path(raw.get("name")) size = _integer(raw.get("size"), "file size") if size != meta_file.logical_bytes or path != meta_file.path: canonical = False completed = raw.get("completed") if completed is None: progress = raw.get("progress", 0) if isinstance(progress, bool) or not isinstance(progress, (int, float)): raise ResourceError("file progress is invalid") completed = round(size * max(0.0, min(float(progress), 1.0))) completed = min(_integer(completed, "completed bytes"), size) priority = _integer(raw.get("priority", 0), "file priority") file = resource_pb2.TorrentFile( file_index=index, canonical_path=path, logical_bytes=size, completed_bytes=completed, selected=priority > 0, padding=meta_file.padding, ) files.append(file) observed_at = observed_at or datetime.now(timezone.utc) qb_torrent_id = _string(torrent.get("hash"), "torrent hash").lower() if len(qb_torrent_id) not in {40, 64} or any( character not in "0123456789abcdef" for character in qb_torrent_id ): raise ResourceError("torrent hash is invalid") save_path = _save_path(torrent.get("save_path")) summary = resource_pb2.ResourceSummary( qb_torrent_id=qb_torrent_id, display_name=_string(torrent.get("name"), "torrent name"), runtime_state=_runtime_state(_string(torrent.get("state"), "state")), total_logical_bytes=sum(file.logical_bytes for file in files), total_file_count=len(files), canonical_paths=canonical, ) summary.resource_id.info_hash_v1_hex = metainfo.info_hash_v1_hex summary.resource_id.info_hash_v2_hex = metainfo.info_hash_v2_hex selected = [file.file_index for file in files if file.selected] complete = [ file.file_index for file in files if file.selected and file.completed_bytes == file.logical_bytes ] _set_selection(summary.selected_files, selected) _set_selection(summary.selected_complete_files, complete) summary.selected_logical_bytes = sum( file.logical_bytes for file in files if file.selected ) summary.selected_complete_bytes = sum( file.logical_bytes for file in files if file.selected and file.completed_bytes == file.logical_bytes ) revision_data = [{ "index": file.file_index, "path": file.canonical_path, "size": file.logical_bytes, "completed": file.completed_bytes, "selected": file.selected, } for file in files] summary.content_revision = hashlib.sha256(json.dumps( revision_data, sort_keys=True, separators=(",", ":"), ).encode("utf-8")).hexdigest() summary.observed_at.FromDatetime(observed_at) return NormalizedResource( summary, tuple(files), metainfo, metainfo_bytes, save_path ) def _set_selection(target: Any, indices: list[int]) -> None: if not indices: return first = previous = indices[0] for index in indices[1:]: if index == previous + 1: previous = index continue target.ranges.add(first=first, last=previous) first = previous = index target.ranges.add(first=first, last=previous) def _runtime_state(state: str) -> int: lowered = state.lower() if "missing" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_MISSING if "error" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_ERROR if "check" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_CHECKING if "stalled" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_STALLED if "pause" in lowered or "stop" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_STOPPED if "queue" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_QUEUED if "download" in lowered or lowered in {"metadl", "forceddl", "allocating"}: return resource_pb2.TORRENT_RUNTIME_STATE_DOWNLOADING if lowered.endswith("up") or "upload" in lowered: return resource_pb2.TORRENT_RUNTIME_STATE_SEEDING return resource_pb2.TORRENT_RUNTIME_STATE_UNSPECIFIED def _path(value: Any) -> str: path = _string(value, "file path") candidate = PurePosixPath(path) if candidate.is_absolute() or ".." in candidate.parts or "\\" in path: raise ResourceError("qBittorrent file path is unsafe") return candidate.as_posix() def _save_path(value: Any) -> PurePosixPath: """Validate qBittorrent's API-visible per-torrent content root.""" path = _string(value, "torrent save path") candidate = PurePosixPath(path) if ( not candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts ): raise ResourceError("qBittorrent torrent save path is unsafe") return candidate def _integer(value: Any, name: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ResourceError(f"{name} is invalid") return value def _string(value: Any, name: str) -> str: if not isinstance(value, str) or not value: raise ResourceError(f"{name} is invalid") return value