Coverage for src/ph/download_queue.py: 92.0%
481 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-28 08:17 +0000
1"""Persistent concurrent game-download queue."""
3import json
4import logging
5import os
6import random
7import shutil
8import time
9import uuid
10from collections.abc import Callable, Sequence
11from concurrent.futures import Future, ThreadPoolExecutor
12from dataclasses import asdict, dataclass, field, replace
13from enum import StrEnum
14from pathlib import Path
15from threading import Event, RLock, Timer
16from typing import Any, cast
18from ph.bittorrent import BitTorrentSettings, TorrentFileChoice
19from ph.downloader import (
20 DownloadCancelled,
21 DownloadError,
22 DownloadRateLimited,
23 DownloadSelectionRequired,
24 download_files,
25)
26from ph.library import replace_game
27from ph.models import DownloadResult, InstalledGame, MediaDownload, Platform
28from ph.organizer import OrganizeError, install_bundled_bios, install_downloads
30LOGGER = logging.getLogger(__name__)
32QUEUE_FILENAME = ".pocket-harbor-downloads.json"
33QUEUE_DIRECTORY = ".pocket-harbor-downloads"
34QUEUE_FORMAT_VERSION = 1
35DEFAULT_CONCURRENT_DOWNLOADS = 3
36RATE_LIMIT_RETRY_BASE_SECONDS = 15.0
37RATE_LIMIT_RETRY_MAX_SECONDS = 60.0 * 60.0
38RATE_LIMIT_RETRY_JITTER = 0.2
40type DownloadRunner = Callable[..., list[DownloadResult]]
41type DownloadInstaller = Callable[..., list[DownloadResult]]
42type GameReplacer = Callable[[InstalledGame, DownloadResult], DownloadResult]
43type BundledBiosInstaller = Callable[..., tuple[Path, ...]]
44type RetryDelay = Callable[[int, float | None], float]
47@dataclass(frozen=True, slots=True)
48class RateLimitRetrySettings:
49 """User-configurable exponential backoff policy for HTTP 429 responses."""
51 base_seconds: float = RATE_LIMIT_RETRY_BASE_SECONDS
52 max_seconds: float = RATE_LIMIT_RETRY_MAX_SECONDS
53 jitter_ratio: float = RATE_LIMIT_RETRY_JITTER
55 def __post_init__(self) -> None:
56 if self.base_seconds <= 0:
57 raise ValueError("Initial retry delay must be positive.")
58 if self.max_seconds < self.base_seconds:
59 raise ValueError("Maximum retry delay must not be shorter than the initial delay.")
60 if not 0 <= self.jitter_ratio <= 1:
61 raise ValueError("Retry jitter ratio must be between 0 and 1.")
64class DownloadState(StrEnum):
65 """Stable states exposed to the TUI and persisted between launches."""
67 QUEUED = "queued"
68 DOWNLOADING = "downloading"
69 RATE_LIMITED = "rate_limited"
70 PAUSED = "paused"
71 FAILED = "failed"
72 CANCELLED = "cancelled"
73 COMPLETED = "completed"
76@dataclass(frozen=True, slots=True)
77class DownloadJob:
78 """Read-only snapshot of one queued game download."""
80 job_id: str
81 title: str
82 store_id: str
83 store_name: str
84 state: DownloadState
85 filename: str
86 downloaded_bytes: int
87 total_bytes: int | None
88 error: str | None
89 created_at: float
90 completed_path: Path | None
91 torrent_candidates: tuple[TorrentFileChoice, ...]
92 platform: Platform
93 roms_directory: Path
94 region: str | None
95 bundled_bios_count: int
96 is_update: bool
97 retry_attempt: int = 0
98 retry_at: float | None = None
99 bios_directory: str = "bios"
102@dataclass(slots=True)
103class _QueuedDownload:
104 job_id: str
105 title: str
106 store_id: str
107 store_name: str
108 referrer: str
109 media: tuple[MediaDownload, ...]
110 platform: Platform
111 roms_directory: Path
112 bios_directory: str
113 timeout_seconds: float
114 bittorrent_settings: BitTorrentSettings | None
115 region: str | None
116 replacement_game: InstalledGame | None = None
117 state: DownloadState = DownloadState.QUEUED
118 filename: str = ""
119 downloaded_bytes: int = 0
120 total_bytes: int | None = None
121 error: str | None = None
122 created_at: float = field(default_factory=time.time)
123 completed_path: Path | None = None
124 bundled_bios_count: int = 0
125 torrent_candidates: tuple[TorrentFileChoice, ...] = ()
126 stop_reason: str | None = None
127 stop_event: Event = field(default_factory=Event, repr=False)
128 last_persisted_at: float = field(default=0.0, repr=False)
129 retry_attempt: int = 0
130 retry_at: float | None = None
132 def snapshot(self) -> DownloadJob:
133 return DownloadJob(
134 job_id=self.job_id,
135 title=self.title,
136 store_id=self.store_id,
137 store_name=self.store_name,
138 state=self.state,
139 filename=self.filename,
140 downloaded_bytes=self.downloaded_bytes,
141 total_bytes=self.total_bytes,
142 error=self.error,
143 created_at=self.created_at,
144 completed_path=self.completed_path,
145 torrent_candidates=self.torrent_candidates,
146 platform=self.platform,
147 roms_directory=self.roms_directory,
148 region=self.region,
149 bundled_bios_count=self.bundled_bios_count,
150 is_update=self.replacement_game is not None,
151 retry_attempt=self.retry_attempt,
152 retry_at=self.retry_at,
153 bios_directory=self.bios_directory,
154 )
157class DownloadQueue:
158 """Run several downloads concurrently and preserve unfinished jobs on disk."""
160 def __init__(
161 self,
162 download_directory: Path,
163 *,
164 max_concurrent: int = DEFAULT_CONCURRENT_DOWNLOADS,
165 runner: DownloadRunner = download_files,
166 installer: DownloadInstaller = install_downloads,
167 replacer: GameReplacer = replace_game,
168 bios_installer: BundledBiosInstaller = install_bundled_bios,
169 retry_delay: RetryDelay | None = None,
170 retry_settings: RateLimitRetrySettings | None = None,
171 ) -> None:
172 if max_concurrent <= 0:
173 raise ValueError("The concurrent download count must be positive.")
174 self.download_directory = download_directory
175 self.queue_path = download_directory / QUEUE_FILENAME
176 self.staging_root = download_directory / QUEUE_DIRECTORY
177 self._runner = runner
178 self._installer = installer
179 self._replacer = replacer
180 self._bios_installer = bios_installer
181 self._retry_delay = retry_delay
182 self._retry_settings = retry_settings or RateLimitRetrySettings()
183 self._lock = RLock()
184 self._executor = ThreadPoolExecutor(
185 max_workers=max_concurrent,
186 thread_name_prefix="pocket-harbor-download",
187 )
188 self._jobs: dict[str, _QueuedDownload] = {}
189 self._futures: dict[str, Future[None]] = {}
190 self._retry_timers: dict[str, Timer] = {}
191 self._closing = False
192 self._refresh_required = False
193 self._load()
194 with self._lock:
195 for job in self._jobs.values():
196 if job.state in {DownloadState.QUEUED, DownloadState.DOWNLOADING}:
197 job.state = DownloadState.QUEUED
198 self._submit_locked(job)
199 elif job.state is DownloadState.RATE_LIMITED:
200 self._schedule_rate_limit_retry_locked(job)
201 self._save_locked()
203 def enqueue(
204 self,
205 *,
206 title: str,
207 store_id: str,
208 store_name: str,
209 referrer: str,
210 media: Sequence[MediaDownload],
211 platform: Platform,
212 roms_directory: Path,
213 timeout_seconds: float,
214 bios_directory: str = "bios",
215 bittorrent_settings: BitTorrentSettings | None = None,
216 region: str | None = None,
217 replacement_game: InstalledGame | None = None,
218 ) -> DownloadJob:
219 """Persist and start one game download using a snapshot of its store settings."""
221 if not media:
222 raise DownloadError("The download list is empty.")
223 job = _QueuedDownload(
224 job_id=uuid.uuid4().hex[:12],
225 title=title.strip() or media[0].expected_filename or "Download",
226 store_id=store_id,
227 store_name=store_name,
228 referrer=referrer,
229 media=tuple(media),
230 platform=platform,
231 roms_directory=roms_directory,
232 bios_directory=bios_directory,
233 timeout_seconds=timeout_seconds,
234 bittorrent_settings=bittorrent_settings,
235 region=region,
236 replacement_game=replacement_game,
237 )
238 with self._lock:
239 if self._closing:
240 raise DownloadError("The download queue is closing.")
241 self._jobs[job.job_id] = job
242 self._save_locked()
243 self._submit_locked(job)
244 LOGGER.info(
245 "Queued game download id=%s title=%r store=%s platform=%s",
246 job.job_id,
247 job.title,
248 job.store_id,
249 job.platform.alias,
250 )
251 return job.snapshot()
253 def jobs(self) -> tuple[DownloadJob, ...]:
254 """Return stable snapshots ordered with the newest job first."""
256 with self._lock:
257 return tuple(
258 job.snapshot()
259 for job in sorted(
260 self._jobs.values(),
261 key=lambda item: item.created_at,
262 reverse=True,
263 )
264 )
266 def find(self, job_id: str) -> DownloadJob | None:
267 with self._lock:
268 job = self._jobs.get(job_id)
269 return job.snapshot() if job is not None else None
271 def dismiss_completed(self, job_id: str) -> bool:
272 """Remove a completed job after the interface has acknowledged it."""
274 with self._lock:
275 job = self._jobs.get(job_id)
276 if job is None or job.state is not DownloadState.COMPLETED:
277 return False
278 del self._jobs[job_id]
279 self._futures.pop(job_id, None)
280 self._save_locked()
281 LOGGER.debug("Removed acknowledged completed download id=%s", job_id)
282 return True
284 def pause(self, job_id: str) -> bool:
285 """Pause a queued or active job while keeping its verified partial data."""
287 with self._lock:
288 job = self._jobs.get(job_id)
289 if job is None or job.state not in {
290 DownloadState.QUEUED,
291 DownloadState.DOWNLOADING,
292 DownloadState.RATE_LIMITED,
293 }:
294 return False
295 if job.state is DownloadState.RATE_LIMITED:
296 self._cancel_retry_timer_locked(job_id)
297 job.state = DownloadState.PAUSED
298 self._save_locked()
299 return True
300 job.stop_reason = "pause"
301 job.stop_event.set()
302 future = self._futures.get(job_id)
303 if future is not None and future.cancel(): 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 job.state = DownloadState.PAUSED
305 job.stop_event.clear()
306 self._save_locked()
307 return True
309 def resume(self, job_id: str) -> bool:
310 """Resume an explicitly paused job."""
312 with self._lock:
313 job = self._jobs.get(job_id)
314 if job is None or job.state is not DownloadState.PAUSED or self._closing:
315 return False
316 self._reset_for_run(job, keep_progress=True)
317 self._save_locked()
318 self._submit_locked(job)
319 return True
321 def retry(self, job_id: str) -> bool:
322 """Retry a failed or cancelled job, reusing any safe partial data."""
324 with self._lock:
325 job = self._jobs.get(job_id)
326 if (
327 job is None
328 or job.state
329 not in {
330 DownloadState.FAILED,
331 DownloadState.CANCELLED,
332 }
333 or self._closing
334 ):
335 return False
336 self._reset_for_run(job, keep_progress=job.state is DownloadState.FAILED)
337 self._save_locked()
338 self._submit_locked(job)
339 return True
341 def cancel(self, job_id: str) -> bool:
342 """Cancel a job and remove its incomplete transfer data."""
344 with self._lock:
345 job = self._jobs.get(job_id)
346 if job is None or job.state in {
347 DownloadState.CANCELLED,
348 DownloadState.COMPLETED,
349 }:
350 return False
351 if job.state is DownloadState.RATE_LIMITED:
352 self._cancel_retry_timer_locked(job_id)
353 job.state = DownloadState.CANCELLED
354 self._remove_staging(job.job_id)
355 self._save_locked()
356 return True
357 job.stop_reason = "cancel"
358 job.stop_event.set()
359 future = self._futures.get(job_id)
360 if (future is not None and future.cancel()) or job.state in { 360 ↛ 364line 360 didn't jump to line 364 because the condition on line 360 was never true
361 DownloadState.PAUSED,
362 DownloadState.FAILED,
363 }:
364 job.state = DownloadState.CANCELLED
365 self._remove_staging(job.job_id)
366 self._save_locked()
367 return True
369 def choose_torrent_file(self, job_id: str, choice: TorrentFileChoice) -> bool:
370 """Apply an explicit Minerva file choice before retrying a failed job."""
372 with self._lock:
373 job = self._jobs.get(job_id)
374 if job is None or job.state is not DownloadState.FAILED:
375 return False
376 changed = False
377 updated: list[MediaDownload] = []
378 for media in job.media:
379 if not changed and media.torrent_file_index is not None:
380 updated.append(
381 replace(
382 media,
383 torrent_file_index=choice.index,
384 expected_filename=choice.filename,
385 torrent_file_path=choice.path,
386 )
387 )
388 changed = True
389 else:
390 updated.append(media)
391 if not changed:
392 return False
393 job.media = tuple(updated)
394 job.torrent_candidates = ()
395 job.error = None
396 self._save_locked()
397 return True
399 @property
400 def refresh_required(self) -> bool:
401 with self._lock:
402 return self._refresh_required
404 def update_retry_settings(self, settings: RateLimitRetrySettings) -> None:
405 """Apply a changed backoff policy to retry delays scheduled from now on."""
407 with self._lock:
408 self._retry_settings = settings
410 def mark_refreshed(self) -> None:
411 """Persist that the frontend has observed all completed installations."""
413 with self._lock:
414 self._refresh_required = False
415 self._save_locked(include_terminal=not self._closing)
417 def shutdown(self) -> None:
418 """Stop workers safely; interrupted jobs resume automatically next launch."""
420 with self._lock:
421 if self._closing:
422 return
423 self._closing = True
424 for job in self._jobs.values():
425 if job.state not in {DownloadState.QUEUED, DownloadState.DOWNLOADING}:
426 continue
427 job.stop_reason = "shutdown"
428 job.stop_event.set()
429 future = self._futures.get(job.job_id)
430 if future is not None and future.cancel(): 430 ↛ 431line 430 didn't jump to line 431 because the condition on line 430 was never true
431 job.state = DownloadState.QUEUED
432 for timer in self._retry_timers.values():
433 timer.cancel()
434 self._retry_timers.clear()
435 self._save_locked(include_terminal=False)
436 self._executor.shutdown(wait=True, cancel_futures=True)
437 with self._lock:
438 self._save_locked(include_terminal=False)
439 LOGGER.info("Download queue stopped with %d persistent job(s)", len(self._jobs))
441 def _submit_locked(self, job: _QueuedDownload) -> None:
442 job.stop_reason = None
443 job.stop_event.clear()
444 self._futures[job.job_id] = self._executor.submit(self._run, job.job_id)
446 def _run(self, job_id: str) -> None:
447 with self._lock:
448 job = self._jobs.get(job_id)
449 if job is None or job.state is not DownloadState.QUEUED or self._closing: 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true
450 return
451 job.state = DownloadState.DOWNLOADING
452 self._save_locked()
453 job_directory = self.staging_root / job.job_id
455 installed_bios: list[Path] = []
456 try:
457 downloads = self._runner(
458 job.media,
459 job_directory,
460 job.referrer,
461 job.timeout_seconds,
462 lambda label, current, total: self._progress(job_id, label, current, total),
463 job.stop_event.is_set,
464 bittorrent_settings=job.bittorrent_settings,
465 resume=True,
466 )
467 if job.stop_event.is_set(): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 raise DownloadCancelled("Download interrupted.")
469 if job.replacement_game is not None:
470 if len(downloads) != 1: 470 ↛ 471line 470 didn't jump to line 471 because the condition on line 470 was never true
471 raise OrganizeError("A game update must contain exactly one download.")
472 installed_bios.extend(
473 self._bios_installer(
474 downloads[0].path,
475 job.platform,
476 job.roms_directory,
477 job.bios_directory,
478 )
479 )
480 completed = [self._replacer(job.replacement_game, downloads[0])]
481 else:
482 completed = self._installer(
483 downloads,
484 job.platform,
485 job.roms_directory,
486 installed_bios.append,
487 job.bios_directory,
488 )
489 if not completed:
490 raise OrganizeError("The completed download was not installed.")
491 except DownloadSelectionRequired as error:
492 self._finish_failed(job_id, str(error), error.candidates)
493 return
494 except DownloadRateLimited as error:
495 self._finish_rate_limited(job_id, error)
496 return
497 except DownloadCancelled:
498 self._finish_interrupted(job_id)
499 return
500 except (DownloadError, OrganizeError, OSError) as error:
501 self._finish_failed(job_id, str(error))
502 return
503 except Exception as error: # pragma: no cover - final worker boundary
504 LOGGER.exception("Unexpected background download failure id=%s", job_id)
505 self._finish_failed(job_id, str(error))
506 return
508 with self._lock:
509 job = self._jobs.get(job_id)
510 if job is None: 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true
511 return
512 job.state = DownloadState.COMPLETED
513 job.completed_path = completed[0].path
514 job.bundled_bios_count = len(installed_bios)
515 job.downloaded_bytes = job.total_bytes or job.downloaded_bytes
516 job.error = None
517 self._refresh_required = True
518 self._save_locked()
519 self._remove_staging(job_id)
520 LOGGER.info("Background download installed id=%s path=%s", job_id, completed[0].path)
522 def _progress(self, job_id: str, label: str, current: int, total: int | None) -> None:
523 with self._lock:
524 job = self._jobs.get(job_id)
525 if job is None: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true
526 return
527 job.filename = label
528 job.downloaded_bytes = current
529 job.total_bytes = total
530 now = time.monotonic()
531 if now - job.last_persisted_at >= 5:
532 job.last_persisted_at = now
533 self._save_locked()
535 def _finish_interrupted(self, job_id: str) -> None:
536 with self._lock:
537 job = self._jobs.get(job_id)
538 if job is None: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 return
540 reason = job.stop_reason
541 if reason == "cancel":
542 job.state = DownloadState.CANCELLED
543 self._remove_staging(job_id)
544 elif reason == "pause":
545 job.state = DownloadState.PAUSED
546 else:
547 job.state = DownloadState.QUEUED
548 job.stop_event.clear()
549 self._save_locked(include_terminal=not self._closing)
550 LOGGER.info("Background download interrupted id=%s reason=%s", job_id, reason)
552 def _finish_failed(
553 self,
554 job_id: str,
555 message: str,
556 candidates: tuple[TorrentFileChoice, ...] = (),
557 ) -> None:
558 with self._lock:
559 job = self._jobs.get(job_id)
560 if job is None: 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true
561 return
562 job.state = DownloadState.FAILED
563 job.error = message
564 job.torrent_candidates = candidates
565 job.stop_event.clear()
566 self._save_locked()
567 LOGGER.error("Background download failed id=%s: %s", job_id, message)
569 def _finish_rate_limited(self, job_id: str, error: DownloadRateLimited) -> None:
570 with self._lock:
571 job = self._jobs.get(job_id)
572 if job is None: 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true
573 return
574 job.retry_attempt += 1
575 delay = max(
576 0.0,
577 self._retry_delay(job.retry_attempt, error.retry_after_seconds)
578 if self._retry_delay is not None
579 else _rate_limit_retry_delay(
580 job.retry_attempt,
581 error.retry_after_seconds,
582 self._retry_settings,
583 ),
584 )
585 job.state = DownloadState.RATE_LIMITED
586 job.error = str(error)
587 job.retry_at = time.time() + delay
588 job.stop_event.clear()
589 self._save_locked()
590 if not self._closing: 590 ↛ 592line 590 didn't jump to line 592
591 self._schedule_rate_limit_retry_locked(job)
592 LOGGER.warning(
593 "Background download rate limited id=%s store=%s attempt=%d retry_in=%.1fs",
594 job_id,
595 job.store_id,
596 job.retry_attempt,
597 delay,
598 )
600 def _schedule_rate_limit_retry_locked(self, job: _QueuedDownload) -> None:
601 self._cancel_retry_timer_locked(job.job_id)
602 delay = max(0.0, (job.retry_at or time.time()) - time.time())
603 timer = Timer(delay, self._retry_rate_limited, args=(job.job_id,))
604 timer.daemon = True
605 self._retry_timers[job.job_id] = timer
606 timer.start()
608 def _retry_rate_limited(self, job_id: str) -> None:
609 with self._lock:
610 self._retry_timers.pop(job_id, None)
611 job = self._jobs.get(job_id)
612 if job is None or job.state is not DownloadState.RATE_LIMITED or self._closing: 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true
613 return
614 job.state = DownloadState.QUEUED
615 job.retry_at = None
616 self._save_locked()
617 self._submit_locked(job)
619 def _cancel_retry_timer_locked(self, job_id: str) -> None:
620 timer = self._retry_timers.pop(job_id, None)
621 if timer is not None:
622 timer.cancel()
624 def _reset_for_run(self, job: _QueuedDownload, *, keep_progress: bool) -> None:
625 job.state = DownloadState.QUEUED
626 job.error = None
627 job.stop_reason = None
628 job.stop_event.clear()
629 job.retry_attempt = 0
630 job.retry_at = None
631 if not keep_progress:
632 self._remove_staging(job.job_id)
633 job.filename = ""
634 job.downloaded_bytes = 0
635 job.total_bytes = None
637 def _remove_staging(self, job_id: str) -> None:
638 if not job_id or "/" in job_id or "\\" in job_id: 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true
639 return
640 shutil.rmtree(self.staging_root / job_id, ignore_errors=True)
642 def _load(self) -> None:
643 try:
644 payload = json.loads(self.queue_path.read_text(encoding="utf-8"))
645 except FileNotFoundError:
646 return
647 except (OSError, UnicodeError, json.JSONDecodeError) as error:
648 LOGGER.warning("Could not read persistent download queue: %s", error)
649 return
650 if not isinstance(payload, dict) or payload.get("version") != QUEUE_FORMAT_VERSION:
651 LOGGER.warning("Ignoring an unsupported persistent download queue")
652 return
653 self._refresh_required = payload.get("refresh_required") is True
654 raw_jobs = payload.get("jobs")
655 if not isinstance(raw_jobs, list):
656 return
657 for raw_job in raw_jobs:
658 try:
659 job = _job_from_json(raw_job)
660 except KeyError, TypeError, ValueError:
661 LOGGER.warning("Ignoring a malformed persistent download job")
662 continue
663 if (
664 job.state is DownloadState.FAILED
665 and job.error is not None
666 and "HTTP 429" in job.error
667 ):
668 job.state = DownloadState.RATE_LIMITED
669 job.retry_attempt = max(1, job.retry_attempt)
670 job.retry_at = time.time()
671 LOGGER.info("Migrated rate-limited download for automatic retry id=%s", job.job_id)
672 if job.state not in {DownloadState.COMPLETED, DownloadState.CANCELLED}: 672 ↛ 657line 672 didn't jump to line 657 because the condition on line 672 was always true
673 self._jobs[job.job_id] = job
675 def _save_locked(self, *, include_terminal: bool = True) -> None:
676 jobs = [
677 _job_to_json(job)
678 for job in self._jobs.values()
679 if include_terminal
680 or job.state not in {DownloadState.COMPLETED, DownloadState.CANCELLED}
681 ]
682 payload = {
683 "version": QUEUE_FORMAT_VERSION,
684 "refresh_required": self._refresh_required,
685 "jobs": jobs,
686 }
687 temporary = self.queue_path.with_name(self.queue_path.name + ".tmp")
688 try:
689 self.download_directory.mkdir(parents=True, exist_ok=True)
690 temporary.write_text(
691 json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
692 encoding="utf-8",
693 )
694 os.replace(temporary, self.queue_path)
695 except OSError as error:
696 temporary.unlink(missing_ok=True)
697 LOGGER.error("Could not persist download queue: %s", error)
700def _job_to_json(job: _QueuedDownload) -> dict[str, object]:
701 return {
702 "id": job.job_id,
703 "title": job.title,
704 "store_id": job.store_id,
705 "store_name": job.store_name,
706 "referrer": job.referrer,
707 "media": [
708 {
709 "url": media.url,
710 "torrent_file_index": media.torrent_file_index,
711 "expected_filename": media.expected_filename,
712 "torrent_file_path": list(media.torrent_file_path)
713 if media.torrent_file_path is not None
714 else None,
715 }
716 for media in job.media
717 ],
718 "platform": {
719 "name": job.platform.name,
720 "slug": job.platform.slug,
721 "code": job.platform.code,
722 "alias": job.platform.alias,
723 "rom_folder": job.platform.rom_folder,
724 "alternate_folders": list(job.platform.alternate_folders),
725 },
726 "roms_directory": str(job.roms_directory),
727 "bios_directory": job.bios_directory,
728 "timeout_seconds": job.timeout_seconds,
729 "bittorrent_settings": asdict(job.bittorrent_settings)
730 if job.bittorrent_settings is not None
731 else None,
732 "region": job.region,
733 "replacement_game": _installed_game_to_json(job.replacement_game),
734 "state": job.state.value,
735 "filename": job.filename,
736 "downloaded_bytes": job.downloaded_bytes,
737 "total_bytes": job.total_bytes,
738 "error": job.error,
739 "created_at": job.created_at,
740 "completed_path": str(job.completed_path) if job.completed_path is not None else None,
741 "bundled_bios_count": job.bundled_bios_count,
742 "torrent_candidates": [
743 {
744 "index": choice.index,
745 "path": list(choice.path),
746 "length": choice.length,
747 "match_score": choice.match_score,
748 }
749 for choice in job.torrent_candidates
750 ],
751 "retry_attempt": job.retry_attempt,
752 "retry_at": job.retry_at,
753 }
756def _job_from_json(value: object) -> _QueuedDownload:
757 payload = _dictionary(value)
758 platform_payload = _dictionary(payload["platform"])
759 media_payload = _list(payload["media"])
760 settings_payload = payload.get("bittorrent_settings")
761 candidates_payload = _list(payload.get("torrent_candidates", []))
762 platform = Platform(
763 _string(platform_payload["name"]),
764 _string(platform_payload["slug"]),
765 _string(platform_payload["code"]),
766 _string(platform_payload["alias"]),
767 _optional_string(platform_payload.get("rom_folder")),
768 tuple(_string(item) for item in _list(platform_payload.get("alternate_folders", []))),
769 )
770 return _QueuedDownload(
771 job_id=_string(payload["id"]),
772 title=_string(payload["title"]),
773 store_id=_string(payload["store_id"]),
774 store_name=_string(payload["store_name"]),
775 referrer=_string(payload["referrer"]),
776 media=tuple(_media_from_json(item) for item in media_payload),
777 platform=platform,
778 roms_directory=Path(_string(payload["roms_directory"])),
779 bios_directory=_string(payload.get("bios_directory", "bios")),
780 timeout_seconds=_number(payload["timeout_seconds"]),
781 bittorrent_settings=(
782 BitTorrentSettings(**_dictionary(settings_payload))
783 if settings_payload is not None
784 else None
785 ),
786 region=_optional_string(payload.get("region")),
787 replacement_game=_installed_game_from_json(payload.get("replacement_game"), platform),
788 state=DownloadState(_string(payload["state"])),
789 filename=_string(payload.get("filename", "")),
790 downloaded_bytes=_integer(payload.get("downloaded_bytes", 0)),
791 total_bytes=_optional_integer(payload.get("total_bytes")),
792 error=_optional_string(payload.get("error")),
793 created_at=_number(payload.get("created_at", time.time())),
794 completed_path=(
795 Path(path) if (path := _optional_string(payload.get("completed_path"))) else None
796 ),
797 bundled_bios_count=_integer(payload.get("bundled_bios_count", 0)),
798 torrent_candidates=tuple(_choice_from_json(item) for item in candidates_payload),
799 retry_attempt=_integer(payload.get("retry_attempt", 0)),
800 retry_at=(_number(payload["retry_at"]) if payload.get("retry_at") is not None else None),
801 )
804def _rate_limit_retry_delay(
805 attempt: int,
806 retry_after_seconds: float | None,
807 settings: RateLimitRetrySettings | None = None,
808) -> float:
809 """Return a capped exponential delay with jitter for an HTTP 429 response."""
811 effective_settings = settings or RateLimitRetrySettings()
812 if retry_after_seconds is not None:
813 base = max(retry_after_seconds, 1.0)
814 return base * random.uniform(1.0, 1.0 + effective_settings.jitter_ratio)
815 else:
816 exponent = min(max(attempt - 1, 0), 30)
817 base = min(
818 effective_settings.base_seconds * (2**exponent),
819 effective_settings.max_seconds,
820 )
821 return base * random.uniform(
822 1.0 - effective_settings.jitter_ratio,
823 1.0 + effective_settings.jitter_ratio,
824 )
827def _media_from_json(value: object) -> MediaDownload:
828 payload = _dictionary(value)
829 raw_path = payload.get("torrent_file_path")
830 return MediaDownload(
831 _string(payload["url"]),
832 _optional_integer(payload.get("torrent_file_index")),
833 _optional_string(payload.get("expected_filename")),
834 tuple(_string(item) for item in _list(raw_path)) if raw_path is not None else None,
835 )
838def _choice_from_json(value: object) -> TorrentFileChoice:
839 payload = _dictionary(value)
840 return TorrentFileChoice(
841 _integer(payload["index"]),
842 tuple(_string(item) for item in _list(payload["path"])),
843 _integer(payload["length"]),
844 _number(payload["match_score"]),
845 )
848def _installed_game_to_json(game: InstalledGame | None) -> dict[str, object] | None:
849 if game is None:
850 return None
851 return {
852 "title": game.title,
853 "roms_directory": str(game.roms_directory),
854 "primary_file": str(game.primary_file),
855 "files": [str(path) for path in game.files],
856 }
859def _installed_game_from_json(value: object, platform: Platform) -> InstalledGame | None:
860 if value is None: 860 ↛ 862line 860 didn't jump to line 862 because the condition on line 860 was always true
861 return None
862 payload = _dictionary(value)
863 return InstalledGame(
864 _string(payload["title"]),
865 platform,
866 Path(_string(payload["roms_directory"])),
867 Path(_string(payload["primary_file"])),
868 tuple(Path(_string(item)) for item in _list(payload["files"])),
869 )
872def _dictionary(value: object) -> dict[str, Any]:
873 if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 raise TypeError
875 return cast("dict[str, Any]", value)
878def _list(value: object) -> list[Any]:
879 if not isinstance(value, list): 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 raise TypeError
881 return value
884def _string(value: object) -> str:
885 if not isinstance(value, str): 885 ↛ 886line 885 didn't jump to line 886 because the condition on line 885 was never true
886 raise TypeError
887 return value
890def _optional_string(value: object) -> str | None:
891 if value is None:
892 return None
893 return _string(value)
896def _integer(value: object) -> int:
897 if not isinstance(value, int) or isinstance(value, bool): 897 ↛ 898line 897 didn't jump to line 898 because the condition on line 897 was never true
898 raise TypeError
899 return value
902def _optional_integer(value: object) -> int | None:
903 if value is None:
904 return None
905 return _integer(value)
908def _number(value: object) -> float:
909 if not isinstance(value, int | float) or isinstance(value, bool): 909 ↛ 910line 909 didn't jump to line 910 because the condition on line 909 was never true
910 raise TypeError
911 return float(value)