Ongoing Reading Project
Een gedrukt boek vol QR-codes dat 5.938 voorgelezen opnames ontsluit.
Job Koelewijn leest elke dag 45 minuten hardop voor (een zijde van een cassette) en neemt dat op; al jaren, 231 boeken, 5.938 lezingen. Sla het boek open op een titel, scan een code, en je hoort precies die ene lezing. Elke pagina is een boek, elke code een dag.
In 2020 liep ik stage bij Job en sindsdien werk ik voor hem. Dit project bouwde ik om al dat voorgelezen materiaal te archieveren en ontsluiten. Van data tot drukwerk tot server. Zelf houdt Job de metadata van zijn voorgelezen materiaal bij in een papieren logboek. Ik vertaalde dat naar een database die op een Hetzner VPS staat met een backup op zijn NAS, waar ook alle originele audiobestanden op staan. Een analyser koppelt de opnames aan het logboek en laat Whisper een voorstel doen bij slecht benoemde bestanden, zodat nu alle audiobestanden gekoppeld zijn een hun metadata.
Voor het boek schreef ik een Python-bibliotheek die InDesign-bestanden genereert, met een rasteroplosser voor het grid van de QR-codes. Iedere QR-code verwijst nu naar via een gehast-adres naar een audiobestand op de Hetzner server. De site is statische HTML achter Caddy; de audio streamt rechtstreeks uit object storage.
Colofon
- Voor: Job Koelewijn
- Mijn aandeel: data, boek, archief, website, server
Code
Waar de code in elke QR-code vandaan komt, ingekort: acht tekens, afgeleid van een geheim en het nummer van de opname. Er is geen index op de site, dus een lezing vind je alleen door te scannen. Omdat codes gedrukt worden, blijft een toegekende code staan.
CODE_LENGTH = 8
def qr_code_for(secret: str, track_id: int) -> str:
digest = hashlib.sha256(f"{secret}:qr:{track_id}".encode()).digest()
return base64.b32encode(digest).decode("ascii").lower()[:CODE_LENGTH]
def assign_codes(conn, secret: str, force: bool = False) -> dict:
"""Give every track a code. Existing codes are kept (they may be printed) unless force."""
stats = {"assigned": 0, "kept": 0}
seen: dict[str, int] = {}
for r in conn.execute("SELECT id, qr_code FROM tracks ORDER BY id").fetchall():
code = r["qr_code"] if r["qr_code"] and not force else qr_code_for(secret, r["id"])
if code in seen:
raise RuntimeError(f"QR code collision: {code} for tracks {seen[code]} and {r['id']}")
seen[code] = r["id"]
if code == r["qr_code"]:
stats["kept"] += 1
else:
conn.execute("UPDATE tracks SET qr_code = ? WHERE id = ?", (code, r["id"]))
stats["assigned"] += 1
conn.commit()
return stats