- Run
cp .env.example .env, and update the.envfile as necessary.
Then run the following commands
- Run
npm installto install the project dependencies.
- Run
npm run devornuxt devto start the development server. - Open http://localhost:3000/
npm run format-check- checks for formatting errors.npm run format- auto-formats all files.npm run lint- checks for Typescript errors.npm run type-check- runs Nuxt type checking.npm run test- runs backend tests.
The backend accepts one MP3 or M4B upload plus an email address, stores a durable SQLite job, and
uses a separate worker process to run FFmpeg and send a temporary Mailgun download link. A job is
one of two kinds: split (cut embedded chapters into a ZIP) or convert (transcode the whole file
between MP3 and M4B). Both share the same job pipeline.
Run the API locally with:
npm run devRun the built API with:
npm run build
npm run api:startRun the worker locally in a second shell with:
npm run worker:devSee docs/backend.md for endpoint details, job states, storage layout, Docker startup, Mailgun setup, and known limitations.
The main page is a focused upload experience for one audiobook at a time:
- Choose exactly one
.m4bor.mp3audiobook with embedded chapter metadata. - Enter the email address that should receive the completion link.
- Submit the multipart upload using backend fields named
fileandemail. - Keep the browser tab open while upload progress is transferring.
- After upload completes, the page polls
GET /api/jobs/:jobIdfor queued, processing, ready, failed, or expired status. - The finished ZIP is delivered by email. The frontend never receives or constructs the download token.
- Download links and generated ZIP files expire after 12 hours by default.
The upload form validates file extensions and email format for user guidance, but the API remains
authoritative for upload size, supported media, queue capacity, chapter metadata, and storage
availability. Files without embedded chapter markers fail with NO_CHAPTERS_FOUND; silence-based
or AI chapter detection is not implemented.
The /convert page ("Audio Converter") is a separate flow that converts one audiobook between MP3
and M4B (POST /api/convert) instead of splitting it. It preserves chapters, cover art, and
metadata, has no chapter requirement (so it also handles songs and clips), and returns a single
file. Once a job is ready, either flow offers a "Delete now" action to purge the file and revoke
its links immediately rather than waiting for the 12-hour expiry.
Active job recovery uses sessionStorage and stores only the public job ID. Email addresses,
filenames, internal errors, download tokens, and temporary URLs are not persisted in browser
storage.
For local development, run the Nuxt API and frontend with npm run dev. Start npm run worker:dev
in a second shell to process queued uploads and send completion emails.
Chaptify is split by runtime boundary. app/ contains the Nuxt UI, upload workflow, browser
download action, polling, and session restore behavior. server/ contains Nitro API routes,
SQLite persistence, Mailgun delivery, storage cleanup, and the worker-only FFmpeg pipeline.
shared/ contains schemas, public response types, constants, and pure helpers used by both sides.
The API process and worker process share NUXT_STORAGE_ROOT and the SQLite database under that
root. The API never runs FFmpeg during an upload request: it streams the multipart file into
temporary storage, moves it into a private per-job directory, creates a queued SQLite job, and
returns a public job ID plus a browser job-access token. The worker claims queued jobs from SQLite
and branches on the job kind: a split job inspects embedded chapters with ffprobe and writes a
ZIP of per-chapter files, while a convert job transcodes the whole file to the other format
(preserving metadata, cover art, and chapters) into a single output file. It stores only token
hashes, then sends the Mailgun completion email.
There are three identifiers with different trust levels. The public job ID is safe to expose in
status URLs. The browser job-access token lets the same browser session download a ready artifact
(and delete it) from POST /api/jobs/:jobId/download and POST /api/jobs/:jobId/delete. The email
download token is embedded only in the emailed
GET /api/download/:token link. Raw tokens are never persisted; only SHA-256 hashes are stored.
Ready artifacts expire after the configured retention period. Cleanup runs in the worker on startup
and during the polling loop, removes expired or failed job files from the shared storage root, and
marks expired jobs so old tokens stop resolving. Docker Compose runs one API service and one worker
service from the same image, with both services mounting the same chaptify-storage volume.
Start in these files for common changes:
- Frontend upload, polling, restore, and browser-download behavior:
app/pages/index.vue,app/composables/use-job-upload.ts, andapp/composables/use-job-status.ts. - API request/response behavior:
server/api/jobs/,server/api/download/[token].get.ts, andshared/utils/schemas/api.ts. - Queue, job states, and token lookup rules:
server/utils/backend/database.tsandserver/utils/backend/ids.ts. - Worker, cleanup, FFmpeg, ZIP, and Mailgun behavior:
server/utils/backend/worker.ts,server/utils/backend/media.ts,server/utils/backend/archive.ts,server/utils/backend/cleanup.ts, andserver/utils/backend/mailgun.ts. - Runtime storage and Docker startup:
server/utils/backend/config.ts,Dockerfile, anddocker-compose.yml.
Use Node 24 and regenerate the lockfile on Linux. This project targets Node 24 (see
.nvmrcandpackage.jsonengines); runnvm usebefore installing. Native build tooling (rollup, oxc, rolldown, tailwind-oxide, unrs-resolver, lightningcss) ships per-platform binaries, and npm on macOS omits some Linux-only optional deps (e.g.@emnapi/*,cac) frompackage-lock.json, which makesnpm cifail on the Linux CI runner and in the Docker build. After changing any dependency, regenerate the lockfile withnpm run lockfile:refresh— it uses Docker to resolve onlinux/amd64+node:24(matching CI, regardless of your machine's OS/arch) and updatespackage-lock.jsononly, leaving your localnode_modulesuntouched. Commit the result. For your ownnode_modules, a normalnpm installis fine — just don't commit a macOS-generated lockfile.
Why
oxc-parseris an explicit devDependency. Nothing in our code imports it. It is theparseSyncimplementation thatoxc-walker(viaunimport/unctx) needs duringnuxt prepare, and it reaches us only as an optional peer dependency. npm 10 installed those; npm 11 — the version bundled with Node 24 — does not, so a freshnpm ciproduced a tree without it andnpm run postinstallfailed withoxc-walker: could not resolve a parseSync implementation. Nuxt keeps its own pinned copy nested undernode_modules/nuxt/, which the top-leveloxc-walkercannot resolve into, so the hoisted copy has to be declared. Don't remove it unless anpm ci+npm run buildin a cleannode:24-alpinecontainer still passes without it.
Update packages to the latest safe version as follows:
- Run
npm outdatedto check for outdated packages. - Run
npm updateto update all the outdated packages.
- If you want to update only a specific package, run
npm update <package-name>.
- Run
npm outdatedagain to check if there are still outdated packages.
Major version updates should be done with caution, as they may introduce breaking changes.
You can do so by using the @latest. e.g. npm install <packagename>@latest
As an alternative, you can also use npm-check-updates.