M5 delivers the Jellyfin playback thin-slice (BRIEF Milestone 5 / §8). The plugin
materializes one ephemeral item pointing at the Core Server and makes it playable
through IMediaSourceProvider; resolve/open/close and playback events are wired. There
is no search interception yet (that is M6).
- ✅ Plugin builds against
Jellyfin.Controller10.11.11 onnet9.0(dotnet build -c Release). - ✅
dotnet test plugin/Streamarr.Plugin.sln— mapper, offer-store, persistence, transport-security, playback-dispatch, search, and cleanup tests pass (translation is the plugin's only logic; its trust boundaries are pinned by tests). - ✅ Jellyfin 10.11.11 (docker) loads the plugin with zero errors; the service
registrator runs and the playback-event bridge attaches to
ISessionManager(seedocs/jellyfin-compatibility.mdfor the exact log lines). The real-host review smoke also covered correct/wrong-key connection tests, materialization + restart persistence, allowed/denied PlaybackInfo, opaque open/close, rejection of forged, cross-user and replayed offers, and root/Latest isolation. - ✅
docker compose -f docker-compose.dev.yml configvalidates the dev stack.
Headless CI cannot exercise ffmpeg Direct Play / transcode against a live Usenet provider. The owner must run this once on a real Jellyfin client (web, mobile, or TV).
- Real Usenet provider credentials + a Newznab indexer configured in the Streamarr
Management UI (until then,
/resolvehas no live article to open). - A Core Server machine API key; put the same value in the plugin config.
- Build the plugin:
(cd plugin && ~/.dotnet/dotnet build Streamarr.Plugin/Streamarr.Plugin.csproj -c Release) docker compose -f docker-compose.dev.yml up --build- Jellyfin → Dashboard → Plugins → confirm Streamarr is listed and Active.
- Open the Streamarr plugin settings:
- Core Server URL =
http://streamarr:8080, API key = the machine key. - Click Test connection → expect "Connected. Server version …". This succeeds only after anonymous shallow health and authenticated capabilities both pass, so repeat once with a deliberately wrong key and confirm it fails.
- Set a Pinned-work query that your indexers can satisfy (a movie).
- Click Materialize pinned work → expect "Materialized …" with a release count.
- Core Server URL =
- The private Streamarr implementation folder and its item do not appear in
normal library browsing, "Latest", or recommendations. The item is tagged
usenet-ephemeraland is reachable only through an eligible intercepted search (or direct API access to the returned item id during this bootstrap test). - Opening the eligible item shows a version picker listing multiple releases
(one
MediaSourceInfoper ranked release, named e.g.1080p WEB-DL x265 · DDP5.1 · GER). - Each unopened source carries an opaque, bounded, replay-safe
OpenToken; it is not a release id, can be replayed only within its active/idle lease, and cannot be used by a different Jellyfin user. - Selecting a version and pressing play opens the source: the Core Server logs a
/resolveand a new session appears in admin-authenticatedGET /api/v1/sessions. - Direct Play works on a client/codec that supports the container.
- Forced transcode works: pick a lower bitrate / incompatible client so Jellyfin
transcodes via ffmpeg from the
/streamURL. Seeking works. The opened source contains no reusable credential inRequiredHttpHeaders; the short-lived path capability alone authorizes that stream. - Pre-probed media info is used: playback starts without a long ffmpeg analyze pause
(resolve populated
MediaStreams+RunTimeTicks,AnalyzeDurationMsis low). - Stopping playback reports telemetry but does not tear down the Core capability; it
remains in admin-authenticated
GET /api/v1/sessionsuntil LRU eviction or hard expiry. Brief Swiftfin stop/resume reports must not interrupt the existing byte stream. - Playback events land server-side:
start/progress/stoprows appear via the Core Server watch-event store (source =jellyfin) — confirm in the DB / logs. - (If a dead release is available) Core auto-fallback remains inside the same
offered work and is attributed in the resolve response; if Core returns a dead
response with
suggestedFallbackReleaseId, the plugin follows it at most once.
- TTL cleanup of ephemeral items and native-search interception are M6, not M5.
- The plugin contains zero domain logic: ranking, health, and fallback selection are all the Core Server's (BRIEF §11). The plugin only translates.
M6 adds the IAsyncActionFilter that injects Usenet works into Jellyfin's native search
(/Items?searchTerm= and /Search/Hints), plus the IScheduledTask that deletes ephemeral
items past their TTL (BRIEF §8.2–8.5, Milestone 6).
- ✅ Plugin builds against
Jellyfin.Controller10.11.11 onnet9.0with the filter + cleanup task registered (dotnet build -c Release, 0 warnings/errors underTreatWarningsAsErrors). - ✅
dotnet test plugin/Streamarr.Plugin.sln— the merge/dedup + hint-shaping (SearchInjectionTests) and TTL-expiry (EphemeralCleanupTests) logic, plus the M5 mapper/offer/store/tracker and security tests. Stable-GUID de-duplication, bounded state, replay-safe offers, and TTL decisions are pinned by these. - ✅ Jellyfin 10.11.11 (docker) loads the plugin with zero errors with the search action
filter registered into
MvcOptions— no exceptions fromStreamarr.Plugin.Searchat startup. - ✅ Real-host fall-through (BRIEF §11): the hardened Jellyfin smoke verifies that
interception off leaves
/Search/Hintsand/Items?searchTerm=native, and that interception on with Core unreachable returns native200responses from both endpoints without synthetic items. Re-run this pinned smoke on every host upgrade. Seejellyfin-compatibility.md.
Headless CI cannot exercise real Usenet works flowing into a client's search UI (needs indexer/ provider credentials and a real client). Run this once end to end.
- Configure real indexers + a Usenet provider + TMDB key in the Streamarr Management UI so
GET /api/v1/searchreturns works with releases. docker compose -f docker-compose.dev.yml up --build.- Jellyfin → Dashboard → Plugins → Streamarr: set Core Server URL + API key, Test connection, then turn Enable search interception on.
- Searching a movie title in a native Jellyfin client (web/mobile/TV) surfaces the Usenet work alongside local library results.
- A fresh injected item remains in plugin-owned staging and does not appear in normal
browsing or "Latest Media" before engagement. It is tagged
usenet-ephemeraland eligible only when the user can see a compatible ordinary Jellyfin library. - Selecting the injected result and pressing play resolves + plays it (the M5 playback path:
version picker,
/resolve, session appears in admin-authenticatedGET /api/v1/sessions). - Playing, favoriting, or marking the item watched promotes it into the visible Streamarr library so normal Jellyfin surfaces such as Continue Watching can use it; removing all engagement demotes it back to staging.
- Repeat the same search — the work updates in place; no duplicate item appears
(stable GUID derived from
workId). - Kill the Core Server (or toggle interception off) and search again — native library search is fully intact; no errors surface in the client. CI exercises the HTTP fall-through contract; this client check still confirms the end-user presentation.
- TTL cleanup: set a short
EphemeralTtlMinutes, run Dashboard → Scheduled Tasks → "Streamarr: clean up ephemeral items", and confirm stale unengaged items are removed viaILibraryManagerwhile native and engaged Streamarr items are untouched.
- Ephemeral retention is authoritative on the Core Server (decoded-size LRU plus hard TTL,
BRIEF §6.1); Jellyfin
CloseLiveStreamreleases plugin attribution only. - TV works materialize as a lazy
Series→Season→Episodehierarchy; movies materialize asMovie. Both share the same Core-owned resolve/playback path. - The plugin still contains zero domain logic — the Core Server does all searching/ranking/ health/fallback; the filter only materializes what the server returned and merges it in.