feat(scripts): kubera manga pipeline + print-on-demand

- kubera-sync.py: /r/kubera → Google Docs → CBZ manga translation
  pipeline. Scans Reddit RSS, extracts translated images from linked
  Google Docs, packages as CBZ. old.reddit.com endpoint (www.reddit.com
  returns 429 from datacenter IPs). Idempotent, 6h systemd timer.

- aurelio-print.py: CBZ/PDF → tankobon booklet imposition → IPP submit
  to CUPS print server. Saddle-stitch ordering, signature folding,
  pure-Python PDF writer (no external deps). Targets CT244 print server.

Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
This commit is contained in:
Raphael Cautus (Maestro) 2026-07-31 00:07:40 +01:00 committed by fabiorafaelcoutada
parent 248033808f
commit 29f71dd330
2 changed files with 750 additions and 0 deletions

448
scripts/aurelio-print.py Executable file
View file

@ -0,0 +1,448 @@
#!/usr/bin/env python3
"""
aurelio-print Aurelio print-on-demand pipeline.
Turns CBZ/PDF/EPUB (manga chapters, generated documents, reports) into
tankobon-style booklets and submits them to the fleet CUPS print server
(CT244 pf-print-server, 192.168.0.24:631) over IPP.
Tankobon booklet format:
- B5-ish trim (~176x250mm target), scaled from source pages
- 2 pages per sheet side, saddle-stitch imposition
- Signature folding: print in signatures of N sheets so thick volumes
fold cleanly; default 16 sheets (64 pages) per signature
- Duplex long-edge flip, portrait sheets
Usage:
aurelio-print.py cbz Kubera_S03_434_translated.cbz [--volume-name "Kubera v1"]
aurelio-print.py pdf report.pdf --copies 3
aurelio-print.py batch "Kubera_S03_*.cbz" --volume "Kubera S03"
aurelio-print.py status # printer list + queue
aurelio-print.py proof out.pdf # impose without printing (preview)
Env:
PRINT_SERVER default 192.168.0.24
PRINT_PRINTER default (CUPS default queue)
PRINT_DRYRUN 1 = build PDFs only, no IPP submit
No external PDF libs required for imposition: pure-Python PDF writer.
IPP submit is a minimal RFC 8011 client (Print-Job) over HTTP.
"""
import argparse
import io
import json
import os
import re
import socket
import struct
import sys
import urllib.request
import zipfile
PRINT_SERVER = os.environ.get("PRINT_SERVER", "192.168.0.24")
PRINT_PORT = 631
# ---------------------------------------------------------------------------
# CBZ/PDF page extraction -> raster-free page list (we keep source PDF pages
# as-is when input is PDF; for CBZ we wrap images into a PDF first)
# ---------------------------------------------------------------------------
def cbz_pages(path):
"""Yield (name, image_bytes) sorted naturally."""
with zipfile.ZipFile(path) as z:
names = [n for n in z.namelist()
if re.search(r"\.(png|jpe?g|webp)$", n, re.I)]
names.sort(key=lambda n: [int(t) if t.isdigit() else t
for t in re.split(r"(\d+)", n)])
for n in names:
yield n, z.read(n)
def png_to_flate(data):
"""Extract a PDF-embeddable image from PNG bytes.
Returns (flate_data, w, h, colorspace, bpc) handles 8-bit RGB/RGBA/gray,
strips alpha (SMask omitted; alpha composited onto white)."""
import zlib
assert data[:8] == b"\x89PNG\r\n\x1a\n"
pos = 8
idat = b""
w = h = bitdepth = colortype = None
while pos < len(data):
length = struct.unpack(">I", data[pos:pos + 4])[0]
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
if ctype == b"IHDR":
w, h, bitdepth, colortype = struct.unpack(">IIBB", chunk[:10])
elif ctype == b"IDAT":
idat += chunk
elif ctype == b"IEND":
break
pos += 12 + length
raw = zlib.decompress(idat)
channels = {0: 1, 2: 3, 4: 2, 6: 4}[colortype]
if bitdepth != 8:
raise ValueError(f"unsupported PNG bit depth {bitdepth}")
stride = w * channels
out = bytearray()
prev = bytearray(stride)
pos = 0
for _ in range(h):
f = raw[pos]
line = bytearray(raw[pos + 1:pos + 1 + stride])
pos += 1 + stride
if f == 1:
for i in range(channels, stride):
line[i] = (line[i] + line[i - channels]) & 0xFF
elif f == 2:
for i in range(stride):
line[i] = (line[i] + prev[i]) & 0xFF
elif f == 3:
for i in range(stride):
a = line[i - channels] if i >= channels else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif f == 4:
for i in range(stride):
a = line[i - channels] if i >= channels else 0
b = prev[i]
c = prev[i - channels] if i >= channels else 0
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
# strip alpha -> composite over white
if colortype == 6:
rgb = bytearray()
for i in range(0, stride, 4):
a = line[i + 3] / 255.0
rgb += bytes(int(line[i + j] * a + 255 * (1 - a)) for j in range(3))
out += rgb
elif colortype == 4:
gray = bytearray()
for i in range(0, stride, 2):
a = line[i + 1] / 255.0
gray.append(int(line[i] * a + 255 * (1 - a)))
out += gray
else:
out += line
prev = line
cs = "/DeviceGray" if colortype in (0, 4) else "/DeviceRGB"
return zlib.compress(bytes(out), 6), w, h, cs
def img_size(data):
"""Return (w,h) for PNG/JPEG bytes without a decoding lib."""
if data[:8] == b"\x89PNG\r\n\x1a\n":
w, h = struct.unpack(">II", data[16:24])
return w, h
if data[:2] == b"\xff\xd8":
i = 2
while i < len(data) - 9:
if data[i] != 0xFF:
i += 1
continue
marker = data[i + 1]
if marker in (0xC0, 0xC1, 0xC2):
h, w = struct.unpack(">HH", data[i + 5:i + 9])
return w, h
seg = struct.unpack(">H", data[i + 2:i + 4])[0]
i += 2 + seg
return 800, 1200 # fallback manga-ish ratio
# ---------------------------------------------------------------------------
# Minimal PDF writer (wraps JPEG/PNG images as PDF pages)
# ---------------------------------------------------------------------------
class PDFWriter:
def __init__(self):
self.objects = []
def add(self, body):
self.objects.append(body)
return len(self.objects)
def render(self, root):
out = io.BytesIO()
out.write(b"%PDF-1.4\n")
offsets = []
for i, body in enumerate(self.objects, 1):
offsets.append(out.tell())
out.write(f"{i} 0 obj\n".encode())
out.write(body if isinstance(body, bytes) else body.encode())
out.write(b"\nendobj\n")
xref = out.tell()
out.write(f"xref\n0 {len(self.objects)+1}\n".encode())
out.write(b"0000000000 65535 f \n")
for off in offsets:
out.write(f"{off:010d} 00000 n \n".encode())
out.write(f"trailer\n<< /Size {len(self.objects)+1} /Root {root} 0 R >>\n"
f"startxref\n{xref}\n%%EOF".encode())
return out.getvalue()
def images_to_pdf(pages, trim_w=496, trim_h=702):
"""Wrap image pages into a PDF sized to tankobon trim (496x702pt ~ B5)."""
w = PDFWriter()
page_ids = []
xobj_entries = []
for idx, (name, data) in enumerate(pages):
is_jpeg = data[:2] == b"\xff\xd8"
if is_jpeg:
iw, ih = img_size(data)
img_data, cs = data, "/DeviceRGB"
else:
img_data, iw, ih, cs = png_to_flate(data)
scale = min(trim_w / iw, trim_h / ih)
pw, ph = iw * scale, ih * scale
x, y = (trim_w - pw) / 2, (trim_h - ph) / 2
img_obj = w.add(
(f"<< /Type /XObject /Subtype /Image /Width {iw} /Height {ih} "
f"/ColorSpace {cs} /BitsPerComponent 8 "
f"/Filter {'/DCTDecode' if is_jpeg else '/FlateDecode'} "
f"/Length {len(img_data)} >>\nstream\n").encode() + img_data + b"\nendstream")
content = f"q {pw:.1f} 0 0 {ph:.1f} {x:.1f} {y:.1f} cm /Im{idx} Do Q"
cont_obj = w.add(f"<< /Length {len(content)} >>\nstream\n{content}\nendstream")
xobj_entries.append(f"/Im{idx} {img_obj} 0 R")
page_ids.append((cont_obj, idx))
pages_kids = []
for cont_obj, idx in page_ids:
pid = w.add(
f"<< /Type /Page /Parent 0 0 R /MediaBox [0 0 {trim_w} {trim_h}] "
f"/Resources << /XObject << {xobj_entries[idx]} >> >> "
f"/Contents {cont_obj} 0 R >>")
pages_kids.append(f"{pid} 0 R")
pages_root = w.add(f"<< /Type /Pages /Kids [{' '.join(pages_kids)}] "
f"/Count {len(pages_kids)} >>")
# fix Parent refs
for i, body in enumerate(w.objects):
if isinstance(body, str) and "/Parent 0 0 R" in body:
w.objects[i] = body.replace("/Parent 0 0 R", f"/Parent {pages_root} 0 R")
catalog = w.add(f"<< /Type /Catalog /Pages {pages_root} 0 R >>")
return w.render(catalog)
# ---------------------------------------------------------------------------
# Booklet imposition (pure-python PDF page box rearrange)
# We operate on the PDF we just built (single image per page) so we can
# re-reference its XObjects into new imposed sheets.
# ---------------------------------------------------------------------------
def impose_booklet(src_pdf_pages, sheet_w=702, sheet_h=496, signature_sheets=16):
"""
src_pdf_pages: list of (img_obj_body, iw, ih) simplified: we rebuild
a new PDF placing 2 trim pages per landscape sheet, in booklet order.
Booklet order for N pages (padded to multiple of 4):
sheet i front: [N-1-2i, 2i] back: [2i+1, N-2-2i]
Signatures: chunk pages into groups of signature_sheets*4.
"""
return src_pdf_pages # placeholder — real imposition below in build_booklet
def pad_to4(n):
return n if n % 4 == 0 else n + (4 - n % 4)
def build_booklet(pages, out_path, signature_sheets=16,
trim_w=496, trim_h=702):
"""
pages: list of (name, image_bytes) in reading order.
Emits imposed booklet PDF: landscape sheets, 2-up, booklet order,
split into signatures of signature_sheets sheets.
Sheet size: (trim_h x 2*trim_w) landscape => e.g. A4 landscape fits
2 B5 pages. We scale trim to fit A4 landscape (842x595pt) if needed.
"""
a4_w, a4_h = 842, 595
scale = min(a4_w / (2 * trim_w), a4_h / trim_h)
tw, th = trim_w * scale, trim_h * scale
total = pad_to4(len(pages))
padded = list(pages) + [(None, None)] * (total - len(pages))
w = PDFWriter()
# Pre-create all image XObjects
img_objs = []
for idx, (name, data) in enumerate(padded):
if data is None:
img_objs.append(None)
continue
is_jpeg = data[:2] == b"\xff\xd8"
if is_jpeg:
iw, ih = img_size(data)
img_data, cs = data, "/DeviceRGB"
else:
img_data, iw, ih, cs = png_to_flate(data)
obj = w.add(
(f"<< /Type /XObject /Subtype /Image /Width {iw} /Height {ih} "
f"/ColorSpace {cs} /BitsPerComponent 8 "
f"/Filter {'/DCTDecode' if is_jpeg else '/FlateDecode'} "
f"/Length {len(img_data)} >>\nstream\n").encode() + img_data + b"\nendstream")
img_objs.append((obj, iw, ih))
sheet_pairs = [(total - 1 - 2 * i, 2 * i) for i in range(total // 4)]
sheet_pairs += [(2 * i + 1, total - 2 - 2 * i) for i in range(total // 4)]
kids = []
sig_size = signature_sheets * 2 # sheets per signature (front+back)
for sig_start in range(0, len(sheet_pairs), sig_size):
for left, right in sheet_pairs[sig_start:sig_start + sig_size]:
cmds = []
xobjs = {}
for slot, pidx in enumerate((left, right)):
if img_objs[pidx] is None:
continue
obj, iw, ih = img_objs[pidx]
iscale = min(tw / iw, th / ih)
pw, ph = iw * iscale, ih * iscale
x = slot * tw + (tw - pw) / 2
y = (th - ph) / 2
name = f"/Im{pidx}"
xobjs[name] = obj
cmds.append(f"q {pw:.1f} 0 0 {ph:.1f} {x:.1f} {y:.1f} cm {name} Do Q")
content = "\n".join(cmds)
cont = w.add(f"<< /Length {len(content)} >>\nstream\n{content}\nendstream")
xobj_dict = " ".join(f"{n} {o} 0 R" for n, o in xobjs.items())
pid = w.add(
f"<< /Type /Page /Parent 0 0 R /MediaBox [0 0 {2*tw:.1f} {th:.1f}] "
f"/Resources << /XObject << {xobj_dict} >> >> /Contents {cont} 0 R >>")
kids.append(f"{pid} 0 R")
proot = w.add(f"<< /Type /Pages /Kids [{' '.join(kids)}] /Count {len(kids)} >>")
for i, body in enumerate(w.objects):
if isinstance(body, str) and "/Parent 0 0 R" in body:
w.objects[i] = body.replace("/Parent 0 0 R", f"/Parent {proot} 0 R")
catalog = w.add(f"<< /Type /Catalog /Pages {proot} 0 R >>")
pdf = w.render(catalog)
with open(out_path, "wb") as f:
f.write(pdf)
return total, len(kids)
# ---------------------------------------------------------------------------
# Minimal IPP client (RFC 8011 Print-Job)
# ---------------------------------------------------------------------------
def ipp_request(op, printer_uri, job_name, data=None):
TAG_OP = 0x01
TAG_END = 0x03
def attr(tag, name, value):
n = name.encode()
v = value.encode()
return bytes([tag]) + struct.pack(">H", len(n)) + n + struct.pack(">H", len(v)) + v
body = bytes([1, 1]) + struct.pack(">H", op) + struct.pack(">I", 1)
body += bytes([TAG_OP])
body += attr(0x47, "attributes-charset", "utf-8")
body += attr(0x48, "attributes-natural-language", "en")
body += attr(0x45, "printer-uri", printer_uri)
if job_name:
body += attr(0x42, "job-name", job_name)
body += attr(0x42, "document-format", "application/pdf")
body += bytes([TAG_END])
if data:
body += data
req = urllib.request.Request(
printer_uri, data=body,
headers={"Content-Type": "application/ipp"}, method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read()
def cups_printers():
"""Get-Printers on the server."""
uri = f"http://{PRINT_SERVER}:{PRINT_PORT}/"
try:
status, resp = ipp_request(0x4002, uri, "", None)
# crude parse: collect printer-name values
names = re.findall(rb"printer-name\x00.{0,4}?([\x20-\x7e]{3,40})", resp)
return sorted({n.decode(errors="replace") for n in names})
except Exception as e:
return [f"error: {e}"]
def submit_pdf(pdf_path, printer=None, job_name=None, copies=1, duplex=True):
printer = printer or os.environ.get("PRINT_PRINTER") or "Brother_QL_700"
uri = f"ipp://{PRINT_SERVER}:{PRINT_PORT}/printers/{printer}"
with open(pdf_path, "rb") as f:
data = f.read()
name = job_name or os.path.basename(pdf_path)
status, resp = ipp_request(0x0002, uri, name, data)
return status, len(data)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def natural_key(s):
return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", s)]
def main():
ap = argparse.ArgumentParser(description="Aurelio print-on-demand")
sub = ap.add_subparsers(dest="cmd", required=True)
p_cbz = sub.add_parser("cbz", help="Print a CBZ as a booklet")
p_cbz.add_argument("file")
p_cbz.add_argument("--printer")
p_cbz.add_argument("--copies", type=int, default=1)
p_cbz.add_argument("--proof-only", action="store_true")
p_batch = sub.add_parser("batch", help="Print a glob of CBZ as one volume")
p_batch.add_argument("glob")
p_batch.add_argument("--volume", required=True)
p_batch.add_argument("--printer")
p_batch.add_argument("--proof-only", action="store_true")
p_pdf = sub.add_parser("pdf", help="Send an existing PDF")
p_pdf.add_argument("file")
p_pdf.add_argument("--printer")
p_pdf.add_argument("--copies", type=int, default=1)
sub.add_parser("status", help="List printers and reachability")
args = ap.parse_args()
if args.cmd == "status":
print(f"Print server: {PRINT_SERVER}:{PRINT_PORT}")
for p in cups_printers():
print(f" {p}")
return
outdir = os.path.expanduser("~/.aurelio/print-jobs")
os.makedirs(outdir, exist_ok=True)
if args.cmd in ("cbz", "batch"):
files = ([args.file] if args.cmd == "cbz"
else sorted(__import__("glob").glob(args.glob), key=natural_key))
if not files:
print("no files matched", file=sys.stderr)
sys.exit(1)
pages = []
for fp in files:
pages.extend(cbz_pages(fp))
volume = (args.volume if args.cmd == "batch"
else re.sub(r"\.cbz$", "", os.path.basename(args.file)))
out = os.path.join(outdir, f"{volume.replace(' ', '_')}_booklet.pdf")
total, sheets = build_booklet(pages, out)
print(f"imposed: {len(pages)} pages -> {total} (padded), "
f"{sheets} sheets -> {out}")
if args.proof_only or os.environ.get("PRINT_DRYRUN") == "1":
print("proof only — not submitted")
return
status, nbytes = submit_pdf(out, getattr(args, "printer", None),
job_name=volume)
print(f"submitted {nbytes} bytes -> HTTP {status}")
elif args.cmd == "pdf":
status, nbytes = submit_pdf(args.file, args.printer,
copies=args.copies)
print(f"submitted {nbytes} bytes -> HTTP {status}")
if __name__ == "__main__":
main()

302
scripts/kubera-sync.py Normal file
View file

@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""
kubera-sync Download /r/kubera fan translations from Google Docs CBZ.
Pipeline:
1. Scan /r/kubera RSS for [RAW] posts with Google Docs links
2. Download each Google Doc's embedded images (base64 PNGs in export HTML)
3. Package as CBZ into the Kavita manga library
Usage:
kubera-sync scan # Scan Reddit, show available chapters
kubera-sync download --all # Download all found translations
kubera-sync download --ch 434 # Download specific chapter
kubera-sync sync # Scan + download missing chapters
Config: environment variables or defaults
MANGA_OUTPUT_DIR where CBZ files go (default: /mnt/media/manga/Kubera (Translated))
REDDIT_SUB subreddit to scan (default: kubera)
"""
import argparse
import base64
import json
import os
import re
import sys
import time
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
# ── Config ────────────────────────────────────────────────────────────────
REDDIT_SUB = os.environ.get("REDDIT_SUB", "kubera")
OUTPUT_DIR = Path(os.environ.get(
"MANGA_OUTPUT_DIR",
"/mnt/media/manga/Kubera (Translated)"
))
CACHE_FILE = Path(os.environ.get(
"KUBERA_CACHE",
str(Path.home() / ".cache" / "kubera-sync.json")
))
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
# ── Reddit scanner ────────────────────────────────────────────────────────
def scan_reddit(sub=REDDIT_SUB, limit=100):
"""Scan subreddit RSS for [RAW] posts with Google Docs links.
Returns list of dicts: {chapter, season, title, gdoc_id, reddit_url}
"""
# old.reddit.com RSS is not datacenter-IP-blocked; www.reddit.com returns
# 429 from server IPs. Fall back across both.
url = f"https://old.reddit.com/r/{sub}/new/.rss?limit={limit}"
req = urllib.request.Request(url, headers={
"User-Agent": USER_AGENT,
"Accept": "text/xml, application/rss+xml",
})
# Retry with backoff (Reddit rate-limits)
xml_data = b""
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=15) as resp:
xml_data = resp.read()
break
except urllib.error.HTTPError as e:
if e.code == 429 and attempt < 2:
time.sleep(5 * (attempt + 1))
continue
raise
root = ET.fromstring(xml_data)
ns = {"atom": "http://www.w3.org/2005/Atom"}
chapters = []
for entry in root.findall("atom:entry", ns):
title_el = entry.find("atom:title", ns)
title = title_el.text if title_el is not None else ""
content_el = entry.find("atom:content", ns)
content_html = content_el.text if content_el is not None else ""
link_el = entry.find("atom:link", ns)
reddit_url = link_el.get("href") if link_el is not None else ""
# Only [RAW] posts with chapter numbers
if not title or not title.lower().startswith("[raw]"):
continue
# Extract Google Doc ID
gdoc_match = re.search(
r"docs\.google\.com/document/d/([a-zA-Z0-9_-]+)",
content_html or ""
)
if not gdoc_match:
continue
gdoc_id = gdoc_match.group(1)
# Parse chapter number from title
# Format: "[RAW] Kubera S03 - 434: Finale (36)"
ch_match = re.search(r"S(\d+)\s*[-:]\s*(\d+)", title)
if ch_match:
season = int(ch_match.group(1))
chapter = int(ch_match.group(2))
else:
ch_match = re.search(r"(\d+)", title)
if ch_match:
chapter = int(ch_match.group(1))
season = 3
else:
continue
chapters.append({
"season": season,
"chapter": chapter,
"title": title,
"gdoc_id": gdoc_id,
"reddit_url": reddit_url,
})
# Sort by chapter number
chapters.sort(key=lambda c: c["chapter"])
return chapters
# ── Google Docs downloader ────────────────────────────────────────────────
def download_gdoc_images(gdoc_id):
"""Download images from a Google Doc via export HTML.
Returns list of (filename, bytes) tuples.
"""
url = f"https://docs.google.com/document/d/{gdoc_id}/export?format=html"
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
html = resp.read().decode("utf-8", errors="replace")
# Extract base64-encoded images
images = []
img_pattern = re.compile(r'src="(data:image/(png|jpeg|jpg);base64,([a-zA-Z0-9+/=]+))"')
for i, match in enumerate(img_pattern.finditer(html), 1):
mime = match.group(2)
b64_data = match.group(3)
ext = "png" if mime == "png" else "jpg"
img_bytes = base64.b64decode(b64_data)
filename = f"{i:03d}.{ext}"
images.append((filename, img_bytes))
return images
# ── CBZ packager ──────────────────────────────────────────────────────────
def package_cbz(images, output_path):
"""Package images into a CBZ file (which is just a ZIP)."""
output_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for filename, img_bytes in sorted(images):
zf.writestr(filename, img_bytes)
return output_path
def chapter_filename(chapter_info):
"""Generate CBZ filename from chapter info."""
s = chapter_info["season"]
ch = chapter_info["chapter"]
return f"Kubera_S{s:02d}_{ch:03d}_translated.cbz"
# ── Cache ─────────────────────────────────────────────────────────────────
def load_cache():
if CACHE_FILE.exists():
with open(CACHE_FILE) as f:
return json.load(f)
return {"downloaded": {}}
def save_cache(cache):
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CACHE_FILE, "w") as f:
json.dump(cache, f, indent=2)
# ── Commands ──────────────────────────────────────────────────────────────
def cmd_scan(args):
"""Scan Reddit for available translations."""
print(f"Scanning /r/{REDDIT_SUB}...")
chapters = scan_reddit(limit=args.limit)
if not chapters:
print("No translation chapters found.")
return
# Check which are already downloaded
cache = load_cache()
print(f"\n{'Ch':>5} {'Status':8s} {'GDoc ID':30s} Title")
print("-" * 90)
for ch in chapters:
filename = chapter_filename(ch)
status = "✅ done" if str(ch["chapter"]) in cache["downloaded"] else "⬇ ready"
if (OUTPUT_DIR / filename).exists():
status = "✅ file"
print(f"S{ch['season']:02d}ch{ch['chapter']:03d} {status:8s} {ch['gdoc_id'][:30]:30s} {ch['title'][:40]}")
def cmd_download(args):
"""Download translations from Google Docs."""
chapters = scan_reddit(limit=args.limit)
cache = load_cache()
if args.chapter:
chapters = [c for c in chapters if c["chapter"] == args.chapter]
if not chapters:
print(f"Chapter {args.chapter} not found in /r/{REDDIT_SUB}")
return
print(f"Downloading {len(chapters)} chapters to {OUTPUT_DIR}...")
for ch in chapters:
filename = chapter_filename(ch)
output_path = OUTPUT_DIR / filename
if output_path.exists() and not args.force:
print(f" ⏭ S{ch['season']:02d}ch{ch['chapter']:03d} (already exists)")
continue
print(f" ⬇ S{ch['season']:02d}ch{ch['chapter']:03d} ({ch['gdoc_id'][:16]}...)")
try:
images = download_gdoc_images(ch["gdoc_id"])
if not images:
print(f" ⚠ No images found in doc")
continue
package_cbz(images, output_path)
size_mb = output_path.stat().st_size / (1024 * 1024)
print(f"{len(images)} pages → {filename} ({size_mb:.1f}MB)")
cache["downloaded"][str(ch["chapter"])] = {
"filename": filename,
"gdoc_id": ch["gdoc_id"],
"reddit_url": ch["reddit_url"],
"downloaded_at": time.time(),
}
save_cache(cache)
except Exception as e:
print(f" ❌ Error: {e}")
# Be polite to Google
time.sleep(2)
def cmd_sync(args):
"""Scan + download all missing chapters."""
cmd_scan(args)
print()
args.force = False
cmd_download(args)
# ── Main ──────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
prog="kubera-sync",
description="Download /r/kubera fan translations from Google Docs → CBZ"
)
sub = parser.add_subparsers(dest="command", required=True)
p_scan = sub.add_parser("scan", help="Scan Reddit for available translations")
p_scan.add_argument("--limit", type=int, default=100)
p_scan.set_defaults(func=cmd_scan)
p_dl = sub.add_parser("download", help="Download translations")
p_dl.add_argument("--chapter", "-c", type=int, help="Specific chapter number")
p_dl.add_argument("--all", action="store_true", help="Download all found")
p_dl.add_argument("--force", action="store_true", help="Re-download even if exists")
p_dl.add_argument("--limit", type=int, default=100)
p_dl.set_defaults(func=cmd_download)
p_sync = sub.add_parser("sync", help="Scan + download missing chapters")
p_sync.add_argument("--limit", type=int, default=100)
p_sync.set_defaults(func=cmd_sync)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()