Coverage for src/ph/downloader.py: 97.4%

181 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 08:17 +0000

1"""Native HTTP and selective BitTorrent download engine.""" 

2 

3import logging 

4import os 

5import re 

6import ssl 

7from collections.abc import Callable, Mapping, Sequence 

8from datetime import UTC, datetime 

9from email.utils import parsedate_to_datetime 

10from functools import partial 

11from pathlib import Path 

12from urllib.error import HTTPError, URLError 

13from urllib.parse import unquote, urlparse 

14from urllib.request import Request, urlopen 

15 

16from ph.bittorrent import ( 

17 BitTorrentCancelled, 

18 BitTorrentError, 

19 BitTorrentSettings, 

20 TorrentFileChoice, 

21 TorrentSelectionRequired, 

22 download_torrent_file, 

23) 

24from ph.models import DownloadResult, MediaDownload 

25from ph.store import USER_AGENT 

26 

27LOGGER = logging.getLogger(__name__) 

28 

29type ProgressCallback = Callable[[str, int, int | None], None] 

30type CancelCallback = Callable[[], bool] 

31 

32 

33class DownloadError(RuntimeError): 

34 """A download could not be completed.""" 

35 

36 

37class DownloadCancelled(DownloadError): 

38 """The user cancelled an in-progress download.""" 

39 

40 

41class DownloadRateLimited(DownloadError): 

42 """The remote store temporarily refused another download.""" 

43 

44 def __init__(self, retry_after_seconds: float | None = None) -> None: 

45 super().__init__("The store returned HTTP 429 Too Many Requests.") 

46 self.retry_after_seconds = retry_after_seconds 

47 

48 

49class DownloadSelectionRequired(DownloadError): 

50 """A changed torrent needs an explicit file choice from the user.""" 

51 

52 def __init__(self, error: TorrentSelectionRequired) -> None: 

53 super().__init__(str(error)) 

54 self.torrent_url = error.torrent_url 

55 self.expected_filename = error.expected_filename 

56 self.catalogue_index = error.catalogue_index 

57 self.candidates: tuple[TorrentFileChoice, ...] = error.candidates 

58 self.total_files = error.total_files 

59 

60 

61def _safe_filename(value: str) -> str: 

62 name = Path(unquote(value)).name 

63 name = re.sub(r"[\\/:*?\"<>|\x00-\x1f]", "_", name).strip(". ") 

64 return name or "download.bin" 

65 

66 

67def _filename_from_headers(headers: Mapping[str, str], url: str) -> str: 

68 disposition = headers.get("Content-Disposition", "") 

69 match = re.search(r"filename\*?=(?:UTF-8''|\")?([^\";]+)", disposition, re.IGNORECASE) 

70 if match: 

71 return _safe_filename(match.group(1).strip()) 

72 return _safe_filename(urlparse(url).path) 

73 

74 

75def download_files( 

76 downloads: Sequence[str | MediaDownload], 

77 directory: Path, 

78 referer: str, 

79 timeout_seconds: float = 30.0, 

80 progress: ProgressCallback | None = None, 

81 cancelled: CancelCallback | None = None, 

82 bittorrent_settings: BitTorrentSettings | None = None, 

83 *, 

84 resume: bool = False, 

85) -> list[DownloadResult]: 

86 """Download direct URLs or selected torrent files without external tools.""" 

87 

88 if not downloads: 

89 LOGGER.warning("Download requested with an empty media list") 

90 raise DownloadError("The download list is empty.") 

91 LOGGER.info("Downloading %d media item(s) into %s", len(downloads), directory) 

92 directory.mkdir(parents=True, exist_ok=True) 

93 resolved = [_as_media_download(download) for download in downloads] 

94 results: list[DownloadResult] = [] 

95 for download in resolved: 

96 _raise_if_cancelled(cancelled) 

97 if download.torrent_file_index is None: 

98 LOGGER.debug("Starting direct media download") 

