Files
dickshow-index/download.py
T

135 lines
4.3 KiB
Python
Raw Normal View History

"""Resumable MP3 downloader.
Downloads each episode's enclosure into audio/<epnum or guid>.mp3.
Skips files that already exist with the expected size (HTTP Range resume).
"""
import json
import os
import re
import sys
import time
import requests
from config import AUDIO_DIR, DOWNLOAD_TIMEOUT, CHUNK_SIZE
from feed import load_episodes
def slug(ep: dict) -> str:
if ep.get("ep_num"):
return f"ep{ep['ep_num']:04d}"
g = re.sub(r"[^a-zA-Z0-9-]", "", ep["guid"])[:24]
return f"bonus-{g}"
def audio_path(ep: dict) -> str:
return os.path.join(AUDIO_DIR, slug(ep) + ".mp3")
def expected_size(ep: dict) -> int:
return ep.get("audio_bytes") or 0
def download_episode(ep: dict, force: bool = False) -> str:
"""Download one episode. Returns local path. Raises on hard failure."""
url = ep["audio_url"]
if not url:
raise ValueError(f"no audio_url for {ep['guid']}")
dest = audio_path(ep)
tmp = dest + ".part"
want = expected_size(ep)
if os.path.exists(dest):
if force:
os.remove(dest)
else:
have = os.path.getsize(dest)
if want and have != want:
print(f" size mismatch for {dest} (have {have}, want {want}); "
f"redownloading", file=sys.stderr)
os.remove(dest)
else:
return dest
headers = {"User-Agent": "dickshow-indexer/1.0"}
mode = "wb"
if os.path.exists(tmp):
have = os.path.getsize(tmp)
if want and have == want:
os.replace(tmp, dest)
return dest
if have > 0:
headers["Range"] = f"bytes={have}-"
mode = "ab"
last_err = None
for attempt in range(1, 6):
try:
with requests.get(url, headers=headers, stream=True,
timeout=DOWNLOAD_TIMEOUT, allow_redirects=True) as r:
if r.status_code == 416: # range beyond EOF -> already complete
os.replace(tmp, dest)
return dest
r.raise_for_status()
total = int(r.headers.get("Content-Length", "0") or 0)
if mode == "ab" and r.status_code == 200:
# server ignored Range; restart cleanly
mode = "wb"
with open(tmp, mode) as f:
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
if chunk:
f.write(chunk)
have = os.path.getsize(tmp)
if want and have < want:
raise IOError(f"incomplete download {have}/{want}")
break
except Exception as e: # noqa: BLE001
last_err = e
print(f" retry {attempt}: {e}", file=sys.stderr)
time.sleep(5 * attempt)
mode = "ab"
headers["Range"] = f"bytes={os.path.getsize(tmp)}-" \
if os.path.exists(tmp) and os.path.getsize(tmp) > 0 else {}
else:
raise IOError(f"download failed for {url}: {last_err}")
os.replace(tmp, dest)
return dest
def main() -> None:
from config import TRANSCRIPT_DIR
from transcribe import transcript_path
eps = load_episodes()
only = set()
for a in sys.argv[1:]:
only.add(int(a))
ok = fail = skip = 0
for ep in eps:
if ep["ep_num"] is not None and only and ep["ep_num"] not in only:
continue
if os.path.exists(transcript_path(ep)):
skip += 1 # already fully transcribed; audio not needed
continue
dest = audio_path(ep)
if os.path.exists(dest) and not (expected_size(ep) and
os.path.getsize(dest) != expected_size(ep)):
skip += 1
continue
label = f"Ep {ep['ep_num']}" if ep["ep_num"] else ep["title"][:40]
try:
p = download_episode(ep)
mb = os.path.getsize(p) / 1e6
print(f"{label}: {mb:.1f} MB")
ok += 1
except Exception as e: # noqa: BLE001
print(f"{label}: FAILED - {e}", file=sys.stderr)
fail += 1
with open(os.path.join(AUDIO_DIR, ".progress.json"), "w") as f:
json.dump({"last_done": ep["guid"]}, f)
print(f"downloaded={ok} skipped={skip} failed={fail}")
if __name__ == "__main__":
main()