Skip to content

API Reference

Domekologe edited this page Aug 8, 2026 · 11 revisions

API Reference

🌐 English · Deutsch

There are two API layers:

  1. External REST API (/api/v1/...) — for scripts, dashboards (e.g. Homepage/Homarr) and third-party systems. Authenticated via API key, no login needed.
  2. Internal API (/api/...) — used by the WebUI, session-based (login required, partly admin). It is not a stable contract, but the most important endpoints are documented below anyway.

External API v1

Authentication

Send the API key in the X-Api-Key header. The key is generated automatically on first start and can be viewed (and regenerated) under Settings → API.

curl -H "X-Api-Key: <key>" http://localhost:8080/api/v1/status

Missing/wrong key → 401 Unauthorized.

Endpoints

Method Path Description
GET /api/v1/status Overall status: version, pause state, queue counters by status, currently running download incl. live progress (episode_progress: percent, phase, speed, bandwidth; overall_progress_percent)
GET /api/v1/queue All queue entries; filter: `?status=queued
GET /api/v1/queue/<id> Single queue entry (404 if unknown)
GET /api/v1/library Full library (all paths, titles, seasons, episodes, sizes)
GET /api/v1/library/series Series only
GET /api/v1/library/movies Movies only
GET /api/v1/stats Overall download statistics
GET /api/v1/autosync Auto-Sync jobs — every job of every user (the internal /api/autosync is filtered to the caller)
GET /api/v1/history Download history — every user; filters: ?status=, ?limit=, ?offset=
GET /api/v1/upscale Upscale queue: items, badge count and the running job's progress
GET /api/v1/mediascan MediaScan (Plex/Jellyfin import): run status and cached title count
GET /api/v1/uptime UpTime monitor: current status, uptime % and average response time per tracked source
GET /api/v1/update-status Self-update: progress of a running update, or the idle state

Example: status

{
  "version": "2.5.3",
  "paused": false,
  "queue": {"total": 12, "queued": 2, "running": 1, "completed": 8, "failed": 1, "cancelled": 0},
  "currently_running": {
    "id": 42, "title": "", "current_episode": 3, "total_episodes": 12,
    "episode_progress": {"percent": 57, "phase": "download", "speed": "2.1x", "bandwidth": "8.4 MB/s", "active": true},
    "overall_progress_percent": 29
  }
}

Internal API (selection)

All internal endpoints require a logged-in session; POST/PUT/DELETE require Content-Type: application/json. Entries marked (admin) additionally require the admin role.

Search & browse

Method Path Description
GET /api/search/sources Every content source a keyword search can currently reach: built-ins plus the ones installed modules registered (search.register_search_source), each with {id, label, adult, thirdparty, enabled, css_class, media_types}, plus the user's saved order and hide_disabled_in_search. This is the list the WebUI fans a search out to, chips under the search field, and lists in Settings → Sources -- so a module source needs no frontend change. 18+ sources are omitted entirely for an age-limited session. media_types is a list of "movies"/"series"/"adult" -- what a module declared on register_search_source(..., media_types=…), or web/source_policy.py's BUILTIN_SOURCE_MEDIA_TYPES for a built-in, defaulting to both films and series. A lookup that already knows what it wants (the Seerr page's "find streams" search) uses it to skip sources that cannot have it.
POST /api/search {keyword, site} -- site is any id from /api/search/sources (built-in: aniworld, sto, filmpalast, megakino, hanime; or a module's own). An 18+ source answers 403 age_limited for a limited session.
GET /api/series / /api/seasons / /api/episodes Series/season/episode data for a URL. /api/episodes additionally answers with locations: where this season's already-downloaded files sit, as {root_label, folder, episodes} per folder, biggest first. root_label is the custom path's name (empty = the global download folder); the absolute path is only included for admins.
GET /api/providers Available hosts/languages of an episode, as {language_label: [hoster, ...]}. Built from the episode's provider_data: an (Audio, Subtitles) tuple key is translated into a label through the language maps, a plain string key is taken as the label it already is — so a module may write {"German Dub": {"VOE": url}} without touching the enums.
GET /api/new-animes, /api/popular-animes, /api/new-series, /api/popular-series, /api/new-movies, /api/random Browse lists
GET /api/home-feed?adult=0|1&limit=30&pool=0|1 The whole new home page in one answer: rows (new/popular/movies, deduplicated across all three), the source list with its enabled/error state, and which sources failed upstream. Includes sources registered by modules. With pool=1 the server builds a reserve pool of up to limit*4 cards (hard ceiling 150) instead of exactly limit, and the answer carries a top-level limit field saying how many of them the client should show. Without it the behaviour is unchanged — the Start Page settings preview relies on that.
GET /api/home-feed/personal The personal home rows for the current user: continue, watchlist, library, upcoming. Every one degrades to an empty list instead of an error.
GET /api/home-panels The home page's button bar: one entry per panel the current account may see, each with a count badge. Admin-only panels are omitted for non-admins. Includes panels registered by modules.
GET /api/home-panel/<id> The body of one panel (stats, list, link). 403 for an admin-only panel, 404 for an unknown one; a panel that fails answers 200 with error: true.
GET /api/home-feed/sources Every source the feed knows (built-in + module-registered) and the effective row layout, without fetching anything. Feeds the Start Page settings.
GET /api/home-feed/row/<row>?adult=0|1&limit=30&pool=0|1 One discovery row (new, popular, movies) on its own. What the home page actually uses: one slow source can no longer hold up the whole page, and rows below the fold are only fetched once they are scrolled to. Cross-row deduplication moves to the client. Takes the same pool=1 as /api/home-feed, and the home page passes it: deselecting a source in the Sources dropdown then no longer shortens the row — the cards behind the cut move up. The home page also resolves the CineInfo/TMDB metadata for the held-back cards in background batches, so a filter change promotes finished cards rather than blank ones.
GET /api/mediaplayer/users The configured Jellyfin/Plex server's user list, for the profile picker. Names only. {configured, server, users, linked}.
GET /api/mediaplayer/image?path=… Artwork proxy for the media server, so the browser never needs its address or token. Server-relative paths only.
GET /api/home/wrapped?period=YYYY-MM The monthly recap card: watched (from the linked Jellyfin/Plex user, absent when none is linked) and downloaded (always MediaForge's own history).
GET /api/home/onboarding What this instance still needs (sources, TMDB key, library scan, modules, media-server link). Admin-only steps are omitted for a normal account.
GET /api/home/suggest?q=… Instant search suggestions, grouped into library and watchlist. Local lookups only — no provider scrape per keystroke.
POST /api/home/mode Switch the active home mode: {mode, max_fsk, pin}. Lowering the age ceiling is always allowed; raising it needs the kids-mode PIN when one is set. The ceiling itself is enforced server-side in the feed, never in the browser.

Queue

Method Path Description
POST /api/download Create a download job
GET /api/queue Queue incl. progress
POST /api/queue/pause / /api/queue/resume Global pause
POST /api/queue/<id>/cancel / restart / skip-episode / retry-episode / move Actions
DELETE /api/queue/<id> / /api/queue/completed Delete an entry / all finished entries
POST /api/autosync/<id>/dry-run What the job would do right now: episodes online/local, what would be queued per language, what was skipped. Writes nothing — not the queue, not the job's own bookkeeping, not even on failure
POST /api/queue/bulk One action (cancel/remove/retry/top/bottom) on up to 500 ids; every id is reported individually in succeeded/failed

AutoSync

Method Path Description
GET/POST /api/autosync List / create jobs (create: admin); body incl. language, provider, custom_path_id, episode_filter (JSON), movie_custom_path_id
POST /api/autosync/site-search Resolve a title to candidate series on AniWorld/serienstream.to (used by the Library "Add to Auto-Sync"); body {title}, returns results[] with site, site_label, title, url, score
PUT/DELETE /api/autosync/<id> Update (incl. episode_filter, movie_custom_path_id, group_name) / delete (admin)
POST /api/autosync/<id>/sync, /api/autosync/sync-all Sync immediately
GET/POST /api/autosync/export, /api/autosync/import, /api/autosync/batch Backup / restore / bulk add. batch actions: enable, disable, set_path, set_group (+group_name), remove_group, delete
POST /api/autosync/group/rename Rename a group; body {old, new} — sets group_name of all own jobs (admin: all) from old to new

Calendar & download history

Method Path Description
GET /api/calendar Aggregated calendar events for the user (AutoSync + optional Seerr/Library overlays). Returns events[], a watcher status block (active, is_scanning, last_sync) and meta (seerr_active, seerr_count). Requires the calendar to be enabled + a TMDB key.
GET /api/history Download history; query: ?search=, ?status=all|completed|failed|cancelled|skipped, ?source=all|manual|autosync|seerr, ?range=all|1d|7d|30d, ?limit= (max 200), ?offset=. Users see their own entries, admins all. Returns entries[] + total.
GET /api/history/<id> Single history entry (403 if not yours and not admin)
GET /api/history/export Export the filtered view; ?format=csv|json plus the same filters as /api/history
POST /api/history/<id>/retry Re-queue a failed/cancelled episode
DELETE /api/history/<id> Delete a single entry
POST /api/history/delete Delete multiple entries (body {ids: [...]})
POST /api/history/clear Clear the history; body may include status/source/range/search to delete only the filtered subset (otherwise all; own entries, admins all)

Settings (admin)

Method Path Description
GET/PUT /api/settings General settings. Includes default_ui_toggles: the instance default for the eight design toggles, a comma-separated list of the ui_* preference keys that should start on (ui_glow_effect, ui_header_color, ui_header_color_help, ui_skeleton_loader, ui_choose_border, ui_active_download_glow, ui_click_effect, ui_icon_move). Edited under Settings → Design → "Default appearance for new accounts" → Advanced Settings and surfaced to templates as appearance_defaults.ui. Absent (null) is not the same as empty: null means no admin ever configured this and keeps the old per-browser localStorage fallback, an empty string means "explicitly all off".
GET/PUT /api/settings/sso, /api/settings/cineinfo, /api/settings/mediaplayer, /api/settings/mediascan, /api/settings/seerr, /api/settings/crunchyroll, /api/settings/dns Sub-areas
GET/POST /api/settings/api-key, /api/settings/api-key/regenerate External API key
GET/POST /api/encoding/settings, /api/encoding/detect-hw Encoding
GET/POST /api/upscale/settings, … Upscaling (reading settings is allowed for everyone)

Crunchyroll

Method Path Description
GET/PUT /api/settings/crunchyroll Load/save settings (password is write-only; clear_password: true forgets it)
POST /api/settings/crunchyroll/test Validate login → {ok, mode, profile, premium}
GET /api/settings/crunchyroll/profiles List account profiles for the selector
GET /api/crunchyroll/availability?title=… Whether a title is on Crunchyroll (powers the provider pill; cached 24 h)

Backed by web/crunchyroll_service.py — a thread-safe client cache (6 h re-login TTL, encrypted session-token file, failure-tolerant) on top of the vendored vendor/crunchyroll_api.py client. Anonymous mode needs no account; watchlist/profile and premium checks require a login. The calendar uses get_simulcast_titles() / get_watchlist_titles() (Crunchyroll knows which anime; TMDB supplies the air dates). Video streams are DRM-protected and never downloaded.

Library & player

Method Path Description
GET /api/library / /api/library/status / /api/library/watcher Inventory & scan status
POST /api/library/refresh (admin), /api/library/delete (admin), /api/library/rename (admin), /api/library/move (admin), /api/library/media_info Management
POST /api/stream/start / /api/stream/stop Start/stop an HLS transcode session (library files)
GET /api/stream/<token>/index.m3u8, /api/stream/<token>/segNNN.ts, /api/stream/<token>/status HLS delivery
POST /api/stream/start-source Stream an episode directly from the provider via transcode; body {episode_url, provider, language, start_pos}
POST /api/stream/start-proxy / /api/stream/close-proxy Start/stop a passthrough proxy session for the provider's native HLS (no FFmpeg); returns {token, playlist_url, hls}
GET /api/proxy/<token>/r/<b64url> Proxy a provider playlist (rewritten) or segment/key (streamed); forwards Range. SSRF-guarded (http/https only, private/loopback hosts blocked)
POST/GET /api/progress/save, /api/progress/get, /api/progress/bulk Watch progress (per user)
GET /api/downloaded-folders The folder names the library holds, for the "already downloaded" badges. Additionally carries aliases: a {loose_title_key: folder} map built from web/library_aliases.py, so the browser recognises a folder whose name shares nothing with the card's title. Empty until the background resolver has caught up (and always empty without a TMDB key) — the client then falls back to the plain string comparison.

Syncplay & Web Console

Method Path Description
GET /api/syncplay/config Whether a Syncplay server is configured
POST /api/syncplay/join / /api/syncplay/leave Join/leave a room (server opens a real Syncplay client); body {room, username?}
POST /api/syncplay/control / /api/syncplay/report Send play/pause/seek; report current position (heartbeat)
GET /api/syncplay/poll Poll authoritative state events + room user list (?session_id=&after=)
GET /api/console (admin) Read-only live console output buffer; ?after=<seq> for incremental polling (only when the Web Console is enabled)

Miscellaneous

Method Path Description
GET /api/captcha/<queue_id>/screenshot / status; POST /api/captcha/<queue_id>/click Interactive captcha solving
GET/POST /api/notif/settings, /api/notif/user-settings, /api/notif/admin-settings, /api/notif/test Notifications
GET /api/seerr/requests; POST …/approve, …/decline, …/hide Seerr requests
GET/POST /api/update-check Update check
GET/POST/DELETE /api/favourites Favourites
GET/POST /api/user/preferences Per-account appearance (theme_pack, theme_mode, accent, plus the eight ui_* design toggles) — keys whitelisted server-side, extendable by modules via db.register_ui_pref_key(). The toggles now cascade over three levels like theme and accent do: account (user_ui_prefs ui_*) → instance default (default_ui_toggles) → localStorage mirror
GET /api/themes; PUT /api/themes/active (admin) Installed theme packs / instance default
GET /api/stats Statistics — accepts ?days= (1–365, default 30) for the trend series
GET /api/stats/trends Chart series only for a given ?days= window (no library scan)
GET /api/media/duplicates One page of duplicate groups: ?page=&per_page= (1–100, default 20) &q= + an aggregate summary
GET /api/history/summary Aggregate figures + 30-day series for the active history filters
GET /api/history/facets Providers and languages present in the history, for the filter dropdowns
GET /api/media/incomplete One page of incomplete or ignored series: ?view=incomplete|ignored&page=&per_page=&q=
GET /api/stats/queue, /api/stats/sync, /api/stats/general Individual statistics sections

Health probes (no authentication)

Method Path Description
GET /healthz Liveness. {"status": "ok"} while the process is up
GET /readyz Readiness. 200 when the database answers, 503 when it does not

Both are deliberately login-exempt (Docker HEALTHCHECK, k8s probes, external monitors have no session) and return a single status string and nothing else — no version, no worker names, no error text. The detailed view is admin-gated at /api/ops/workers.

Operations (all admin)

Registered by web/routes/ops.py. Every endpoint below is listed in that module's ADMIN_ONLY_OPS_ENDPOINTS, which app.py folds into _admin_only — importing the set instead of re-typing it is what stops a new endpoint from being registered but unprotected (asserted by tests/test_ops.py).

Method Path Description
GET /api/ops/audit Audit entries; ?q=&category=&severity=&since=&until=&limit=&offset=
GET /api/ops/audit/stats Totals, per-category counts, available categories/severities
GET /api/ops/audit/verify Recompute the hash chain; reports the first mismatching row
GET /api/ops/audit/export CSV of the filtered view
GET/POST /api/ops/groups List / create groups
PUT/DELETE /api/ops/groups/<id> Update / delete a group (built-ins refuse both)
PUT /admin/api/users/<id>/groups Set a user's custom group membership
GET /api/ops/schema Migration status + snapshot list
POST /api/ops/snapshots Create a manual snapshot
GET /api/ops/snapshots/<id>/verify Open read-only and prove it is restorable
POST /api/ops/snapshots/<id>/restore Replace the live database (restart required)
DELETE /api/ops/snapshots/<id> Delete a snapshot
GET /api/ops/workers Worker heartbeats + health roll-up
GET/POST /api/ops/maintenance List (incl. currently active limits) / create a window
PUT/DELETE /api/ops/maintenance/<id> Update / delete a window
GET /api/ops/diagnostics Scrubbed diagnostic bundle (ZIP download)
GET /api/ops/profile/export Settings profile as plain JSON (secret-free, allowlisted)
POST /api/ops/profile/preview Validate an uploaded profile and report what it would change
POST /api/ops/profile/import Apply a profile, optionally limited to keys
GET/POST /api/ops/rules List (incl. field/operator/action catalogue) / create a rule
PUT/DELETE /api/ops/rules/<id> Update / delete a rule
POST /api/ops/rules/test Evaluate the saved rules against a hypothetical context
GET/POST /api/ops/language-profiles List (incl. bindings) / create a profile
PUT/DELETE /api/ops/language-profiles/<id> Update / delete a profile
POST /api/ops/language-profiles/bind Bind or unbind a title (profile_id: null unbinds)

See Operations, Audit Log and Rules & Languages for what these do.

Versioning, scopes and the spec

The specification is generated, not written

GET /api/v1/openapi.json describes every endpoint, its path parameters and the scope it requires. It is built from the routes that are actually registered, so it cannot drift from the code — a specification maintained separately from the implementation is a specification that is wrong.

It is readable without a key, on purpose: a client cannot know which scopes to ask for until it can read the spec, and the document describes shapes rather than data.

Deprecation policy

A v1 endpoint is never removed and never changes shape in a breaking way. Anything that has to change appears under a new path. Anything on its way out answers with a Deprecation header for at least one minor release before it stops being documented.

Scoped API keys

The original external_api_key is all-or-nothing: whatever can read /api/v1/status can also read the whole library and the download history. A Home Assistant dashboard and a script that manages downloads got identical credentials, and revoking one meant revoking both.

A scoped key (Settings → API) carries only the scopes you pick:

Scope Opens
status:read /api/v1/status
queue:read queue, single queue item, upscale queue
library:read library, series, movies, mediascan
history:read download history
stats:read statistics
autosync:read Auto-Sync jobs
uptime:read uptime monitor
update:read update status

Three properties matter:

  • Hashed. Only a SHA-256 is stored. The plaintext is shown once, at creation, and cannot be recovered — a stolen database must not hand over working credentials. This is why the old key could not simply grow a scopes column: it is stored in clear text by design, because the settings page displays it.
  • Individually revocable and expirable. An unparseable expiry counts as expired rather than being ignored; the alternative lets a malformed date make a key eternal.
  • Unknown scopes are dropped on save. A scope nothing checks looks granted and does nothing.

An authenticated key without the required scope gets 403, not 401. The credential was fine, the permission was not, and conflating the two sends people to regenerate a key that was never the problem.

The legacy key keeps working and grants every scope. Breaking every existing integration to introduce scopes would be a poor trade, so scoped keys are an upgrade, not an imposition.

Fixed along the way: the login-exemption list named seven of the thirteen v1 endpoints by hand. The other six — autosync, uptime, update-status, mediascan, upscale, history — were wrapped in login_required, which answers /api/ paths with a bare 401. A caller with a perfectly valid API key was told its key was wrong, and the obvious next step (regenerating it) changed nothing. The list is now derived from the endpoint→scope map, so a new v1 endpoint cannot be born broken the same way.

Clone this wiki locally