99 results.extend( 

100 _download_with_urllib( 

101 [download.url], 

102 directory, 

103 referer, 

104 timeout_seconds, 

105 progress, 

106 cancelled, 

107 resume, 

108 ) 

109 ) 

110 continue 

111 if not download.expected_filename: 

112 raise DownloadError("The torrent download is missing its expected filename.") 

113 destination = ( 

114 directory / download.expected_filename 

115 if resume 

116 else _unique_download_path(directory / download.expected_filename) 

117 ) 

118 LOGGER.debug( 

119 "Starting torrent media download file=%r index=%d", 

120 download.expected_filename, 

121 download.torrent_file_index, 

122 ) 

123 try: 

124 download_torrent_file( 

125 download.url, 

126 download.torrent_file_index, 

127 download.expected_filename, 

128 destination, 

129 referer, 

130 timeout_seconds, 

131 partial(progress, download.expected_filename) if progress is not None else None, 

132 cancelled, 

133 bittorrent_settings, 

134 download.torrent_file_path, 

135 resume=resume, 

136 ) 

137 except BitTorrentCancelled as error: 

138 LOGGER.info("Torrent download cancelled") 

139 raise DownloadCancelled("Download cancelled.") from error 

140 except TorrentSelectionRequired as error: 

141 LOGGER.warning("Torrent file selection requires user confirmation: %s", error) 

142 raise DownloadSelectionRequired(error) from error 

143 except BitTorrentError as error: 

144 LOGGER.error("Torrent download failed: %s", error) 

145 raise DownloadError(str(error)) from error 

146 results.append(DownloadResult(download.url, destination)) 

147 LOGGER.info("Completed %d media download(s)", len(results)) 

148 return results 

149 

150 

151def _as_media_download(download: str | MediaDownload) -> MediaDownload: 

152 return download if isinstance(download, MediaDownload) else MediaDownload(download) 

153 

154 

155def _download_with_urllib( 

156 urls: Sequence[str], 

157 directory: Path, 

158 referer: str, 

159 timeout_seconds: float, 

160 progress: ProgressCallback | None, 

161 cancelled: CancelCallback | None, 

162 resume: bool = False, 

163) -> list[DownloadResult]: 

164 results: list[DownloadResult] = [] 

165 ssl_context = ssl.create_default_context() 

166 for url in urls: 

167 _raise_if_cancelled(cancelled) 

168 resumable_partial = _resumable_partial(directory) if resume else None 

169 resume_offset = resumable_partial.stat().st_size if resumable_partial is not None else 0 

170 headers = {"User-Agent": USER_AGENT, "Referer": referer} 

171 if resume_offset: 

172 headers["Range"] = f"bytes={resume_offset}-" 

173 request = Request(url, headers=headers) 

174 try: 

175 with urlopen( 

176 request, 

177 timeout=timeout_seconds, 

178 context=ssl_context, 

179 ) as response: 

180 response_status = getattr(response, "status", None) 

181 range_accepted = resume_offset > 0 and response_status == 206 

182 filename = _filename_from_headers(response.headers, response.geturl()) 

183 if range_accepted and resumable_partial is not None: 

184 partial = resumable_partial 

185 destination = partial.with_name(partial.name.removesuffix(".part")) 

186 else: 

187 destination = ( 

188 directory / filename 

189 if resume 

190 else _unique_download_path(directory / filename) 

191 ) 

192 partial = destination.with_name(destination.name + ".part") 

193 if resumable_partial is not None and resumable_partial != partial: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 resumable_partial.unlink(missing_ok=True) 

195 total_header = response.headers.get("Content-Length") 

196 response_total = ( 

197 int(total_header) if total_header and total_header.isdigit() else None 

198 ) 

199 downloaded = resume_offset if range_accepted else 0 

200 total = ( 

201 _content_range_total(response.headers.get("Content-Range")) 

202 if range_accepted 

203 else response_total 

204 ) 

205 if range_accepted and total is None and response_total is not None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 total = resume_offset + response_total 

207 try: 

208 if progress and downloaded: 

209 progress(filename, downloaded, total) 

