REST API Endpoints
All endpoints are under the /api prefix.
Jobs
List Active Jobs
Returns active disc jobs (excludes cleared/archived jobs). The 10 most recent finished
(completed/failed) jobs are included; every other state is returned in full, so a job
still waiting on you (for example one parked in review_needed) never ages out of the
dashboard behind newer discs.
app.api.routes.list_jobs(session=Depends(get_session))
async
List active disc jobs (excludes cleared/archived jobs).
The dashboard is a recency window, but the cap applies to finished jobs only. A non-terminal job - above all REVIEW_NEEDED, which waits on a human indefinitely - must never age out behind newer discs: history shows only terminal states, so a job pushed off this list would be visible nowhere at all. Non-terminal jobs are bounded in practice by the number of drives, so exempting them cannot make this list unbounded in normal use.
Get Job by ID
app.api.routes.get_job(job=Depends(get_job_or_404))
async
Get a specific job by ID.
Get Job Detail
Returns full job detail with all titles for history drill-down. Includes classification metadata, TheDiscDB mappings, subtitle info, and per-track breakdown.
app.api.routes.get_job_detail(job=Depends(get_job_or_404), session=Depends(get_session))
async
Get full job detail with titles for history drill-down.
Get Job Titles
Returns all titles (tracks) with match results for a job.
app.api.routes.get_job_titles(job=Depends(get_job_or_404), session=Depends(get_session))
async
Get all titles with match results for a job.
Start Job
Start ripping a disc. Job must be in idle or review_needed state.
Cancel Job
Cancel a running job.
Clear Job
Soft-delete a job from the dashboard (sets cleared_at timestamp). Job remains visible in history.
History & Analytics
Job History
Returns completed/failed jobs with pagination and filtering. Jobs appear in history automatically when they reach a terminal state, with no manual clearing required.
An explicit state is honoured for any job state, and include_all_states=true drops
the state filter entirely. Either makes history a complete record of every job, including
one still in progress or parked in review.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number (1-indexed) |
per_page |
int | 20 | Results per page (max 100) |
content_type |
string | — | Filter: tv or movie |
state |
string | — | Filter by any JobState, e.g. completed, failed, review_needed. An unknown value is rejected with 422 |
include_all_states |
bool | false |
Return every state instead of the completed/failed default. Ignored when state is given |
q |
string | none | Case-insensitive text search over volume label, detected/TMDB title, matched episode codes and error text (% and _ are literal). Max 200 characters |
Job Statistics
Takes the same content_type, state, include_all_states and q filters as the history endpoint and aggregates exactly the jobs that query would list (completed/failed by default). Returns: total jobs, completed/failed counts, TV/movie counts, average processing time, top 5 common errors, and 10 most recent jobs.
Review
Submit Review
Submit a review decision for a title that needs human intervention.
Request Body:
Approve All
Approve all pending review items and continue processing.
POST /api/jobs/{job_id}/titles/{title_id}/llm-match
Run the LLM episode matcher for a single title and persist the suggestion to match_details.llm_suggestion. Requires ai_episode_matching_enabled and the job to have a known detected_title + detected_season.
Response (200):
{
"suggestion": {
"episode": 7,
"confidence": 0.93,
"reasoning": "Mentions of named character and unique plot beat.",
"runner_up": {"episode": 6, "confidence": 0.12},
"model": "gemini-2.5-flash-lite"
},
"reason": null
}
When no suggestion is available (feature disabled, no transcript, no synopses, AI returned zero confidence, or any internal error), suggestion is null and reason describes why ("no_suggestion", "cached", or "internal_error"). On a cached hit (the suggestion was already computed for this title), the existing suggestion is returned with reason: "cached" to avoid duplicate Whisper transcription.
GET /api/jobs/{job_id}/titles/{title_id}/media
Streams the ripped file for one title, byte for byte, with HTTP range support
(206/416 come from Starlette's FileResponse). Served as
video/x-matroska.
The file is never transcoded, and this is deliberate. MakeMKV remuxes
losslessly, so a rip carries disc-native codecs that no browser decodes, and
the endpoint exists to hand the file to a native player (VLC, MPV, IINA) rather
than to feed a <video> element. Engram has no ffmpeg dependency for playback
and should not grow one here. See
docs/superpowers/specs/2026-08-22-review-external-player-design.md for why
in-browser playback was ruled out.
The path is read from the database (DiscTitle.output_filename, falling back to
organized_to) and never accepted from the client. It must resolve inside one
of the configured roots: staging, the TV library, the movie library, the import
watch folder, or the job's own staging_path (which is where manual imports
live).
Status codes:
| Code | Meaning |
|---|---|
200 / 206 |
The file, whole or ranged. |
403 |
Origin gate refused the peer, or the path resolved outside every configured root. |
404 |
Title not found, the track has not been ripped, or the recorded file is gone from disk. |
GET /api/jobs/{job_id}/titles/{title_id}/playlist.m3u
Returns a one-entry .m3u naming the media URL above, as
audio/x-mpegurl with Content-Disposition: attachment, so the browser hands
it to whatever owns the extension. Same status codes as the media endpoint; the
path is resolved before the body is built, so a broken track fails as HTTP
rather than inside the user's player.
The URL inside the playlist is derived from the request's Host header, so it
carries the address the dashboard was actually reached on. Deriving it from
settings.host would emit localhost for every remote user: correct on the
backend machine, broken everywhere else, and invisible to local testing.
Disc-derived text in the playlist label passes through
sanitize_playlist_field, because .m3u is line-oriented and a crafted volume
label containing a line break could otherwise forge a second entry.
Both endpoints sit behind require_localhost_or_lan: loopback always, LAN peers
only when allow_lan_access is set.
Configuration
Get Configuration
Returns current application configuration. API keys are redacted (masked as "***").
Update Configuration
Update configuration fields. Accepts partial updates.
Tool Validation
Validate MakeMKV
Validate MakeMKV installation and license.
Validate FFmpeg
Validate FFmpeg installation.
Detect Tools
Auto-detect MakeMKV and FFmpeg installations on the system. Searches platform-specific paths.
Diagnostics
Bug Report
Generate a sanitized bug report with system info, recent errors, and optional job context. Returns a pre-filled GitHub issue URL.
Simulation
Debug Mode Only
Simulation endpoints are only available when DEBUG=true.
Insert Disc
{
"volume_label": "ARRESTED_DEVELOPMENT_S1D1",
"content_type": "tv",
"simulate_ripping": true,
"rip_speed_multiplier": 1
}
Remove Disc
Advance Job
Manually advance a job to its next state.
Reset All Jobs
Delete all jobs and titles. Useful for test cleanup.
Insert from Staging
Create a job from pre-existing files in the staging directory.