Memories and Archive
Verified 2026-09-06 by a real run: two throwaway accounts, a template book with two written chapters, two audio recordings (one multipart, one 20 MB through presign → Backblaze → register), a short mp4, three pictures and one interior PDF export. The owner saw all nine on /memories at 390×844 and 1280×800, played the audio and the video in place, and downloaded a 20.5 MB zip in 19.5 s; every expected file was in it and byte-identical to what was uploaded. A family member in the same family group saw the same nine and downloaded the same zip. With the storyteller’s “family may download” switch off, that family member still saw everything and got a plain refusal on download — including on the job they had already opened.
The owner’s reason for this page: “The audios and videos that the users upload become memory. Usually, this is when somebody passes away: their account, their memory, their documents can be downloaded by the family members if they wish to, because then that becomes memory for them.”
So /memories has two jobs and no others: find and play any one thing, and
take a copy of all of it.
1. What counts as a memory
Six kinds, from five tables. The page hides that entirely — a grieving family should never have to know which table their mother’s voice is in.
| On the page | Comes from | Filed under | Notes |
|---|---|---|---|
| Recording | recordings where media_type = 'audio' | Recordings | Plays in place; shows transcript state |
| Video | recordings where media_type = 'video' | Videos | Plays in place through a range-streaming proxy |
| Photo | images where kind is photo or document | Photos | Thumbnail if the uploader made one |
| Picture | images where kind = 'art' | Photos | Cover art and anything drawn for the book |
| Book | export_jobs where kind is interior or cover, newest completed per book | Book | Opens and downloads the real PDF |
| Story | stories | Book | Opens in the reader; downloads as a readable web page |
GET /api/memories?storytellerId=&filter=&page= merges the five, sorted newest
first, 24 to a page. The merge is exact rather than approximate: it takes the
first (page + 1) Ă— 24 + 1 rows from each source and slices the merge, so
nothing can be skipped or repeated between pages.
Two things that run through everything here and were both found by the real run:
- Timestamps.
recordings,storiesandbooksusetimestamp without time zone;imagesandexport_jobsusetimestamptz. JavaScript reads a bare stamp as the reader’s local time, which put a recording seven hours into the future and sorted it above photographs from the same minute.parseTimestampinlib/memories/format.tstreats a naive stamp as UTC, which is whatnow()wrote. - Dates in file names are the storyteller’s calendar date, from
profiles.timezone, not UTC. A story recorded at nine in the evening in New York should not be filed under the next day.
2. The page
/memories, one screen. 18 px type, 48 px controls, one column on a phone and
three at desktop width.
- Big filter pills: All / Recordings / Videos / Photos / Book.
- Each card: a thumbnail or an icon, the title, the date, who added it, the length or the size, and two buttons — Play or Open, and Download.
- Audio and video play inside the card. Photographs open full-screen. Stories go to the reader; PDFs open in a new tab.
- A storyteller chooser appears only when the viewer can see more than one person’s memories. Most people never see it.
- “Show more” loads the next 24. Empty state says what to do, not sorry.
Playback and single-file downloads go through
GET /api/memories/[id]/media?type=recording|image|export|story, which repeats
the access check and streams from Backblaze with Range passed straight
through — Safari will not start a <video> without a 206.
3. The archive
POST /api/account/archive { storytellerId } counts and prices what is there
and opens an export_jobs row with kind: 'archive'. It zips nothing; it
exists so the page can say “Gathering 11 items — about 20 MB” and mean it.
GET /api/account/archive/[jobId]/download?part=all streams the zip:
Backblaze → archiver → the response, one file at a time, nothing on disk and
nothing held whole in memory.
README.txt what this is, when it was made, for whom
manifest.json every file, machine-readable, with anything missing named
book/<title>-interior.pdf newest completed interior
book/<title>-cover.pdf newest completed cover
stories/01-<title>.html each story as a page that opens offline
stories/stories.json titles, chapter order, dates, source recording ids
recordings/<date>-<title>.m4a the audio exactly as uploaded
recordings/<date>-<title>.transcript.txt
videos/<date>-<title>.mp4
photos/<date>-<title>.jpgThe story pages carry the printed book’s own font names ('PT Serif',
'Playfair Display', falling back to Georgia) and load nothing from the
internet. When there is no finished book PDF the folder is simply absent and
the README says so — nothing is generated to fill a gap.
Limits, and what happens at them.
- A Vercel function is capped at 300 seconds. Measured here: 20.5 MB in about 20 seconds through a local dev server, so a gigabyte is already most of that budget once real network variance is allowed for.
- So: if the estimate exceeds 1 GB, one zip is not offered at all. The page says why and hands back the same files folder by folder — each part is a complete zip of its own, with its own README saying which part it is. A stream cut off at the platform cap is a corrupt zip with no error message, which is the worst possible outcome for the one download that matters.
- A file that cannot be read is recorded in
manifest.jsonasincluded: falseand named undermissing, rather than failing the whole download.
4. Who may download
Written as one small decision table in lib/memories/access.ts, deliberately
separate from the family agent’s work so it can be swapped for their helper
later without touching the rule.
| Who | See | Download the archive |
|---|---|---|
| The storyteller | Always | Always |
| Someone in a family group the storyteller is in | Always | While profiles.memories_family_download is true (default true) |
| Anyone else | No | No |
The switch lives on /memories/settings and is checked again on every
download, not only when the job is opened — the real run confirmed that a job
opened while it was on is refused once it is off. Refusals are sentences, not
codes: “This person has turned off downloads for family members. You can still
play and open their memories here.”
5. The legacy flow — not built
The fuller idea the toggle stands in for is real and is not here:
- naming a person to look after these memories after a death;
- a way for a family to prove what has happened;
- what becomes of the account, the subscription and the storage afterwards;
- notifying the named person, and a waiting period before access changes.
Until that exists, the switch is the whole of it, and /memories/settings says
so in those words rather than implying more.
6. What else is not done
- No search and no date range. Filters and “show more” only. Fine at nine items and at ninety; a family with nine hundred will want search.
- No selecting several items to download together. One at a time, or the whole archive.
- The archive is not resumable. A dropped connection means starting again.
- No email when a large archive is ready — the download is synchronous by design, so there is nothing to notify about.
- Part downloads are per folder, not per size. A single folder over 1 GB (a family with many videos) still cannot be taken in one piece.
- Deleting a memory is not here. This page finds, plays and copies.
- The archive job row records size and completion, but there is no screen that lists past archives.