Technical Details - MiCat-S/Mi302 GitHub Wiki
This page is for people who want to know how Mi302 works inside or want to contribute: the request flow, path matching, the Emby endpoints that are implemented, the admin API, the code layout, running the tests, and the projects Mi302 builds on.
Mi302 is a FastAPI application run by uvicorn. Its data lives in SQLite (data/library.db).
-
Scan:
.strmfiles, regular video files, nfo files and images in the library folders are written to the database, see Library and Scanning. -
Browse: players call the Emby API (
/Users/{id}/Views,/Items, …) and Mi302 builds EmbyBaseItemDtoobjects from the database. -
Playback info:
POST /Items/{id}/PlaybackInfo. strm items come back withProtocol=HttpandIsRemote=true, direct play only (transcoding off), and aDirectStreamUrlpointing to/videos/{id}/stream.{container}. -
Stream: the player requests
/Videos/{id}/stream,/Videos/{id}/original.xxxor/Items/{id}/Download:- strm items: Mi302 reads the strm and applies the path rules. With a pickcode it gets a 115 direct link using the player's User-Agent; otherwise it can first follow the upstream redirects. It answers
302 Location: <real URL>. - Regular video files are served directly, with Range support.
- strm items: Mi302 reads the strm and applies the path rules. With a pickcode it gets a 115 direct link using the player's User-Agent; otherwise it can first follow the upstream redirects. It answers
-
Progress:
/Sessions/Playing…stores the resume position and watched state, and feeds intro/credits learning, see Intro and Credits.
See Playback for the details. Mi302 reports itself as Emby Server 4.8.11.0.
Every incoming HTTP request has its path normalised before routing:
- ASCII letters in the path are lower-cased and other characters are left alone (the
Éin/Persons/Émiliestays), so paths are case-insensitive. - A leading
/embyor/mediabrowseris removed, so both prefixed and bare paths work. - A trailing
/is removed. - Query parameter names are case-insensitive (
ParentIdandparentidboth work).
Also:
- Error responses are plain text, as in Emby.
- CORS is open to all origins.
- FastAPI's generated API docs are at
/api-docs.
The token is looked for in this order:
- The header
X-Emby-TokenorX-MediaBrowser-Token. -
Token="…"inside the headerX-Emby-Authorization(orAuthorization), in the formMediaBrowser Client="…", Device="…", DeviceId="…", Version="…", Token="…". - The query parameter
api_key,X-Emby-Token,ApiKeyorX-MediaBrowser-Token.
Tokens are issued by POST /Users/AuthenticateByName. When the same user logs in again from the same device (DeviceId), the old token is revoked. API keys (from api_keys in the config file, or created in the web admin page) act as an admin (the first admin by name) and are meant for programs such as MoviePilot; they cannot call /web/api/… or /p115/….
When the path names a user (/Users/{id}/…) or the request has a UserId parameter, watch history, resume points and favourites are read and written for that user. A regular user can only name themselves and gets 403 otherwise; admins and API keys can name another user and get 404 if that user does not exist. The id is case-insensitive, and the GUID form with dashes is accepted. Without one, the logged-in user is used.
Paths are written in Emby's usual casing but matched case-insensitively. Everything not marked "no login" needs a token.
| Endpoint | Notes |
|---|---|
GET /System/Info/Public |
Server name, version and id; no login |
GET /System/Info |
Full server info |
GET, POST /System/Ping |
Returns Emby Server; no login |
GET /System/Endpoint |
Always reports "local network"; no login |
GET /Branding/Configuration |
Empty branding; no login |
GET /Users/Public |
Users for the login screen (empty when server.public_users is off); no login |
POST /Users/AuthenticateByName |
Log in with JSON or a form; the password field is Pw or Password; no login |
GET /Users |
All users; admin only |
GET /Users/Query |
The same list as {Items, TotalRecordCount} (MoviePilot counts users with it); admin only |
GET /Users/{id} |
A user; only yourself unless you are an admin |
POST /Sessions/Logout |
Log out and revoke the token |
POST /Sessions/Capabilities, /Sessions/Capabilities/Full
|
Accepted and ignored |
GET /Sessions |
Empty list |
GET, POST /DisplayPreferences/{id} |
Fixed defaults, nothing is stored; no login |
GET /Users/{id}/GroupingOptions, /Plugins, /Localization/{kind}
|
Empty lists; no login |
GET /Items/{id}/RemoteImages |
Always empty (Mi302 does not look up remote images; MoviePilot then uses /Items/{id}/Images/{type}); no login |
WebSocket /embywebsocket
|
Only answers KeepAlive |
| Endpoint | Notes |
|---|---|
GET /Users/{id}/Views, /Library/MediaFolders
|
Library list |
GET /Library/VirtualFolders, /Library/VirtualFolders/Query, /Library/SelectableMediaFolders
|
Libraries and their folders (used by MoviePilot) |
POST /Library/Refresh |
Rescan everything; admin only |
POST /Library/Media/Updated |
Scan only the paths in the notification; admin only |
POST /Items/{id}/Refresh |
Rescan only the series or movie containing this item; admin only |
GET /Users/{id}/Items, /Items
|
Item queries, parameters below |
GET /Users/{id}/Items/{itemId}, /Items/{id}
|
Full details of one item, including MediaSources, People and Chapters; returns a person for a person id |
GET /Users/{id}/Items/Latest |
Recently added (Limit, ParentId, IncludeItemTypes; episodes are grouped by series) |
GET /Users/{id}/Items/Resume |
Continue watching |
GET /Items/{id}/Ancestors |
Parent items |
GET /Items/Counts |
Numbers of movies, series and episodes |
GET /Shows/{id}/Seasons |
Seasons |
GET /Shows/{id}/Episodes |
Episodes (SeasonId, Season, StartIndex, Limit, Fields) |
GET /Shows/NextUp |
Next up (SeriesId, Limit) |
GET /Genres |
All genres |
GET /Persons, /Persons/{name}
|
Search people, look up a person by name (original or Chinese) |
GET /Persons/{name}/Images/{type} |
Person photo, a 302 to the URL from the nfo; no login |
POST /Users/{id}/PlayedItems/{itemId}; DELETE (or POST …/Delete) |
Mark watched or unwatched; for a series or season, all its episodes |
POST /Users/{id}/FavoriteItems/{itemId}; DELETE (or POST …/Delete) |
Add or remove a favourite |
Query parameters for /Items: ParentId, Recursive, IncludeItemTypes (including Person also searches people), ExcludeItemTypes, Ids, PersonIds, Person, IsFolder, SearchTerm, NameStartsWith, Years, Genres, Filters (IsPlayed, IsUnplayed, IsFavorite, IsResumable), IsPlayed, IsFavorite, SortBy, SortOrder, StartIndex, Limit, Fields (MediaSources, People, Chapters), UserId.
Negative or unreadable StartIndex and Limit values are ignored. Without Limit, /Items and /Shows/{id}/Episodes return everything, as Emby does. Latest, resume and next up default to 20 items and people search to 50, with at most 500 for these. Limit=0 returns only the total count.
SortBy accepts SortName, Name, DateCreated, DateLastContentAdded, PremiereDate, ProductionYear, CommunityRating, CriticRating, Runtime, Random, DatePlayed, PlayCount, IndexNumber, ParentIndexNumber and AiredEpisodeOrder.
Endpoints that return empty results so players do not fail: /Users/{id}/Items/{id}/Intros, /Items/{id}/Similar, /Movies/{id}/Similar, /Shows/{id}/Similar, /Videos/{id}/AdditionalParts, /Items/{id}/CriticReviews, /Users/{id}/Items/{id}/LocalTrailers, /Users/{id}/Items/{id}/SpecialFeatures, /Items/{id}/SpecialFeatures, /Items/{id}/ThemeMedia.
| Endpoint | Notes |
|---|---|
GET /Items/{id}/Images/{type}[/{index}] |
Read an image; type is Primary, Backdrop, Thumb, Logo or Art; no login |
POST /Items/{id}/Images/{type}[/{index}] |
Upload an image (base64 text or the raw image, up to 30 MB), stored in data/images/; admin only |
DELETE /Items/{id}/Images/{type}[/{index}] |
Delete the uploaded image and go back to the folder image; admin only |
GET /Users/{id}/Images/{type} |
User avatar; always 404 |
| Endpoint | Notes |
|---|---|
GET, POST /Items/{id}/PlaybackInfo |
Media source |
GET, HEAD /Videos/{id}/{name} |
Stream (stream, stream.mkv, original.mkv, …); needs login when redirect.require_auth is on |
GET, HEAD /Items/{id}/Download, /Items/{id}/File
|
Download; always needs login, returns 403 when server.allow_download is off |
POST /Sessions/Playing, /Sessions/Playing/Progress, /Sessions/Playing/Stopped
|
Playback progress |
POST /Sessions/Playing/Ping |
Accepted and ignored |
POST /Users/{id}/PlayingItems/{itemId}, …/Progress; DELETE (or POST …/Delete) |
Older progress reporting API |
| Endpoint | Notes |
|---|---|
GET /Episode/{id}/IntroTimestamps (also /v1), GET /Episode/{id}/Timestamps
|
Intro and credits in the Jellyfin Intro Skipper plugin format |
GET /MediaSegments/{id} |
Jellyfin 10.10 media segments |
GET, HEAD /d/{pickcode}[.ext][/name] |
Short links used in Mi302's own strm files, a 302 to the 115 direct link; no login |
GET, HEAD /p115/redirect?pickcode=…, /api/v1/plugin/p115strmhelper/redirect_url?pickcode=…
|
Compatibility with other tools' strm files; no login |
OPTIONS, PROPFIND, GET, HEAD /dav/{115 path} |
Read-only WebDAV (webdav.enabled): PROPFIND lists folders (Depth at most 1), GET on a file redirects (302) to the 115 direct link, HEAD returns only the size; HTTP Basic sign-in required, write methods return 405 |
The API behind the web admin page /web. 115 file and folder IDs have 19 digits, more than JavaScript can represent exactly, so responses always carry them as strings; requests accept strings or numbers. Apart from /web/api/setup, it needs the token of a logged-in admin account (sent in the X-Emby-Token header); API keys are rejected. Requests and responses are JSON.
| Path | Purpose |
|---|---|
GET, POST /web/api/setup |
Whether the first admin still has to be created; create it (only while no account exists) |
GET, PUT /web/api/settings |
Read and save the web settings (written to the config file) |
GET, POST /web/api/scan |
Scan progress and item counts per library; start a scan with {"library": name}, {"path": path}, or neither for everything; when no scan is running, last describes the previous one (scope, items processed, whether it was stopped) |
GET /web/api/libraries/userdata |
How many watch-history records a library (name) has and for how many users (for the delete-library confirmation) |
GET, POST /web/api/users; PUT, DELETE /web/api/users/{id}
|
Users |
GET /web/api/browse, /web/api/115/browse
|
Pick a folder on the server, pick a 115 directory |
GET /web/api/115/offline, POST /web/api/115/offline, POST /web/api/115/offline/delete, POST /web/api/115/offline/clear
|
115 cloud download: task list (page, with how many can still be added this month); add tasks (urls one per line, folder the 115 path to save to, empty for 115's default); delete tasks (hashes, files also deletes the downloaded files); clear finished or failed records (what: done/failed) |
POST /web/api/115/offline/retry |
Add a failed cloud-download task again (hash, url, folder_id): removes that failed record first (not the files), then adds it back to the original folder |
POST /web/api/115/share/read, POST /web/api/115/share/start, GET /web/api/115/share/status, POST /web/api/115/share/stop
|
Transfer 115 shares (needs the cookie login): read shares (text, one link per line; returns each share's title, size and top-level items, with 115's own message in error when it cannot be read); transfer in the background (shares: [{code, receive_code, title, ids}], ids as strings; folder the 115 folder to save into; target: path organises into target_path, empty does not organise yet); progress; stop (after the share in hand) |
POST /web/api/115/rapid/read, POST /web/api/115/rapid/start, GET /web/api/115/rapid/status, POST /web/api/115/rapid/stop
|
Rapid upload from Aliyun Drive to 115 (needs the 115 cookie login and an Aliyun Drive login): read (source an Aliyun Drive path, media_only videos and subtitles only; returns the number of videos and subtitles, total size, files without a SHA1, files over 115's single-file limit, the first 200 files); upload in the background (source, folder the 115 folder to save into, target: path organises into target_path, empty does not organise yet, media_only); progress and the result of each file; stop (after the file in hand) |
GET /web/api/aliyun/status, POST /web/api/aliyun/token, POST /web/api/aliyun/logout, GET /web/api/aliyun/dirs
|
Aliyun Drive: account status; log in (refresh_token; the previous one is kept when it fails); log out; folder picker (path: subfolders and file count of that level; empty gives 資源庫 and 備份盤) |
GET /web/api/115/recyclebin, POST /web/api/115/recyclebin/clean
|
One page of the 115 recycle bin (offset, limit); emptying it (permanent; needs {"confirm": "清空"}, plus the password security key on the cookie channel) |
GET /web/api/deleted |
What Mi302 sent to the 115 recycle bin, newest first (source filters by source, offset, limit); returns items, total, sources
|
GET /web/api/115/files |
Browse 115: subfolders and files of a folder (cid and path, or only path), with what the library made of each video (lib); files come 1000 at a time, offset for the next batch |
POST /web/api/115/delete |
Delete what is ticked in Browse 115 (parent is the folder being viewed, ids): the folder is listed again and only items really in it are moved to the 115 recycle bin; for sync folders the local strm files and library entries are removed too |
GET /web/api/115/organize, POST /web/api/115/organize/check, GET /web/api/115/organize/job
|
Organise 115: folders to organise (name differs from MoviePilot's answer, or wrong episode numbers; q, kind as series/movie/episodes/held (held back), offset, limit; folders added from Browse 115 are in pinned; held on each entry says whether it is held back), with the background check progress job and the number not asked yet unchecked; ask MoviePilot in the background (refresh asks again for everything); progress of the reorganise job |
POST /web/api/115/organize/folder, DELETE /web/api/115/organize/folder/{id}
|
Pin a folder picked in Browse 115 (cid, path) at the top of the list; unpin it |
POST /web/api/115/organize/preview, POST /web/api/115/organize/recommend, POST /web/api/115/organize/execute
|
Preview one folder (id; per-part settings parts: {part: {type, tmdbid, season, format}}; target as parent/auto/path plus target_path), returning a token; episode format for a part (id, part, asking MoviePilot first); run previews by token (tokens; cleanup moves source folders with no videos left to the recycle bin) |
POST /web/api/115/organize/all, POST /web/api/115/organize/all/stop, GET /web/api/115/organize/all
|
Organise all: everything in the list matching q and kind (plus entries pinned from Browse 115) is previewed one by one in the background; entries whose preview has an empty review are organised, the rest are skipped with the reason (target, target_path, cleanup; entries held back and those that would send more than max_videos videos at once are left out, default 300, 0 = no limit; if MoviePilot cannot be reached it waits for it, up to 15 minutes); stop after the current entry; progress with the skipped and failed entries (at most 2000 are kept; results_total counts all of them and truncated says whether some were not kept; progress also appears as batch in GET /web/api/115/organize/job) |
POST /web/api/115/organize/hold |
Hold back (id, hold): skipped by Organise all and moved to the end of the list; returns the entry |
GET /web/api/115/organize/episodes, POST /web/api/115/organize/delete
|
Deleting: every episode of a show and its folder (series; wrong numbers marked problem); delete ticked episodes (series_id, file_ids, remove_folder) or one list entry entirely (id), all to the 115 recycle bin, removing local strm files and library entries; {"ids": [...]} deletes ticked entries one by one in the background, progress in deleting of organize/job
|
GET /web/api/server, POST /web/api/server/check, POST /web/api/server/update, POST /web/api/server/restart
|
Version and the last update check (boot differs on every start); check now; update to the latest version (runs in the background, progress in job); restart |
GET /web/api/libraries/suggest?path= |
Bulk add libraries: list the subfolders with a suggested type and the reason, the video count (complete when fully counted), whether a library already uses the folder (used), and whether to tick it by default; partial means some folders were not fully read |
POST /web/api/moviepilot/test, GET /web/api/moviepilot/status, POST /web/api/moviepilot/scrape
|
MoviePilot connection test, status, scrape items missing metadata |
GET /web/api/moviepilot/library-dirs |
MoviePilot library folders whose storage is 115 (dirs: name, path, type; enabled says whether MoviePilot is set up), for Organise to (整理到) when transferring shares; dirs is empty with the reason in error when MoviePilot is not set up or cannot be read |
GET /web/api/series, POST /web/api/moviepilot/fill
|
Series and episode-gap list; fill missing episodes |
GET /web/api/moviepilot/subscriptions, POST /web/api/moviepilot/subscriptions/clear, GET /web/api/moviepilot/subscriptions/unsubscribed
|
How many subscriptions MoviePilot has; cancel them all ({"confirm": "取消訂閱"}); the subscription lists saved before the last 5 cancellations (backups) |
GET /web/api/intro/status, POST /web/api/intro/clear
|
Intro and credits ({"season_id": id} clears one season's learned records) |
GET /web/api/intro/seasons?q=, PUT /web/api/intro/seasons/{season id}
|
Season list and search for intro and credits; manual settings for a season (all_seasons applies them to the whole series) |
GET /web/api/people/status, POST /web/api/people/resolve
|
Chinese names for cast and crew |
GET, POST /web/api/backups; GET /web/api/backups/{name}
|
List backups, back up now, download a backup |
GET /web/api/mediainfo/status, GET /web/api/mediainfo/titles, POST /web/api/mediainfo/probe
|
Media info: status; titles still missing media info (filterable, ordered); probing (optional filters, limit, ids) |
POST /web/api/mediainfo/probe/limit, POST /web/api/mediainfo/stop
|
Change how many the running batch probes ({"limit": N}); stop the running batch |
GET /web/api/logs, /web/api/logs/download
|
Logs, download the log file |
GET, POST /web/api/apikeys; DELETE /web/api/apikeys/{key}
|
API keys |
GET /p115/status |
115 account status |
POST /p115/qrcode, GET /p115/qrcode/status, POST /p115/cookies, POST /p115/logout
|
QR-code login, paste a cookie, log out |
POST /p115/open/qrcode, GET /p115/open/qrcode/status, POST /p115/open/logout
|
115 open platform authorisation |
POST /p115/strm/sync?mode=incremental (full sync without mode), GET /p115/strm/status, PUT /p115/strm/tasks
|
Sync, sync status, sync tasks |
POST /p115/strm/rewrite, POST /p115/strm/rewrite/stop
|
Rewrite existing strm files to the current server address (local files only); stop it (what is rewritten stays) |
GET /web/api/dupes, POST /web/api/dupes/scan, GET /web/api/dupes/groups, GET /web/api/dupes/shows, POST /web/api/dupes/delete, GET /web/api/dupes/log
|
115 duplicate files: status, find (paths), grouped list, delete (overrides; sha1 + size for one group; dry_run to only count); kind=versions for versions (grp for one group, show for one series or movie, use_suggestions to follow the suggestion; the web list uses GET /web/api/dupes/shows?kind=versions, one row per series, and expands one with groups?kind=versions&show=…; identical files work the same way with shows?kind=exact and groups?show=…, and deleting with show covers only that series), deletion log; kind=big for big files (filter with min_size in bytes and type as movie/episode/none; when deleting, use_suggestions means everything that matches, and nothing has to be kept); POST /web/api/dupes/prefer sets which resolution the versions suggestion keeps (prefer is 1080, 2160 or highest) |
POST /web/api/scan/stop, POST /p115/strm/stop, POST /web/api/dupes/stop, POST /web/api/moviepilot/stop, POST /web/api/115/organize/check/stop, POST /web/api/115/organize/execute/stop, POST /web/api/115/organize/delete/stop
|
Stop a scan, a 115 sync, a duplicate scan or delete, a MoviePilot scrape, fill or unsubscribe (what is scrape, fill or unsubscribe), or an Organise 115 check, run or delete of ticked entries; each stops at a safe point and leaves the rest for next time; returns stopped (false if nothing was running) |
GET /web serves the admin page itself, and /web/115 redirects to /web#115.
Each module under embyserver/:
| Module | Contents |
|---|---|
__main__.py |
Command-line entry: python -m embyserver [-c config.yaml] [--scan] [--sync-115 [incremental]] [--reset-password USER NEW_PASSWORD]
|
app.py |
Builds the FastAPI app, normalises paths, starts background jobs |
auth.py |
Users, tokens, Emby authorization headers |
backup.py |
Daily backup of the database and config file |
browse115.py |
Browsing 115: folder listings, videos matched to the library |
config.py |
Config data classes and loading |
config_file.py |
Writes the settings back to config.yaml
|
settings.py |
Settings editable in the web page: validation, applying, keeping the config file in step |
db.py |
SQLite tables and access |
dupes.py |
Duplicate files on 115: identical files, different versions, which one to keep; big files |
dto.py |
Turns database rows into Emby BaseItemDto, UserDto and MediaSourceInfo
|
filetypes.py |
Shared file extension lists |
organize115.py |
Organise 115: asks MoviePilot what each folder would be called (answers stored in organize_checks) and previews whole folders with it |
http_util.py |
httpx client for external services that turns connection errors into readable reasons |
intro.py |
Learns intros and credits from playback |
library_suggest.py |
Bulk-adding libraries: guesses whether subfolders hold movies or shows |
logs.py |
Logging to the console, the log file and memory, with credentials masked |
mediainfo.py |
Media info: ffprobe output and X-mediainfo.json to Emby format |
prober.py |
Probes the videos behind strm files with ffprobe |
moviepilot.py |
Scraping and filling missing episodes through MoviePilot |
moviepilot-plugin/ |
MoviePilot V3 plugin Mi302 Organizer (package.v3.json, plugins.v3/mi302organizer/): works out names with MoviePilot's own rules (naming.py) and renames in bulk from Mi302's list with MoviePilot's 115 authorisation; see MoviePilot
|
p115.py |
115: QR-code login, direct links, directory listing, life events (115's activity log), circuit breaker |
p115_open.py |
115 open platform channel |
probe_select.py |
Media info: picking titles to probe (filters, order) |
people.py |
Cast and crew, Chinese names, Chinese genres |
redirect.py |
Resolves the real URL behind a strm, path rules, redirect cache |
reorganize.py |
Running reorganisations (sending previews to MoviePilot, cleaning up emptied folders, the incremental sync afterwards) and deletion; episode-format templates (fallback when MoviePilot cannot recommend one) |
scanner.py |
Library scanning |
share115.py |
Transferring 115 shares: reads shares, saves them into a temporary subfolder, waits for 115 to finish, then adds it to Organise 115 for MoviePilot |
rapid115.py |
Rapid upload from Aliyun Drive to 115: 115's upload initialisation (encryption, second check), batch runs, adding the result to Organise 115 |
aliyun.py |
Aliyun Drive open platform: login and token refresh, listing, download links, reading a byte range |
strm_sync.py |
Creates strm files from 115 and downloads metadata |
textutil.py |
Pinyin sorting, pinyin search, Traditional/Simplified conversion |
updater.py |
Checking for updates, updating and restarting from the web page |
routes/system.py |
System, Users, Sessions and similar endpoints |
routes/items.py |
Libraries, item queries, user data, images |
routes/playback.py |
PlaybackInfo, streaming, progress reports, intro endpoints |
routes/p115.py |
Admin API for 115 login and sync, pickcode short links |
routes/web/ |
The web admin page and its API, split by page into setup, scan, intro, moviepilot, organize, p115 and server
|
routes/common.py |
Small helpers shared by the routes |
web/admin.html |
The web admin page (one file with HTML, CSS and JavaScript) |
Tables in library.db: meta (server id, 115 login state, sync progress and so on), users, tokens, items, user_data (watch history, favourites), p115_index (115 file id to local path), media_info, people, person_names, intro_obs, mp_no_image.
Python 3.10 or later is required. In a virtual environment:
pip install -r requirements.txt pytest ruff
python -m pytest
ruff check embyserver testsThe tests also check the admin page's JavaScript syntax with node --check (skipped without node) and the documentation: wiki links and anchors resolve, every page is in the sidebar, and the config reference in all three languages covers every key.
After changing install.sh:
shellcheck install.sh-
DDSRem-Dev/MoviePilot-Plugins: the 302 playback flow follows the
embyreverseproxyplugin, except that Mi302 needs no real Emby server behind it and implements the Emby API itself; the 115 QR-code login and direct-link flow followsp115strmhelper. -
StrmAssistant (Emby 神醫助手):
X-mediainfo.jsonuses its "media info persistence" format, and intro/credits learning follows its playback-behaviour intro detection. -
xiao-vvv/emby-mediainfo (MIT licence): the ffprobe-to-Emby field mapping, the probing rate limits and the 115 circuit-breaker rules are adapted from it; its copyright notice is kept in
embyserver/mediainfo.py.