Integration API
SmartLists exposes a REST API on your Jellyfin server. It is the same API the plugin's own configuration pages use, and it is the supported way for external scripts, automations and other Jellyfin plugins to create, refresh and inspect smart lists.
Typical uses:
- Provisioning lists from a script or config-management tool instead of clicking through the UI
- Triggering a refresh from an external trigger (a download client finishing, a cron job, a webhook)
- Reading refresh progress and history into a dashboard
- Another Jellyfin plugin creating lists on the user's behalf
Stability
What is and isn't covered
The endpoints listed under Endpoint reference are the plugin's public integration contract as of the 12.0.x release line. They will not change shape without a note in the release changelog.
Everything else is UI plumbing and is not part of the contract. That includes
/Plugins/SmartLists/currentuser, every /Plugins/SmartLists/User/* route, the
/Plugins/SmartLists/backups* routes, /Plugins/SmartLists/Timer/*, the image upload and
delete routes, and /Plugins/SmartLists/Pages/UserPlaylists. They exist to serve the admin and
user configuration pages, they change whenever those pages change, and they may be removed or
reshaped in any release. Don't build against them.
To check which plugin version a server is running, call Jellyfin's own GET /Plugins endpoint and
look for the SmartLists entry.
Authentication
Every endpoint requires authentication. The simplest credential for an integration is a Jellyfin API key.
Creating an API key
- In Jellyfin, go to Dashboard → Advanced → API Keys
- Click + and give the key a name (e.g.
smartlists-automation) - Copy the generated key
Sending the key
Jellyfin expects the key in an Authorization header:
The quotes around the token are part of the header value.
?api_key= no longer works
The legacy ?api_key=YOUR_KEY query parameter returns 401 Unauthorized on Jellyfin 12.
Use the Authorization header.
Elevation and user context
All endpoints in the reference below are declared [Authorize(Policy = "RequiresElevation")] —
they require an administrator. An API key satisfies that policy, so an API key is enough for
everything documented here.
There is one important difference between an API key and a logged-in admin's access token: an API
key carries no user identity. Jellyfin issues no Jellyfin-UserId claim for it, so the plugin
cannot infer "the current user" from the request.
Collections need an explicit owner when you use an API key
A smart collection has an owner whose watch state, favourites and play counts are used to evaluate user-specific rules. With a logged-in admin token the plugin defaults that owner to the caller. With an API key there is no caller to default to.
Creating: send UserId (preferred) or CreatedByUserId in the request body.
There is no existing owner to fall back on, so one of them is required.
Updating: both are optional. Omitting UserId means "leave ownership alone" — the stored
owner is kept. Send UserId only when you actually intend to reassign the collection. An owner
is required again only if the stored one is missing or points at a deleted user.
When no owner can be determined you get:
{
"title": "Validation Error",
"status": 400,
"detail": "Collection owner could not be determined. Supply 'UserId' (or 'CreatedByUserId') in the request body when calling with an API key."
}
Get valid user IDs from GET /Plugins/SmartLists/users.
Smart playlists are unaffected — they carry their owners explicitly in UserPlaylists, so
they work identically with either credential.
OpenAPI
Jellyfin publishes a machine-readable schema at:
The SmartLists routes appear in that document alongside the core Jellyfin API, so you can generate a typed client:
openapi-generator-cli generate \
-i http://localhost:8096/api-docs/openapi.json \
-g python \
-o ./jellyfin-client
Fixed in this release
Earlier plugin versions broke the whole document: four multipart upload actions declared their
IFormFile parameter as [FromForm] IFormFile, which Swashbuckle rejects. The schema generator
threw and returned HTTP 500 for /api-docs/openapi.json server-wide, not just for the
plugin's routes. That is fixed. If you get a 500 from that URL, you are on an older build —
upgrade the plugin.
Contributors adding a multipart endpoint: bind the file as a bare IFormFile parameter, never
[FromForm] IFormFile. MVC already binds IFormFile from the multipart body via
BindingSourceMetadataProvider, so the attribute changes nothing at runtime — it only downgrades
the binding source from BindingSource.FormFile to BindingSource.Form, which is the exact
combination the schema generator throws on. Add [Consumes("multipart/form-data")] too, so the
generated schema declares the right request shape.
The generated schema is thin on error bodies (most error statuses are declared without a schema). Treat the Error format section below as authoritative for those.
Endpoint reference
All routes are relative to your Jellyfin server root, e.g.
http://localhost:8096/Plugins/SmartLists.
Lists
| Method | Route | Description |
|---|---|---|
GET |
/Plugins/SmartLists |
All smart lists. Optional ?type=Playlist or ?type=Collection (case-insensitive). |
GET |
/Plugins/SmartLists/{id} |
A single smart list by ID. Looks in playlists first, then collections. |
POST |
/Plugins/SmartLists |
Create a list. Returns 201 with the stored list. Optional ?skipRefresh=true. |
PUT |
/Plugins/SmartLists/{id} |
Replace a list. Returns 200 with the stored list. Optional ?skipRefresh=true. |
DELETE |
/Plugins/SmartLists/{id} |
Delete a list. Returns 204. Optional ?deleteJellyfinList=false. |
Query and body gotchas
?type=is not validated. Any value that is not exactlyPlaylistorCollectionreturns200with an empty array rather than a400.?type=playlists(plural) silently returns nothing.Typeis required in the body on bothPOSTandPUT. Send"Playlist"or"Collection"(the numeric forms0and1also work). Omitting it fails deserialization and returns a400witherrors["$"].PUTwith a differentTypeconverts the list. Sending"Type": "Collection"for an ID that is currently a playlist deletes the Jellyfin playlist and rebuilds it as a collection under the same smart-list ID. This is a destructive operation, not a validation error.DELETEdefaults todeleteJellyfinList=true, which removes the real Jellyfin playlist/collection as well as the smart-list configuration and any uploaded images. Pass?deleteJellyfinList=falseto keep the Jellyfin object — it stays behind, minus the[Smart]suffix.skipRefresh=truestores the configuration without queueing a refresh. Useful when you are about to make several writes and want a single refresh at the end.
Actions
| Method | Route | Description |
|---|---|---|
POST |
/Plugins/SmartLists/{id}/enable |
Enable a list, then queue a refresh. |
POST |
/Plugins/SmartLists/{id}/disable |
Disable a list and remove its Jellyfin playlist/collection. |
POST |
/Plugins/SmartLists/{id}/refresh |
Refresh one list. Synchronous — returns when done. |
POST |
/Plugins/SmartLists/refresh |
Refresh all playlists. |
POST |
/Plugins/SmartLists/refresh-direct |
Refresh all playlists and collections. |
All five return 200 with { "message": "..." }.
Refresh behaviour
POST /{id}/refreshblocks until the refresh completes. Expect a long-running request for a large library, and set your client timeout accordingly.POST /refreshandPOST /refresh-directreturn 409 Conflict when a refresh is already running. Userefresh-directfor "refresh everything" — plainrefreshskips collections.- Refreshing a disabled list returns
400. Enable it first.
Reference data
| Method | Route | Description |
|---|---|---|
GET |
/Plugins/SmartLists/fields |
Every rule field, operator, field→operator map and sort option. |
GET |
/Plugins/SmartLists/users |
[{ "Id", "Name" }] — Jellyfin users, for UserId values. |
GET |
/Plugins/SmartLists/libraries |
[{ "Id", "Name", "CollectionType" }] — libraries, for LibraryName rules. |
GET /fields returns an object with these keys, each a [{ "Value", "Label" }] array unless noted:
ContentFields, VideoFields, AudioFields, RatingsPlaybackFields, FileFields,
LibraryFields, PeopleFields, PeopleSubFields, CollectionFields,
SimilarityComparisonFields, Operators, FieldOperators
(field name → allowed operator names), OrderOptions.
Operators is the full operator catalogue, in the same [{ "Value", "Label" }] shape as the field
lists — Value is what you put in a rule's Operator, Label is the UI wording.
FieldOperators is the authoritative answer to "which operators may I use with this field".
Status
| Method | Route | Description |
|---|---|---|
GET |
/Plugins/SmartLists/Status |
{ ongoingOperations, history, statistics }. |
GET |
/Plugins/SmartLists/Status/History |
The last refresh for each list. |
GET |
/Plugins/SmartLists/Status/Ongoing |
Refreshes in flight right now. |
An entry in ongoingOperations (and in Status/Ongoing) contains:
listId, listName, listType, triggerType, startTime (ISO 8601),
totalItems, processedItems, estimatedTimeRemaining (seconds), elapsedTime (seconds),
errorMessage, batchCurrentIndex, batchTotalCount.
A history entry contains listId, listName, listType, triggerType, startTime,
endTime, duration (seconds), success, errorMessage.
statistics contains totalLists, ongoingOperationsCount, queuedOperationsCount,
lastRefreshTime, averageRefreshDuration (seconds), successfulRefreshes, failedRefreshes.
Where refresh failures surface
A list can be created successfully and still never produce any items — for example if
MediaTypes is empty. The API returns 201 either way; the failure only appears in the
Jellyfin log and in GET /Plugins/SmartLists/Status/History. Check history after your first
refresh.
Worked examples
The examples use these shell variables:
1. Find a user ID
Both playlists and collections need at least one Jellyfin user.
GUID formats differ across the API
User IDs come back without dashes. Smart-list IDs are returned with dashes. Both forms parse on input, but string-comparing an ID from one endpoint against an ID from another will fail — normalize before comparing.
2. Create a smart playlist
Type, Name and at least one owner in UserPlaylists are enforced by the API. MediaTypes is
not enforced, but a list without it can never refresh — treat it as required:
curl -sS -X POST "$JF/Plugins/SmartLists" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"Type": "Playlist",
"Name": "My Smart Playlist",
"MediaTypes": ["Movie"],
"UserPlaylists": [{ "UserId": "d7fb16f13aea4aa79942dcc7cf22c655" }]
}'
A more realistic body, with rules, sorting and a cap:
{
"Type": "Playlist",
"Name": "Recent Highly Rated Movies",
"MediaTypes": ["Movie"],
"UserPlaylists": [{ "UserId": "d7fb16f13aea4aa79942dcc7cf22c655" }],
"ExpressionSets": [
{
"Expressions": [
{ "MemberName": "ProductionYear", "Operator": "GreaterThanOrEqual", "TargetValue": "2020" },
{ "MemberName": "CommunityRating", "Operator": "GreaterThan", "TargetValue": "7.5" }
]
}
],
"Order": { "SortOptions": [{ "SortBy": "ReleaseDate", "SortOrder": "Descending" }] },
"MaxItems": 50,
"AutoRefresh": "OnLibraryChanges"
}
The response is 201 Created with the stored list, including the server-generated Id.
3. Create a smart collection
Collections take a single UserId instead of UserPlaylists. With an API key this field is
required — see Elevation and user context.
curl -sS -X POST "$JF/Plugins/SmartLists" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"Type": "Collection",
"Name": "My Smart Collection",
"MediaTypes": ["Movie"],
"UserId": "d7fb16f13aea4aa79942dcc7cf22c655"
}'
Collection names must be unique
Jellyfin keys collections by name. Creating a second collection whose formatted name collides
with an existing one returns 400. Playlists have no such restriction.
4. Trigger a refresh and poll for progress
Single list, synchronous — this call returns only once the refresh has finished:
LIST_ID="3fa85f64-5717-4562-b3fc-2c963f66afa6"
curl -sS -X POST "$JF/Plugins/SmartLists/$LIST_ID/refresh" -H "$AUTH"
Everything, playlists and collections:
Because refresh-direct can take a while, poll progress from a second connection:
[
{
"listId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"listName": "Recent Highly Rated Movies",
"listType": "Playlist",
"triggerType": "Manual",
"startTime": "2026-01-01T12:00:00.0000000Z",
"totalItems": 1200,
"processedItems": 640,
"estimatedTimeRemaining": 4.2,
"elapsedTime": 7.9
}
]
When the array is empty, nothing is running. Confirm the outcome in history:
5. Delete
Remove the smart list and the Jellyfin playlist/collection it manages (the default):
Remove only the SmartLists configuration, leaving the Jellyfin playlist/collection in place as a plain, no longer managed list:
Both return 204 No Content.
Rules, fields and operators
Rules are not documented again here. The field names, their allowed operators and their value
formats are in Fields and Operators — use the
JSON name column for MemberName.
For the live, authoritative list as your server actually has it, call
GET /Plugins/SmartLists/fields (see Reference data).
A few rule facts that bite integrators:
- Rule logic is structural, not a field. Expressions inside one
ExpressionSetare ANDed; separateExpressionSetsare ORed. There is noRuleLogicproperty to send. TargetValueis always a string, including numbers and booleans ("2020","true").IsIn/IsNotIntake a semicolon-separated string, not a JSON array:"TargetValue": "Action;Comedy". Matching is case-insensitive and by substring, so"Act"matches"Action". Commas are not separators.NewerThan/OlderThanneednumber:unit, with unit one ofhours,days,weeks,months,years— e.g."3:days".Weekdaytakes an integer0–6(0= Sunday).- Unknown media types are silently dropped.
"MediaTypes": ["Film"]returns201withMediaTypes: [], and the list can then never refresh. Validate against Media Types before sending. SortByuses a different vocabulary fromOrderOptions.OrderOptionsfrom/fieldsare legacy combined strings like"ReleaseDate Descending";SortOptionssplits that into"SortBy": "ReleaseDate"plus"SortOrder": "Descending". An unrecognisedSortBysilently falls back to no sorting rather than erroring.- Legacy fields are migrated on read. A stored
IsPlayedrule is returned as aPlaybackStatusrule, so a rule you wrote may not be the rule you read back. - Null values are omitted from responses. An absent field means "unset", not
false/0/[].
Error format
Every error body the plugin's controllers produce is
RFC 7807 ProblemDetails:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Bad Request",
"status": 400,
"detail": "Invalid list ID format",
"traceId": "00-..."
}
Read detail for the human-readable message. The HTTP status code always matches status.
Request-body validation failures additionally carry an errors object
(ValidationProblemDetails) with per-field messages. A missing Type discriminator, for example,
comes back as:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"$": ["Smart list JSON is missing required Type property."]
}
}
Three responses this does not cover
Normalization happens inside the plugin's controllers, so responses produced before or outside them keep their own shape. A robust client should also tolerate:
- a bare
401/403with an empty body, from Jellyfin's authorization layer - a plain-text
500(Error processing request.) from Jellyfin's exception middleware - Jellyfin's own core error bodies on core routes
Success responses are unaffected: action endpoints still return { "message": "..." } on 200.
For Jellyfin plugin developers
If you are writing another Jellyfin plugin and want to create or refresh smart lists, the answer is call the REST API over loopback. Referencing SmartLists' types directly does not work, and the reason is worth understanding before you try.
Why you cannot share types with this plugin
Jellyfin loads each plugin into its own AssemblyLoadContext.
Emby.Server.Implementations/Plugins/PluginManager.cs creates
new PluginLoadContext(plugin.Path) once per plugin, and PluginLoadContext resolves assemblies
through an AssemblyDependencyResolver scoped to that plugin's own folder, returning null — and
so deferring to the Default context, which holds Jellyfin core and the BCL — when the assembly is
not found there.
The consequence for cross-plugin type sharing:
- Ship
Jellyfin.Plugin.SmartLists.dllalongside your plugin and your load context resolves it from your folder. You get a second, independent copy of every type.SmartPlaylistDtofrom your copy is a differentTypefromSmartPlaylistDtoin ours, so a cast fails andGetRequiredService<ISmartListService>()will not match our DI registration. - Don't ship it, and your context finds it in neither your folder nor the Default context, so the reference fails to resolve at all.
The DI container is shared across plugins. Assembly type identity is not, and DI resolution is keyed on type identity. There is no arrangement of references that makes this work.
Self-provisioning an API key
A plugin does not have to ask the user to paste in a key. IAuthenticationManager from
MediaBrowser.Controller.Security is injectable, and can create one at startup:
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Security;
public sealed class SmartListsClientFactory(
IAuthenticationManager authenticationManager,
IServerApplicationHost appHost)
{
private const string KeyName = "My Plugin (SmartLists integration)";
public async Task<HttpClient> CreateAsync()
{
// CreateApiKey returns Task, not the key itself - read it back by name.
var keys = await authenticationManager.GetApiKeys().ConfigureAwait(false);
var key = keys.FirstOrDefault(k => string.Equals(k.AppName, KeyName, StringComparison.Ordinal));
if (key is null)
{
await authenticationManager.CreateApiKey(KeyName).ConfigureAwait(false);
keys = await authenticationManager.GetApiKeys().ConfigureAwait(false);
key = keys.First(k => string.Equals(k.AppName, KeyName, StringComparison.Ordinal));
}
// Honours a configured base URL path; no trailing slash.
var baseUrl = appHost.GetApiUrlForLocalAccess(IPAddress.Loopback, allowHttps: false);
var client = new HttpClient { BaseAddress = new Uri(baseUrl + "/") };
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("MediaBrowser", $"Token=\"{key.AccessToken}\"");
return client;
}
}
From there, client.PostAsJsonAsync("Plugins/SmartLists", body) behaves exactly like the curl
examples above — including the requirement to send an explicit UserId when creating a
collection, since an API key has no user identity.
Be a good citizen
Name the key after your plugin so the server owner can see what it is in Dashboard →
Advanced → API Keys, and delete it with IAuthenticationManager.DeleteApiKey(accessToken)
when your plugin is uninstalled.
An in-process API?
An in-process typed facade — a small interface-only package both plugins could reference, loaded from the Default context — is possible, but it is only worth building if people actually want it. If you have a use case the REST API handles badly, open an issue on GitHub describing it.