Files
Classroom_Desktop/scripts/classroom-presentation-import
T
ekklesiaandClaude Opus 5.5 3855989029 fix: a presentation that runs out of memory says so, and the service survives it
On a 4 GB VM the kernel killed the import service mid-conversion. Measured:
LibreOffice needs 0.2 GB for a text deck and 1.4-2.1 GB for decks of many
different photographs, for about twenty seconds — a spike, not a leak.
What made it a problem was the rest: OOMPolicy=stop took the watcher down
with LibreOffice, it restarted and resumed the same conversion with no
limit, and the library said "Getting this ready" throughout.

The service is capped at 40% of memory, is first in line when memory runs
short everywhere, and survives a killed LibreOffice (OOMPolicy=continue).
A killed conversion is a failed import of kind "memory"; an import
interrupted twice is not tried a third time; the card says the computer
needed more memory, to close other programs and choose Try again; an
import nobody has worked on for 20 minutes shows the same card; and an
exclusive lock keeps it to one import at a time.

Lab, the real service and LibreOffice: at a 4 GB-machine ceiling the
2.1 GB deck still imported; with no swap and a 300 MB ceiling LibreOffice
was killed, the watcher kept its PID and the card offered Try again,
which worked; the whole service killed during two attempts gave up after
the second; three decks at once never ran two LibreOffices together.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 22:16:50 -05:00

