319 lines
11 KiB
Python
319 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import platform
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
from iqpilot.common.params import Params
|
|
from iqpilot.common.swaglog import cloudlog
|
|
from iqpilot.ui.onroad.offline_tiles import offline_map_root
|
|
|
|
try:
|
|
from iqpilot.iq_maps.tiles_auth import get_base_urls as _private_base_urls, get_requests_auth as _private_auth
|
|
except Exception: # ProprietaryModuleMissing or import errors in stripped builds
|
|
_private_base_urls = None
|
|
_private_auth = None
|
|
|
|
DEFAULT_TILE_BUNDLE_BASE_URL = "https://maps.konn3kt.com/iqosmd/v1"
|
|
BASE_URL_PARAM = "OfflineTilesBaseUrl"
|
|
PROGRESS_PARAM = "OfflineTilesDownloadProgress"
|
|
REQUEST_PARAM = "OfflineTilesDownloadRequest"
|
|
CHUNK_BYTES = 1 << 20
|
|
HTTP_TIMEOUT_S = 30.0
|
|
STREAM_RETRIES = 8
|
|
|
|
|
|
def candidate_base_urls(params: Params) -> list[str]:
|
|
override = params.get(BASE_URL_PARAM)
|
|
if isinstance(override, bytes):
|
|
override = override.decode("utf-8", errors="ignore")
|
|
override = (override or "").strip()
|
|
if override:
|
|
return [override.rstrip("/")]
|
|
urls: list[str] = []
|
|
if _private_base_urls is not None:
|
|
try:
|
|
urls.extend(url.rstrip("/") for url in _private_base_urls())
|
|
except Exception:
|
|
pass
|
|
urls.append(DEFAULT_TILE_BUNDLE_BASE_URL)
|
|
return urls
|
|
|
|
|
|
def request_auth() -> tuple[str, str] | None:
|
|
if _private_auth is None:
|
|
return None
|
|
try:
|
|
return _private_auth()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def fetch_index(base_url: str, session: requests.Session) -> dict:
|
|
response = session.get(f"{base_url}/index.json", timeout=HTTP_TIMEOUT_S, auth=request_auth())
|
|
response.raise_for_status()
|
|
index = response.json()
|
|
regions = index.get("regions")
|
|
if not isinstance(regions, dict):
|
|
raise ValueError("tile bundle index has no regions")
|
|
return regions
|
|
|
|
|
|
def region_bundle_dir(selector: str) -> Path:
|
|
return offline_map_root() / "regions" / selector
|
|
|
|
|
|
def region_bundle_path(selector: str) -> Path:
|
|
return region_bundle_dir(selector) / "tiles" / "offline.mbtiles"
|
|
|
|
|
|
def region_bundle_installed(selector: str) -> bool:
|
|
return region_bundle_path(selector).exists()
|
|
|
|
|
|
def installed_region_selectors() -> list[str]:
|
|
regions_root = offline_map_root() / "regions"
|
|
if not regions_root.exists():
|
|
return []
|
|
return sorted(
|
|
child.name for child in regions_root.iterdir()
|
|
if child.is_dir() and (child / "tiles" / "offline.mbtiles").exists()
|
|
)
|
|
|
|
|
|
def _hash_existing(path: Path) -> tuple["hashlib._Hash", int]:
|
|
digest = hashlib.sha256()
|
|
size = 0
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
size += len(chunk)
|
|
return digest, size
|
|
|
|
|
|
def _write_manifest(selector: str, entry: dict) -> None:
|
|
manifest = {
|
|
"region": selector,
|
|
"version": entry.get("version", ""),
|
|
"mbtiles": {
|
|
"bounds": entry.get("bounds", ""),
|
|
"minzoom": entry.get("minzoom"),
|
|
"maxzoom": entry.get("maxzoom"),
|
|
"bytes": entry.get("bytes"),
|
|
"sha256": entry.get("sha256", ""),
|
|
},
|
|
}
|
|
if entry.get("day_path"):
|
|
manifest["mbtiles_day"] = {
|
|
"bytes": entry.get("day_bytes"),
|
|
"sha256": entry.get("day_sha256", ""),
|
|
}
|
|
manifest_path = region_bundle_dir(selector) / "manifest.json"
|
|
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
manifest_path.write_text(json.dumps(manifest, indent=2))
|
|
|
|
|
|
class TileBundleDownloader:
|
|
|
|
def __init__(self, params: Params | None = None, mem_params: Params | None = None,
|
|
abort_check=None):
|
|
self.params = params if params is not None else Params()
|
|
if mem_params is not None:
|
|
self.mem_params = mem_params
|
|
else:
|
|
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
|
|
self.session = requests.Session()
|
|
self._cancelled = threading.Event()
|
|
self._abort_check = abort_check
|
|
|
|
def cancel(self) -> None:
|
|
self._cancelled.set()
|
|
|
|
def _should_abort(self) -> bool:
|
|
if self._cancelled.is_set():
|
|
return True
|
|
if not self.mem_params.get(REQUEST_PARAM):
|
|
self._cancelled.set()
|
|
return True
|
|
if self._abort_check is not None and self._abort_check():
|
|
self._cancelled.set()
|
|
return True
|
|
return False
|
|
|
|
def _publish_progress(self, region: str, downloaded: int, total: int, active: bool) -> None:
|
|
self.mem_params.put(PROGRESS_PARAM, {
|
|
"active": active,
|
|
"region": region,
|
|
"downloaded_bytes": int(downloaded),
|
|
"total_bytes": int(total),
|
|
})
|
|
|
|
def _download_one(self, selector: str, entry: dict, base_url: str,
|
|
progress_offset: int, progress_total: int) -> bool:
|
|
night_path = region_bundle_path(selector)
|
|
ok = self._download_file(
|
|
selector, base_url, entry["path"], int(entry.get("bytes", 0)),
|
|
str(entry.get("sha256", "")).strip().lower(), night_path,
|
|
progress_offset, progress_total,
|
|
)
|
|
if not ok:
|
|
return False
|
|
if entry.get("day_path"):
|
|
day_ok = self._download_file(
|
|
selector, base_url, entry["day_path"], int(entry.get("day_bytes", 0)),
|
|
str(entry.get("day_sha256", "")).strip().lower(),
|
|
night_path.with_name("offline_day.mbtiles"),
|
|
progress_offset + int(entry.get("bytes", 0)), progress_total,
|
|
)
|
|
if not day_ok:
|
|
cloudlog.warning(f"iq_maps: day-style bundle failed for {selector}; night set installed")
|
|
_write_manifest(selector, entry)
|
|
cloudlog.info(f"iq_maps: installed tile bundle {selector}")
|
|
return True
|
|
|
|
def _download_file(self, selector: str, base_url: str, remote_path: str, expected_bytes: int,
|
|
expected_sha: str, final_path: Path,
|
|
progress_offset: int, progress_total: int) -> bool:
|
|
url = f"{base_url}/{remote_path.lstrip('/')}"
|
|
part_path = final_path.with_name(final_path.name + ".part")
|
|
part_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
downloaded = 0
|
|
digest = hashlib.sha256()
|
|
last_error: Exception | None = None
|
|
for attempt in range(STREAM_RETRIES):
|
|
if self._should_abort():
|
|
cloudlog.warning(f"iq_maps: tile bundle download cancelled for {selector}")
|
|
return False
|
|
if attempt:
|
|
time.sleep(min(30.0, 2.0 * attempt))
|
|
try:
|
|
digest = hashlib.sha256()
|
|
resume_from = 0
|
|
if part_path.exists():
|
|
digest, resume_from = _hash_existing(part_path)
|
|
if expected_bytes and resume_from > expected_bytes:
|
|
part_path.unlink()
|
|
digest = hashlib.sha256()
|
|
resume_from = 0
|
|
|
|
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
|
|
auth = request_auth()
|
|
response = self.session.get(url, headers=headers, stream=True, timeout=HTTP_TIMEOUT_S, auth=auth)
|
|
if resume_from and response.status_code != 206:
|
|
digest = hashlib.sha256()
|
|
resume_from = 0
|
|
part_path.unlink(missing_ok=True)
|
|
if response.status_code == 416:
|
|
response = self.session.get(url, stream=True, timeout=HTTP_TIMEOUT_S, auth=auth)
|
|
response.raise_for_status()
|
|
|
|
downloaded = resume_from
|
|
mode = "ab" if resume_from else "wb"
|
|
with open(part_path, mode) as f:
|
|
for chunk in response.iter_content(chunk_size=CHUNK_BYTES):
|
|
if self._should_abort():
|
|
cloudlog.warning(f"iq_maps: tile bundle download cancelled for {selector}")
|
|
return False
|
|
f.write(chunk)
|
|
digest.update(chunk)
|
|
downloaded += len(chunk)
|
|
self._publish_progress(selector, progress_offset + downloaded, progress_total, active=True)
|
|
break
|
|
except requests.RequestException as exc:
|
|
last_error = exc
|
|
cloudlog.warning(f"iq_maps: tile bundle stream interrupted for {selector} "
|
|
+ f"(attempt {attempt + 1}/{STREAM_RETRIES}): {exc}")
|
|
else:
|
|
raise requests.RequestException(f"stream failed after {STREAM_RETRIES} attempts") from last_error
|
|
|
|
if expected_bytes and downloaded != expected_bytes:
|
|
cloudlog.error(f"iq_maps: tile bundle size mismatch for {selector}: {downloaded} != {expected_bytes}")
|
|
part_path.unlink(missing_ok=True)
|
|
return False
|
|
if expected_sha and digest.hexdigest() != expected_sha:
|
|
cloudlog.error(f"iq_maps: tile bundle sha256 mismatch for {selector}")
|
|
part_path.unlink(missing_ok=True)
|
|
return False
|
|
|
|
part_path.replace(final_path)
|
|
return True
|
|
|
|
def download_regions(self, selectors: list[str]) -> bool:
|
|
self._cancelled.clear()
|
|
ok = True
|
|
try:
|
|
self.mem_params.put(REQUEST_PARAM, {"regions": list(selectors)})
|
|
regions = None
|
|
base_url = ""
|
|
for candidate in candidate_base_urls(self.params):
|
|
try:
|
|
regions = fetch_index(candidate, self.session)
|
|
base_url = candidate
|
|
break
|
|
except (requests.RequestException, ValueError, json.JSONDecodeError):
|
|
cloudlog.warning(f"iq_maps: tile bundle index unavailable at {candidate}")
|
|
if regions is None:
|
|
cloudlog.error("iq_maps: no tile bundle host reachable")
|
|
return False
|
|
|
|
wanted: list[tuple[str, dict]] = []
|
|
for selector in selectors:
|
|
entry = regions.get(selector)
|
|
if entry is None:
|
|
cloudlog.warning(f"iq_maps: no tile bundle published for {selector}")
|
|
ok = False
|
|
continue
|
|
if region_bundle_installed(selector) and self._installed_matches(selector, entry):
|
|
continue
|
|
wanted.append((selector, entry))
|
|
|
|
progress_total = sum(int(entry.get("bytes", 0)) + int(entry.get("day_bytes", 0)) for _, entry in wanted)
|
|
progress_offset = 0
|
|
for selector, entry in wanted:
|
|
if self._should_abort():
|
|
return False
|
|
try:
|
|
if not self._download_one(selector, entry, base_url, progress_offset, progress_total):
|
|
ok = False
|
|
except (requests.RequestException, OSError):
|
|
cloudlog.exception(f"iq_maps: tile bundle download failed for {selector}")
|
|
ok = False
|
|
progress_offset += int(entry.get("bytes", 0)) + int(entry.get("day_bytes", 0))
|
|
return ok
|
|
finally:
|
|
self._publish_progress("", 0, 0, active=False)
|
|
try:
|
|
self.mem_params.remove(REQUEST_PARAM)
|
|
except Exception:
|
|
pass
|
|
|
|
@staticmethod
|
|
def _installed_matches(selector: str, entry: dict) -> bool:
|
|
manifest_path = region_bundle_dir(selector) / "manifest.json"
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text())
|
|
except (OSError, json.JSONDecodeError):
|
|
return False
|
|
installed_sha = str(manifest.get("mbtiles", {}).get("sha256", "")).strip().lower()
|
|
expected_sha = str(entry.get("sha256", "")).strip().lower()
|
|
if not expected_sha or installed_sha != expected_sha:
|
|
return False
|
|
if entry.get("day_path"):
|
|
day_file = region_bundle_dir(selector) / "tiles" / "offline_day.mbtiles"
|
|
installed_day = str(manifest.get("mbtiles_day", {}).get("sha256", "")).strip().lower()
|
|
expected_day = str(entry.get("day_sha256", "")).strip().lower()
|
|
if not day_file.exists() or installed_day != expected_day:
|
|
return False
|
|
return True
|