210 with partial.open("ab" if range_accepted else "wb") as output: 

211 while True: 

212 _raise_if_cancelled(cancelled) 

213 chunk = response.read(1024 * 256) 

214 if not chunk: 

215 break 

216 output.write(chunk) 

217 downloaded += len(chunk) 

218 if progress: 

219 progress(filename, downloaded, total) 

220 _raise_if_cancelled(cancelled) 

221 os.replace(str(partial), str(destination)) 

222 except BaseException: 

223 if not resume: 223 ↛ 225line 223 didn't jump to line 225 because the condition on line 223 was always true

224 partial.unlink(missing_ok=True) 

225 raise 

226 results.append(DownloadResult(url=url, path=destination)) 

227 except HTTPError as error: 

228 if error.code == 416 and resumable_partial is not None: 

229 LOGGER.warning( 

230 "Server rejected HTTP resume; restarting %s", 

231 resumable_partial.name.removesuffix(".part"), 

232 ) 

233 resumable_partial.unlink(missing_ok=True) 

234 results.extend( 

235 _download_with_urllib( 

236 [url], 

237 directory, 

238 referer, 

239 timeout_seconds, 

240 progress, 

241 cancelled, 

242 resume=True, 

243 ) 

244 ) 

245 continue 

246 if error.code == 429: 

247 retry_after = _retry_after_seconds(error.headers.get("Retry-After")) 

248 LOGGER.warning( 

249 "HTTP download rate limited status=429 retry_after_seconds=%s", 

250 retry_after, 

251 ) 

252 raise DownloadRateLimited(retry_after) from error 

253 LOGGER.error("HTTP download returned status=%d", error.code) 

254 raise DownloadError("Download returned HTTP %d." % error.code) from error 

255 except (URLError, TimeoutError, OSError) as error: 

256 reason = getattr(error, "reason", error) 

257 LOGGER.error("HTTP download failed: %s", reason) 

258 raise DownloadError(f"Download failed: {reason}") from error 

259 return results 

260 

261 

262def _resumable_partial(directory: Path) -> Path | None: 

263 partials = tuple(path for path in directory.glob("*.part") if path.is_file()) 

264 return partials[0] if len(partials) == 1 else None 

265 

266 

267def _content_range_total(value: str | None) -> int | None: 

268 if not value: 

269 return None 

270 match = re.fullmatch(r"bytes\s+\d+-\d+/(\d+|\*)", value.strip(), re.IGNORECASE) 

271 if match is None or match.group(1) == "*": 

272 return None 

273 return int(match.group(1)) 

274 

275 

276def _retry_after_seconds(value: str | None) -> float | None: 

277 if value is None: 

278 return None 

279 normalized = value.strip() 

280 if normalized.isdigit(): 

281 return float(normalized) 

282 try: 

283 retry_at = parsedate_to_datetime(normalized) 

284 except TypeError, ValueError, OverflowError: 

285 return None 

286 if retry_at.tzinfo is None: 286 ↛ 288line 286 didn't jump to line 288 because the condition on line 286 was always true

287 retry_at = retry_at.replace(tzinfo=UTC) 

288 return max(0.0, (retry_at - datetime.now(UTC)).total_seconds()) 

289 

290 

291def _raise_if_cancelled(cancelled: CancelCallback | None) -> None: 

292 if cancelled is not None and cancelled(): 

293 raise DownloadCancelled("Download cancelled.") 

294 

295 

296def _unique_download_path(destination: Path) -> Path: 

297 partial = destination.with_name(destination.name + ".part") 

298 if not destination.exists() and not partial.exists(): 

299 return destination 

300 suffixes = "".join(destination.suffixes) 

301 stem = destination.name[: -len(suffixes)] if suffixes else destination.name 

302 counter = 2 

303 while True: 

304 candidate = destination.with_name(f"{stem} ({counter}){suffixes}") 

305 partial = candidate.with_name(candidate.name + ".part") 

306 if not candidate.exists() and not partial.exists(): 

307 return candidate 

308 counter += 1