567 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Watch the teacher's downloads, and keep the library tidy.
A teacher downloads a session in ordinary Chrome and then looks for it in
Presentations. Nothing in between is their job: no file manager, no
import button, no "where did you save it". This is the in between.
classroom-presentation-import run as a service, forever
classroom-presentation-import --once one pass, for a technician
classroom-presentation-import --status what the library holds
WHAT IT WILL NOT DO.
It never leaves a duplicate in Downloads. Once a supported download is
complete, its one original is moved into the managed library. Generated
PDFs and page images live beside it and can always be rebuilt from it.
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
directory of its own inside Downloads and those are worksheets, not
sessions; the cleanup will not remove one either.
It never imports a file that is still arriving. Chrome writes
`Session 7.pptx.crdownload` and renames it when the transfer is
done, so a finished name with that sibling beside it is a download in
progress, not a presentation.
It never overwrites a presentation with a later one of the same
name. Two downloads called "Bible Study.pdf" are two presentations.
"""
import argparse
import errno
import os
import shutil
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
import classroom_presentations as library # noqa: E402
from classroom_config import feature, read_device_config # noqa: E402
# How often the downloads directory is looked at.
#
# Polling rather than inotify, deliberately. A teacher waits a few
# seconds either way, this cannot leak a watch descriptor or miss an
# event across a restart, and it is the kind of code somebody can still
# read in two years.
SCAN_SECONDS = 4
# How often expired presentations are cleared out.
CLEANUP_SECONDS = 3600
def say(message):
print(f"classroom-presentation-import: {message}", flush=True)
def new_directory(root):
root.mkdir(parents=True, exist_ok=True)
directory = root / uuid.uuid4().hex
directory.mkdir()
return directory
def sync_directory(directory):
"""Best-effort persistence for a rename or unlink transaction."""
try:
descriptor = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(descriptor)
except OSError:
pass
finally:
os.close(descriptor)
def move_download(source, destination):
"""Move one completed download without risking it on another filesystem.
rename is atomic on one filesystem. Across filesystems, copy to a hidden
temporary file, flush it, publish it atomically, and only then unlink the
source. If unlinking fails, the published copy is removed and the source
remains the one original for the next scan.
"""
source = os.fspath(source)
destination = os.fspath(destination)
temporary = destination + ".moving"
os.makedirs(os.path.dirname(destination), exist_ok=True)
if os.path.exists(destination) or os.path.exists(temporary):
raise FileExistsError(destination)
try:
os.replace(source, destination)
sync_directory(os.path.dirname(destination))
sync_directory(os.path.dirname(source))
return
except OSError as error:
if error.errno != errno.EXDEV:
raise
try:
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
outgoing.flush()
os.fsync(outgoing.fileno())
shutil.copystat(source, temporary, follow_symlinks=True)
os.replace(temporary, destination)
sync_directory(os.path.dirname(destination))
try:
os.unlink(source)
except OSError:
# Roll back to the still-complete source rather than leave two
# originals or make a failed move look successful.
try:
os.unlink(destination)
sync_directory(os.path.dirname(destination))
except OSError:
pass
raise
sync_directory(os.path.dirname(source))
except Exception:
try:
os.unlink(temporary)
except OSError:
pass
raise
def forget_presentation(presentation_id, root):
"""Remove transaction records for one interrupted presentation."""
data = library.ledger(root)
changed = False
for mark, value in list(data.items()):
if value == presentation_id:
data.pop(mark, None)
changed = True
if changed:
library.write_json(library.ledger_path(root), data)
class NeedsMemory(RuntimeError):
"""The kernel killed the converter or renderer for want of memory."""
# An import the service was killed in the middle of is resumed at the next
# start — once. Measured in the lab: a deck that needs more memory than
# the machine has free is killed again every time, and with no limit the
# service converted, died and restarted in a loop while the library said
# "Getting this ready…" for ever.
MAX_ATTEMPTS = 2
def prepare(presentation):
"""Build the derived PDF, pages and cover from a managed original."""
data = presentation.data
managed = presentation.managed_copy
# Counted and written BEFORE the work, so that a kill in the middle of
# it is still counted when the service comes back.
data["attempts"] = int(data.get("attempts") or 0) + 1
data["attempt_started_at"] = library.stamp()
presentation.save()
if managed is None or not managed.is_file():
raise RuntimeError("the managed original is missing")
# A restart may find conversion or rendering output from the interrupted
# attempt. The managed original is the recovery point; everything else is
# derived and is rebuilt cleanly.
if presentation.slides != managed:
try:
presentation.slides.unlink()
except OSError:
pass
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
try:
presentation.thumbnail.unlink()
except OSError:
pass
if data["format"] == "pdf":
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
if error == library.KILLED:
raise NeedsMemory(error)
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
data.get("original_name", ""),
library.first_text(presentation.slides)),
library.titles_in_use(presentation.directory.parent,
exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
data["error"] = None
data["error_kind"] = None
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
def import_one(source, root=None):
"""Take one finished download into the library.
Ordered so that an interruption is always recoverable. Metadata and the
transaction record are written before the move. Startup either retries
from Downloads or resumes from the moved managed original. A presentation
becomes ready only once every derived file it needs is on disk.
"""
root = root if root is not None else library.library_root()
mark = library.fingerprint(source)
if mark is None:
return None
directory = new_directory(root)
managed = directory / "original" / source.name
data = {
"id": directory.name,
"state": "importing",
"title": library.readable_title(source.name),
"original_name": source.name,
"source_path": str(source),
"managed_copy": str(managed.relative_to(directory)),
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
"thumbnail_page": 1,
"pages": 0,
"error": None,
}
presentation = library.Presentation(directory, data)
presentation.save()
# Recorded before the work, so that a file which crashes the
# conversion every time cannot be retried in a loop forever. The
# startup sweep is what un-records an import interrupted part way.
library.remember(mark, directory.name, root)
say(f"importing {source.name}")
try:
move_download(source, managed)
except Exception as error: # noqa: BLE001
# A failed move leaves the source in Downloads. Remove the transaction
# and retry on the next scan; there is no half-owned file to show.
forget_presentation(presentation.id, root)
shutil.rmtree(directory, ignore_errors=True)
say(f"could not move {source.name}: {error}")
return None
try:
return prepare(presentation)
except Exception as error: # noqa: BLE001
# Conversion and rendering failures keep the managed original. The
# teacher can remove it, and a technician can recover or inspect it;
# no successfully downloaded file is lost because a derived asset
# could not be made.
fail(presentation, error)
say(f"could not import {source.name}: {error}")
return presentation
def fail(presentation, error, kind=None):
"""Record a failure a teacher's card can say something useful about."""
presentation.data["state"] = "failed"
presentation.data["error"] = str(error)[:300]
presentation.data["error_kind"] = kind or (
"memory" if isinstance(error, NeedsMemory) else "unreadable")
presentation.save()
def attempt(presentation):
"""prepare(), with any failure recorded rather than raised."""
try:
prepare(presentation)
except Exception as error: # noqa: BLE001
fail(presentation, error)
say(f"could not import {presentation.title!r}: {error}")
def retry_requested(root=None):
"""Presentations a teacher pressed Try again on, taken up now."""
root = root if root is not None else library.library_root()
retried = 0
if not root.is_dir():
return 0
for presentation in library.library(root):
if presentation.state != "retry":
continue
managed = presentation.managed_copy
if managed is None or not managed.is_file():
fail(presentation, RuntimeError("the managed original is missing"), "unreadable")
continue
say(f"trying again: {presentation.title!r}")
attempt(presentation)
retried += 1
return retried
def sweep(root=None):
"""Recover imports interrupted during a move, conversion or rendering.
If Downloads still has the source, discard only the incomplete managed
copy and let the next scan retry. If the source has already been moved,
the managed original is the recovery point and its derived assets are
rebuilt in place. This distinction prevents a crash after the move from
deleting the only copy of a successful download.
"""
root = root if root is not None else library.library_root()
if not root.is_dir():
return 0
stale = 0
for presentation in library.library(root):
if presentation.state != "importing":
continue
source_text = presentation.data.get("source_path")
source = Path(source_text) if source_text else None
managed = presentation.managed_copy
if (managed is not None and managed.is_file()
and (source is None or not source.exists())):
attempts = int(presentation.data.get("attempts") or 0)
if attempts >= MAX_ATTEMPTS:
# Killed in the middle of it every time it was tried.
say(f"giving up on {presentation.title!r} after {attempts} "
"interrupted attempts; the teacher can try again")
fail(presentation, NeedsMemory("interrupted every attempt"))
else:
say(f"resuming an interrupted import: {presentation.title!r}")
attempt(presentation)
stale += 1
continue
say(f"retrying an interrupted move: {presentation.title!r}")
forget_presentation(presentation.id, root)
shutil.rmtree(presentation.directory, ignore_errors=True)
stale += 1
return stale
def scan(root=None, downloads=None):
"""One look at the downloads directory."""
root = root if root is not None else library.library_root()
known = library.ledger(root)
imported = 0
for candidate in library.candidates(downloads):
mark = library.fingerprint(candidate)
if mark is None or mark in known:
continue
if import_one(candidate, root) is not None:
imported += 1
known = library.ledger(root)
return imported
def cleanup(root=None, moment=None):
"""Remove presentations whose time is up.
Only ever the selected managed directory, including its one original and
derived assets, and never the presentation on the screen.
"""
root = root if root is not None else library.library_root()
lock = library.presenting_lock()
showing = ""
try:
showing = lock.read_text().strip()
except OSError:
showing = ""
removed = 0
for presentation in library.library(root):
if not presentation.expired(moment):
continue
if presentation.id == showing:
# A retention timer must never interrupt a room. It will
# still be expired in an hour.
say(f"{presentation.title!r} is expired but is being presented")
continue
if presentation.last_presented_at is None:
why = f"never presented, imported {presentation.data['imported_at']}"
else:
why = f"last presented {presentation.data['last_presented_at']}"
say(f"removing {presentation.title!r} — {why}")
if library.remove(presentation):
removed += 1
return removed
def status(root=None):
presentations = library.library(root)
if not presentations:
print("the library is empty")
return 0
for presentation in presentations:
expires = presentation.expires_at()
print(f"{presentation.title}")
print(f" state {presentation.state}"
f"{' — ' + presentation.error if presentation.error else ''}")
print(f" slides {presentation.pages}"
f" (card shows slide {presentation.thumbnail_page})")
print(f" imported {presentation.data.get('imported_at')}")
presented = presentation.data.get("last_presented_at") or "never"
print(f" presented {presented} "
f"({presentation.times_presented} times)")
print(f" expires {library.stamp(expires) if expires else 'unknown'}")
return 0
def hold_lock(root=None):
"""The importer's lock, held for as long as this process lives."""
import fcntl
root = root if root is not None else library.library_root()
root.mkdir(parents=True, exist_ok=True)
handle = open(root / ".importer.lock", "a")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def main():
parser = argparse.ArgumentParser(
description="Bring finished downloads into the Presentations library.")
parser.add_argument("--once", action="store_true",
help="one pass, then stop")
parser.add_argument("--status", action="store_true",
help="what the library holds; changes nothing")
parser.add_argument("--cleanup", action="store_true",
help="apply the retention rules now")
arguments = parser.parse_args()
if arguments.status:
return status()
# A device that does not offer Presentations does not run an importer
# for them. Said out loud for the technician who started it by hand.
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
say("PRESENTATIONS_ENABLED is false on this device; not starting")
return 0
if arguments.cleanup:
removed = cleanup()
say(f"removed {removed} expired presentation(s)")
return 0
# One importer at a time, whoever started it. Conversions are the
# memory the machine runs short of, so two at once — the service and a
# technician's --once, say — must not happen.
held = hold_lock()
if held is None:
say("another importer is already running; not starting a second")
return 0
sweep()
retry_requested()
if arguments.once:
imported = scan()
cleanup()
say(f"imported {imported} presentation(s)")
return 0
say(f"watching {library.downloads_root()}")
last_cleanup = 0.0
while True:
try:
retry_requested()
scan()
if time.monotonic() - last_cleanup > CLEANUP_SECONDS:
cleanup()
last_cleanup = time.monotonic()
except Exception as error: # noqa: BLE001
# One bad file must not stop the watcher. A teacher whose
# next download never appears has no way to find out why.
say(f"scan failed: {error}")
time.sleep(SCAN_SECONDS)
if __name__ == "__main__":
sys.exit(main())