mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-03 10:45:45 +01:00
196 lines
5.7 KiB
Python
196 lines
5.7 KiB
Python
"""Remote path mapping utilities.
|
|
|
|
Used when an external download client reports a completed download path that does
|
|
not exist inside the Shelfmark runtime environment (commonly different Docker
|
|
volume mounts).
|
|
|
|
A mapping rewrites a remote path prefix into a local path prefix.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PureWindowsPath
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
_WINDOWS_DRIVE_PREFIX_LENGTH = 2
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RemotePathMapping:
|
|
"""Mapping from a remote path prefix to a local path prefix."""
|
|
|
|
host: str
|
|
remote_path: str
|
|
local_path: str
|
|
|
|
|
|
def _normalize_prefix(path: str) -> str:
|
|
normalized = str(path or "").strip()
|
|
if not normalized:
|
|
return ""
|
|
|
|
normalized = normalized.replace("\\", "/")
|
|
|
|
if normalized != "/":
|
|
normalized = normalized.rstrip("/")
|
|
|
|
return normalized
|
|
|
|
|
|
def _is_windows_path(path: str) -> bool:
|
|
"""Check if a path looks like a Windows path (has a drive letter like C:/)."""
|
|
return len(path) >= _WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":" and path[0].isalpha()
|
|
|
|
|
|
def _normalize_host(host: str) -> str:
|
|
return str(host or "").strip().lower()
|
|
|
|
|
|
def _is_relative_to(path: Path, prefix: Path) -> bool:
|
|
try:
|
|
path.relative_to(prefix)
|
|
except ValueError:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _join_contained_path(local_prefix: str, remainder: str) -> Path | None:
|
|
local_path = Path(local_prefix)
|
|
|
|
if remainder:
|
|
remainder_path = Path(remainder)
|
|
windows_remainder_path = PureWindowsPath(remainder)
|
|
|
|
if (
|
|
remainder_path.is_absolute()
|
|
or windows_remainder_path.is_absolute()
|
|
or ".." in remainder_path.parts
|
|
or ".." in windows_remainder_path.parts
|
|
):
|
|
return None
|
|
|
|
remapped = local_path / remainder_path
|
|
else:
|
|
remapped = local_path
|
|
|
|
resolved_local_path = local_path.resolve(strict=False)
|
|
resolved_remapped = remapped.resolve(strict=False)
|
|
if not _is_relative_to(resolved_remapped, resolved_local_path):
|
|
return None
|
|
|
|
return remapped
|
|
|
|
|
|
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
|
|
"""Parse configured remote-path mapping rows into normalized mappings."""
|
|
if not value or not isinstance(value, list):
|
|
return []
|
|
|
|
mappings: list[RemotePathMapping] = []
|
|
|
|
for row in value:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
|
|
host = _normalize_host(row.get("host", ""))
|
|
remote_path = _normalize_prefix(row.get("remotePath", ""))
|
|
local_path = _normalize_prefix(row.get("localPath", ""))
|
|
|
|
if not host or not remote_path or not local_path:
|
|
continue
|
|
|
|
mappings.append(
|
|
RemotePathMapping(host=host, remote_path=remote_path, local_path=local_path)
|
|
)
|
|
|
|
mappings.sort(key=lambda m: len(m.remote_path), reverse=True)
|
|
return mappings
|
|
|
|
|
|
def remap_remote_to_local_with_match(
|
|
*,
|
|
mappings: Iterable[RemotePathMapping],
|
|
host: str,
|
|
remote_path: str | Path,
|
|
) -> tuple[Path | None, bool]:
|
|
"""Remap a remote path and report whether a configured mapping matched.
|
|
|
|
Returns ``(None, True)`` when a mapping prefix matched but the remainder was
|
|
unsafe to join under the local prefix.
|
|
"""
|
|
host_normalized = _normalize_host(host)
|
|
remote_normalized = _normalize_prefix(str(remote_path))
|
|
|
|
if not remote_normalized:
|
|
return Path(str(remote_path)), False
|
|
|
|
# Windows paths are case-insensitive, so we need case-insensitive matching
|
|
# for paths that look like Windows paths (e.g., D:/Torrents)
|
|
is_windows = _is_windows_path(remote_normalized)
|
|
|
|
for mapping in mappings:
|
|
if _normalize_host(mapping.host) != host_normalized:
|
|
continue
|
|
|
|
remote_prefix = _normalize_prefix(mapping.remote_path)
|
|
if not remote_prefix:
|
|
continue
|
|
|
|
# For Windows paths, do case-insensitive prefix matching
|
|
if is_windows:
|
|
remote_lower = remote_normalized.lower()
|
|
prefix_lower = remote_prefix.lower()
|
|
matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/")
|
|
else:
|
|
matches = remote_normalized == remote_prefix or remote_normalized.startswith(
|
|
remote_prefix + "/"
|
|
)
|
|
|
|
if matches:
|
|
# Use the length of the original prefix to extract remainder
|
|
# This preserves the original case in folder names
|
|
remainder = remote_normalized[len(remote_prefix) :]
|
|
local_prefix = _normalize_prefix(mapping.local_path)
|
|
|
|
remainder = remainder.removeprefix("/")
|
|
|
|
remapped = _join_contained_path(local_prefix, remainder)
|
|
if remapped is None:
|
|
return None, True
|
|
|
|
return remapped, True
|
|
|
|
return Path(remote_normalized), False
|
|
|
|
|
|
def remap_remote_to_local(
|
|
*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path
|
|
) -> Path:
|
|
"""Remap a remote path to a local path using the configured mappings."""
|
|
remapped, _ = remap_remote_to_local_with_match(
|
|
mappings=mappings,
|
|
host=host,
|
|
remote_path=remote_path,
|
|
)
|
|
if remapped is None:
|
|
return Path(str(remote_path))
|
|
return remapped
|
|
|
|
|
|
def get_client_host_identifier(client: object) -> str | None:
|
|
"""Return a stable identifier used by the mapping UI.
|
|
|
|
Sonarr uses the download client's configured host. Shelfmark currently uses
|
|
the download client 'name' (e.g. qbittorrent, sabnzbd).
|
|
"""
|
|
name = getattr(client, "name", None)
|
|
if isinstance(name, str) and name.strip():
|
|
return name.strip().lower()
|
|
|
|
return None
|