Files
jellyfin-plugin-floatplane/doc/research/jellyfin-plugin-research.md
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

47 KiB
Raw Permalink Blame History

Building a Jellyfin 10.10.7 plugin for Floatplane — research report (2026-10-25)

Verification basis. Server facts below were checked against the Jellyfin source at branch release-10.10.z (commit 84c580b, 2026-02-20, SharedVersion.cs = AssemblyVersion("10.10.7")), the jellyfin-web release-10.10.z branch, and the live repos of the template, jprm, chaosflix-jellyfin, MediathekViewDL, NextPVR and Floatplane-Downloader (shallow clones in the scratchpad under src/). Where I infer rather than verify, I say so.

0. Executive summary

  • 10.10.7 is .NET 8, ABI 10.10.0.0. Build a net8.0 class library against NuGet Jellyfin.Controller/Jellyfin.Model 10.10.7 with ExcludeAssets=runtime; set targetAbi: "10.10.0.0" and framework: "net8.0" in build.yaml/meta.json. The upstream template on master has moved to 10.11/.NET 9, so copy its structure but downgrade the versions.
  • The channel subsystem (IChannel) is alive and functional in 10.10.7 (it lives in src/Jellyfin.LiveTv/Channels/), still exists in 10.11/12, and is what community plugins like chaosflix (CCC talks) and MediathekViewDL use. It is the only way to get a browsable, playable, virtual catalog into the official web client and the Android/TV apps without any client modification. No deprecation statement exists; it is simply old Emby-era API with known quirks (one parent per item, cache behaviour).
  • ChannelMediaInfo no longer exists in 10.10. IRequiresMediaInfoCallback.GetChannelItemMediaInfo returns IEnumerable<MediaSourceInfo> directly; RequiredHttpHeaders is a Dictionary<string,string> on MediaSourceInfo.
  • Jellyfin forwards only User-Agent (and Referer for ffmpeg) from RequiredHttpHeaders to ffmpeg/ffprobe/its own HTTP relay — never cookies or Authorization. So the plugin must either hand Jellyfin a URL that needs no auth (signed CDN URL) or proxy the stream through its own ASP.NET controller (chaosflix pattern, HMAC-signed anonymous URL). The latter is the robust choice and also makes Range/seeking work.
  • Floatplane auth has moved to OAuth2/OIDC (Keycloak at auth.floatplane.com) with device-code flow and DPoP (verified in Floatplane-Downloader's fApi.deviceLogin() and the OIDC discovery doc in the scratchpad); yt-dlp still uses the legacy sails.sid cookie. The plugin's "secrets" are therefore a refresh token (+ DPoP key), stored in the plain-XML PluginConfiguration — Jellyfin has no secret store.
  • Nothing Floatplane+Jellyfin exists on GitHub (API search returns 0 repos). Closest prior art: Inrixia/Floatplane-Downloader (Plex-oriented downloader), a 2018 Kodi addon, GrayJay plugin.
  • A plugin DLL is IL/AnyCPU: build once on x86, run on the Pi 5 arm64 unchanged. Only RID-specific publishing or native NuGet deps break this; the Floatplane plugin needs neither.
  • Warning: lscr.io/linuxserver/jellyfin:latest is now 12.3 (release 2026-10-23). A docker pull on the Pi would jump from 10.10.7 to 12.x and make a 10.10 plugin NotSupported (12 needs .NET 10 plugins). Pin 10.10.7ubu2404-ls80 (last 10.10.7 LSIO build, 2025-10-07, amd64+arm64).

1. Plugin basics for 10.10.x

1.1 .NET and package versions (verified)

  • Jellyfin.Server.csproj and MediaBrowser.Controller.csproj on release-10.10.z: <TargetFramework>net8.0</TargetFramework>.
  • NuGet Jellyfin.Controller 10.10.7 exists (published 2025-04-05, targets net8.0; depends on Jellyfin.Common/Model/Naming 10.10.7, Microsoft.Extensions.Configuration.* 8.0.x). https://www.nuget.org/packages/Jellyfin.Controller/10.10.7
  • Jellyfin core (Niels, forum): "We avoid ABI changes in patch releases as much as possible, so any plugin with targetAbi of 10.10.0 or higher should work with all 10.10.z releases." https://forum.jellyfin.org/t-question-about-targetabi
  • Server-side check (Emby.Server.Implementations/Plugins/PluginManager.cs): Version.TryParse(manifest.TargetAbi) then the plugin is only instantiated if _appVersion >= targetAbi; otherwise status NotSupported. So 10.10.0.0 is right; 10.10.7.0 also works but is needlessly strict.

1.2 The template repo and its state (verified)

https://github.com/jellyfin/jellyfin-plugin-template — structure on master: Jellyfin.Plugin.Template/ (Plugin.cs, Configuration/PluginConfiguration.cs, Configuration/configPage.html, .csproj), build.yaml, Directory.Build.props, .slnx, .vscode/ (build-and-copy tasks), .github/workflows/* (thin wrappers calling jellyfin/jellyfin-meta-plugins reusable workflows), README.md (the de-facto plugin dev guide).

History that matters (from git log): 3afa1fc (2024-10-05) "Upgrade template to 10.9.x and .NET 8" → 4291891 Jellyfin.Controller 10.9.11 → 6277846 (2025-11-23) bumped to net9.0 and added ExcludeAssets=runtime → 726279e (2026-09-03) "Upgrade to Jellyfin 10.11.x". The template never had a 10.10 commit; current master is targetAbi: "10.11.0.0", framework: "net9.0", Jellyfin.Controller 10.11.5. For 10.10 use this csproj (derived from the net8 era + the ExcludeAssets fix):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>Jellyfin.Plugin.Floatplane</RootNamespace>
    <Nullable>enable</Nullable>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisMode>AllEnabledByDefault</AnalysisMode>
    <!-- do NOT set RuntimeIdentifier / SelfContained / PublishReadyToRun -->
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Jellyfin.Controller" Version="10.10.7"><ExcludeAssets>runtime</ExcludeAssets></PackageReference>
    <PackageReference Include="Jellyfin.Model" Version="10.10.7"><ExcludeAssets>runtime</ExcludeAssets></PackageReference>
  </ItemGroup>
  <ItemGroup>
    <None Remove="Configuration\configPage.html" />
    <EmbeddedResource Include="Configuration\configPage.html" />
  </ItemGroup>
</Project>

ExcludeAssets=runtime keeps Jellyfin's own DLLs out of your publish folder (the README notes a plugin shows NotSupported if package version and server mismatch; shipping server DLLs inside the plugin folder causes them to be loaded into the plugin's load context — see 1.5).

Required classes (template README, verified against 10.10 source):

  • PluginConfiguration : BasePluginConfiguration (MediaBrowser.Model.Plugins), public get/set properties, defaults in ctor; serialized to XML.
  • Plugin : BasePlugin<PluginConfiguration>, IHasWebPages with ctor (IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(...), override string Name, override Guid Id, static Instance, and GetPages() returning PluginPageInfo { Name, EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html" }. PluginPageInfo (10.10: MediaBrowser.Model.Plugins) has Name, DisplayName, EmbeddedResourcePath, EnableInMainMenu, MenuSection, MenuIcon.
  • IPluginServiceRegistrator (MediaBrowser.Controller.Plugins): void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost); must have a parameterless ctor; discovered by type-scanning (PluginManager.RegisterServices → Activator.CreateInstance). In 10.10 ChannelManager's constructor takes IEnumerable<IChannel> channels from DI, so a channel must be registered here (serviceCollection.AddSingleton<IChannel, FloatplaneChannel>()). IScheduledTask implementations, by contrast, are found by GetExports<IScheduledTask>() type scanning and need no registration.

1.3 build.yaml / meta.json / zip / repository (verified from jprm source and PluginManifest.cs)

build.yaml for 10.10:

---
name: "Floatplane"
guid: "<new GUID, must match Plugin.Id>"
version: "1.0.0.0"          # valid C# assembly version
targetAbi: "10.10.0.0"
framework: "net8.0"          # optional in jprm but DEFAULTS TO netstandard2.1 — always set it
overview: "Browse and play Floatplane videos"
description: >
  ...
category: "Channels"         # allowed: Authentication, Channels, General, LiveTV, Metadata, Notifications
owner: "brasse"
artifacts:
- "Jellyfin.Plugin.Floatplane.dll"   # every DLL that must land in the zip
changelog: >
  ...

jprm (https://github.com/oddstr13/jellyfin-plugin-repository-manager, pip install jprm) looks for jprm.yaml, .jprm.yaml, .ci/jprm.yaml, .github/jprm.yaml, .gitlab/jprm.yaml, meta.yaml or build.yaml (Metadata.md). What it does:

  • jprm plugin build <path> -o ./artifacts [-v VERSION] [--dotnet-configuration Release] [--dotnet-framework net8.0] [--max-cpu-count 1] → rewrites Version/TargetFramework in the csproj(s), runs dotnet clean, dotnet restore --no-cache, dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 -p:PublishDir=<tmp> -p:Version=<v>, then copies the artifacts list + generated meta.json (+ optional image) flat into <slug>_<version>.zip and writes a .md5sum file.
  • jprm repo init <dir> creates an empty manifest.json; jprm repo add <repo>/manifest.json <zip> --url https://host/base [--plugin-url <full zip url>] copies the zip into <repo>/<slug>/<slug>_<version>.zip and appends a version entry.
  • Generated meta.json keys: guid, name, description, overview, owner, category, version, changelog, targetAbi, timestamp (+ image/imageUrl). The server's PluginManifest additionally understands assemblies (DLL whitelist, relative paths), status, autoUpdate, imagePath.
  • Repository manifest.json = JSON array of { guid, name, description, overview, owner, category, imageUrl?, versions: [{ version, changelog, targetAbi, sourceUrl, checksum (md5 of zip), timestamp }] }. Add it in Dashboard → Plugins → Repositories with the raw URL (works fine from Gitea raw/releases). The jprm GitHub Action (oddstr13/jellyfin-plugin-repository-manager@v1, inputs path, output, version, dotnet-config, dotnet-target, max-cpu-count, verbosity; output artifact) is a composite action that pip installs requirements and runs jprm plugin build.
  • A jprm-free alternative that is byte-for-byte what Jellyfin needs (chaosflix release.yml): dotnet publish X.csproj -c Release -o artifacts && cp meta.json artifacts/ && (cd artifacts && zip -j ../plugin.zip X.dll meta.json) && md5sum plugin.zip.

1.4 Installation paths (verified)

  • BaseApplicationPaths: PluginsPath = <ProgramDataPath>/plugins, PluginConfigurationsPath = <plugins>/configurations (→ <Name>.xml). Official docs: "placed in the plugin directory ... /var/lib/jellyfin/plugins/ on most Linux ... Once the server is restarted any additions should automatically show up." https://jellyfin.org/docs/general/server/plugins/
  • PluginManager enumerates top-level subdirectories of the plugins path, reads meta.json if present, otherwise synthesizes a manifest from the folder name (legacy PluginName_1.0.0.0 form). Loads every *.dll found recursively (Directory.GetFiles(plugin.Path, "*.dll", AllDirectories)) unless meta.json assemblies whitelists specific ones; each DLL is LoadFromAssemblyPath'd into a collectible PluginLoadContext : AssemblyLoadContext (uses AssemblyDependencyResolver, no LoadUnmanagedDll override). Statuses: Active, Disabled, Deleted, Malfunctioned, NotSupported, Superceded, Restart.
  • Official image (jellyfin/jellyfin, jellyfin-packaging/docker/Dockerfile): JELLYFIN_DATA_DIR=/config, JELLYFIN_CONFIG_DIR=/config/config, JELLYFIN_LOG_DIR=/config/log, JELLYFIN_CACHE_DIR=/cache → plugins at /config/plugins/<Name>_<version>/.
  • linuxserver image (root/etc/s6-overlay/s6-rc.d/svc-jellyfin/run): JELLYFIN_DATA_DIR=/config/data, JELLYFIN_CONFIG_DIR=/config, JELLYFIN_LOG_DIR=/config/log, JELLYFIN_CACHE_DIR=/config/cache → plugins at /config/data/plugins/<Name>_<version>/ on your Pi 5, configs in /config/data/plugins/configurations/, logs in /config/log/.

1.5 Load-context caveat (verified mechanism, inferred consequence)

Because all DLLs in the plugin folder are loaded into the plugin's own AssemblyLoadContext, any copy of an assembly Jellyfin already provides (Jellyfin., Microsoft.Extensions., Serilog, etc.) gets a second identity inside your context and can produce MissingMethodException/cast failures. Keep the publish output to your own DLL plus true third-party deps (e.g. MediathekViewDL ships FuzzySharp.dll and lists it in artifacts).


2. Architecture portability (x64 ↔ arm64)

  • A plain dotnet publish with no -r/RuntimeIdentifier, no --self-contained, no PublishReadyToRun/PublishAot produces an IL-only AnyCPU assembly. The Jellyfin plugin ecosystem relies on this: the official repository has one manifest for every platform, chaosflix's release workflow builds on ubuntu-latest (x64) and the same zip is what its Android/Android TV e2e suites install (verified in its repo). The counter-example you may find — someRandomDude-a/jellyfin-plugins with per-platform manifests — exists only because that author publishes with package.ps1 -Runtime linux-x64 (RID-specific builds), not because Jellyfin needs it.
  • Native dependencies: PluginLoadContext has no LoadUnmanagedDll override and is constructed with the plugin directory (so .deps.json-driven runtimes/<rid>/native resolution is effectively absent — inferred). .NET's default DllImport probing still includes the directory of the calling assembly, so a native .so placed flat next to the DLL for the host architecture would load (inferred, moderate confidence). Practical rule: avoid NuGet packages that ship native binaries (SkiaSharp, SQLitePCLRaw bundles, ffmpeg wrappers); the Floatplane plugin needs only System.Net.Http/System.Text.Json from the shared framework.
  • Building on an arm64 Gitea runner: mcr.microsoft.com/dotnet/sdk:8.0 is multi-arch (tags 8.0-bookworm-slim-arm64v8, 8.0-noble-arm64v8, 8.0-alpine3.24-arm64v8 verified at https://github.com/dotnet/dotnet-docker/blob/main/README.sdk.md); actions/setup-dotnet (mirrored at https://gitea.com/actions/setup-dotnet) resolves linux-arm64 SDKs; act_runner has an arm64 binary. jprm needs python3/pip in the job container (the SDK images do not ship Python — install it or use the zip-by-hand recipe). The build is IL-only, so it is equally valid to build on brasse-linux01 and push the zip; an arm64 build is only needed for CI hygiene.

3. Exposing online video as playable items: the channel API in 10.10

3.1 Status of channels (verified)

  • Code location in 10.10: src/Jellyfin.LiveTv/Channels/ChannelManager.cs (1216 lines), ChannelDynamicMediaSourceProvider.cs, RefreshChannelsScheduledTask.cs; API Jellyfin.Api/Controllers/ChannelsController.cs (GET /Channels, /Channels/Features, /Channels/{id}/Features, /Channels/{id}/Items, /Channels/Items/Latest).
  • Channels are injected into user views: UserViewManager.GetUserViews with IncludeExternalContent does _channelManager.GetChannelsInternalAsync(new ChannelQuery{UserId}) and list.AddRange(channels) → each channel shows up as a library tile under "My Media" in web and apps; jellyfin-web list.js handles ChannelFolderItem.
  • Per-user gating: GetChannelsInternalAsync filters by Channel.IsVisible(user) (user policy EnableAllChannels default true, else EnabledChannels GUID list) and then IChannel.IsEnabledFor(userId).
  • Still present in later versions: chaosflix targets 12.1 today and supported 10.11 before; issue "IChannel: an item can only live under one parent" (https://github.com/jellyfin/jellyfin/issues/18127) was filed Sept 2026 against 10.11.7/12.1 and is open with no maintainer statement about removal; jellyfin.org still lists "Channels: Allow streaming remote audio or video content" as a plugin category; the 12.0 release notes list plugin interface breaks but nothing about channels. No official jellyfin-org channel plugin is maintained (jellyfin-plugin-iptv is archived), so treat it as "legacy but working".

3.2 The interfaces, exactly as in 10.10 (verified)

MediaBrowser.Controller.Channels contains: IChannel, IRequiresMediaInfoCallback, IHasCacheKey, ISupportsLatestMedia, ISupportsMediaProbe, ISupportsDelete, IDisableMediaSourceDisplay, IHasFolderAttributes, InternalChannelFeatures, InternalChannelItemQuery, ChannelItemInfo, ChannelItemResult, ChannelItemType, ChannelParentalRating, ChannelSearchInfo, ChannelLatestMediaSearch, Channel, IChannelManager. Enums in MediaBrowser.Model.Channels: ChannelMediaType, ChannelMediaContentType, ChannelFolderType, ChannelItemSortField.

public interface IChannel {
    string Name { get; }  string Description { get; }  string DataVersion { get; }
    string HomePageUrl { get; }  ChannelParentalRating ParentalRating { get; }
    InternalChannelFeatures GetChannelFeatures();
    bool IsEnabledFor(string userId);
    Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken ct);
    Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken ct);
    IEnumerable<ImageType> GetSupportedChannelImages();
}
public interface IRequiresMediaInfoCallback {
    Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken ct); // NOT ChannelMediaInfo
}
public interface IHasCacheKey { string? GetCacheKey(string? userId); }
public interface ISupportsLatestMedia { Task<IEnumerable<ChannelItemInfo>> GetLatestMedia(ChannelLatestMediaSearch request, CancellationToken ct); }
public interface ISupportsMediaProbe { }   // marker only
// InternalChannelItemQuery: FolderId (string), UserId (Guid), StartIndex?, Limit?, SortBy (ChannelItemSortField?), SortDescending
// ChannelItemResult: IReadOnlyList<ChannelItemInfo> Items; int? TotalRecordCount
// InternalChannelFeatures: MediaTypes, ContentTypes, DefaultSortFields, MaxPageSize?, AutoRefreshLevels?, DailyDownloadLimit?, SupportsSortOrderToggle, SupportsContentDownloading

ChannelItemInfo fields: Id (your stable external id), Name, SeriesName, Type (Media/Folder), MediaType (Video/Audio/Photo), ContentType (Clip, Episode, Movie, Trailer, Podcast, …), FolderType (Container, Series, Season, MusicAlbum, …), Overview, ImageUrl, RunTimeTicks, PremiereDate, ProductionYear, DateCreated, DateModified, IndexNumber, ParentIndexNumber, OfficialRating, CommunityRating, Genres, Studios, Tags, People, ProviderIds, HomePageUrl, OriginalTitle, ExtraType, TrailerTypes, Artists, AlbumArtists, StartDate, EndDate, IsLiveStream, Etag, MediaSources (List<MediaSourceInfo>, for channels that do not use the callback).

3.3 How the server treats channel items (verified in ChannelManager)

  • Channel entity id = GetInternalChannelId(channel.Name) — renaming your channel creates a new library entity; keep Name stable.
  • Listing cache: GetChannelItems writes ChannelItemResult JSON to <cache>/channels/<channelId>/<md5(DataVersion+"2")>/<md5(folderId|root + GetCacheKey(userId) + sort)>.json and treats it as fresh for 3 hours (CacheLength). On a cache hit the plugin is not called and items are served from the library DB. To force refresh: bump DataVersion, vary IHasCacheKey.GetCacheKey (e.g. include a counter you increment after a sync), or delete the cache folder. (MediathekViewDL buckets DataVersion by day for exactly this reason.)
  • Entity sync: every ChannelItemInfo becomes a real BaseItem (Folder/Series/Season/Video/Episode/Movie/Trailer/Audio/AudioBook by FolderType/ContentType/MediaType) with SourceType.Channel, ChannelId, ExternalId = info.Id, ParentId = <folder you listed it under>; internal id derived from md5(externalId + channelName + "16"). Items missing from a fresh listing are deleted. Metadata is only copied on first creation (except container-folder names, DateModified, image URL). ImageUrl becomes the primary image. → Design stable, unique Ids, and do not list the same Id under two folders (issue #18127: the item moves to the last parent; chaosflix notes duplicate-id workaround creates separate items with separate watched state).
  • Media sources: BaseItem.GetMediaSources for SourceType.Channel first returns ChannelManager.GetStaticMediaSources (sources you put in ChannelItemInfo.MediaSources, persisted to channelmediasourceinfos.json); then MediaSourceManager.GetPlaybackMediaSources appends dynamic sources from ChannelDynamicMediaSourceProvider → IRequiresMediaInfoCallback.GetChannelItemMediaInfo(item.ExternalId) (result memory-cached 5 minutes per id — fine for short-lived CDN tokens). Dynamic sources are not probed: the server only checks SupportsDirectStream = SupportsDirectStream(path, protocol) (false if the path contains .m3u) and user permissions (EnableVideoPlaybackTranscoding, EnablePlaybackRemuxing). ISupportsMediaProbe in 10.10 only changes whether RunTimeTicks is re-copied on refresh — it does not trigger a probe.
  • MediaSourceInfo relevant fields (MediaBrowser.Model.Dto): Id (must parse as a GUID — MediathekViewDL hashes its id into a GUID "because Jellyfin's pipeline calls Guid.Parse"), Path, Protocol (MediaProtocol.Http), Container, IsRemote, SupportsDirectPlay/SupportsDirectStream/SupportsTranscoding/SupportsProbing (default true), MediaStreams, RunTimeTicks, Bitrate, RequiredHttpHeaders, IsInfiniteStream, RequiresOpening/OpenToken (live streams), Name.
  • Latest items: ISupportsLatestMedia feeds /Channels/Items/Latest and RefreshChannelsScheduledTask ("RefreshInternetChannels", default every 24 h, hidden when no channels). The 10.10 web client never calls that endpoint and excludes the channels view type from the home "Recently added" sections, so this is low priority.

3.4 Where the bytes go: the three playback paths for a remote HTTP source (verified)

Given a MediaSourceInfo { Protocol = Http, Path = <url> }, MediaInfoHelper.SetDeviceSpecificData runs StreamBuilder against the client's device profile and sets SupportsDirectPlay/DirectStream + TranscodingUrl. The web client then (playbackmanager.js):

  1. Browser direct play — supportsDirectPlay() requires SupportsDirectPlay (server says codecs/container fit), IsRemote + appHost.supports('remotevideo') (true in browsers except Tizen/webOS/Opera TV/Orsay/Edge UWP), Protocol === 'Http' and RequiredHttpHeaders empty; then mediaUrl = mediaSource.Path — the browser fetches the CDN URL itself (CORS and cookie rules of the CDN apply; HLS goes through hls.js). Any required header disables this.
  2. Server relay ("Static") — otherwise if SupportsDirectPlay || SupportsDirectStream: GET /Videos/{id}/stream.{container}?Static=true&mediaSourceId=…&api_key=…; VideosController sees InputProtocol == Http and calls FileStreamResponseHelpers.GetStaticRemoteStreamResult: Jellyfin's HttpClient (NamedClient.Default) GETs the URL, forwards only User-Agent from RequiredHttpHeaders, sets Accept-Ranges: none and streams the body → no seeking. Not available for .m3u8 paths (SupportsDirectStream forced false).
  3. ffmpeg (remux or transcode) — TranscodingUrl → EncodingHelper.AttachMediaSourceInfo copies RequiredHttpHeaders into state.RemoteHttpHeaders; GetInputModifier emits only -user_agent and -referer; MediaEncoder.GetMediaInfo (ffprobe) emits only -user_agent. Nothing else (no Cookie, no Authorization, no custom -headers) reaches ffmpeg. Whether ffmpeg copies streams (cheap HLS remux) or re-encodes depends on CanStreamCopyVideo(state, videoStream), which needs a probed/declared MediaStream — with an empty MediaStreams list StreamBuilder treats the source as having no video stream and you get a full transcode. On the Pi 5 that is CPU-only (V4L2 deprecated in 10.10, Pi 5 has no H.264 encoder; ~1–2 1080p software streams), so you want either direct play or remux, which means supplying MediaStreams (codec h264/aac, width/height, bitrate — Floatplane's delivery variants[].meta.video/audio carry this) or probing yourself via IMediaEncoder.GetMediaInfo as chaosflix does.

Consequences for Floatplane: yt-dlp shows Floatplane's delivery/info returns HLS (m3u8) origins/variants and that fetching them needs Origin/Referer headers and sometimes browser impersonation, plus HLS-AES keys. Those headers cannot be forwarded by Jellyfin, so the proven pattern is the chaosflix pattern: return Path = http://<jellyfin>/api/FloatplaneStream/proxy/<id>?t=<hmac> (anonymous endpoint, process-lifetime HMAC secret, forwards Range, retries mirrors), Protocol = Http, IsRemote = false so clients always go through the server, SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, Container set correctly, MediaStreams filled. For a flat MP4 (outputKind=flat / scenario=download variants) the proxy gives seekable progressive download; for HLS your proxy must rewrite the playlist's segment URLs to point back at itself (or ask Floatplane for flat).

3.5 Real examples to crib from

  • chaosflix-jellyfin (https://github.com/praetorianer777/chaosflix-jellyfin): ChaosflixChannel : IChannel, IRequiresMediaInfoCallback, ISupportsLatestMedia, IHasCacheKey; ChaosflixStreamController : ControllerBase with [Route("api/[controller]")], [HttpGet("proxy/{eventGuid}")], HMAC-signed anonymous URLs (ProxySignature), Range passthrough, mirror failover; registrator does AddHttpClient(...), AddSingleton<ChaosflixChannel>(), AddSingleton<IChannel>(sp => sp.GetRequiredService<ChaosflixChannel>()), AddHostedService<...>; an IScheduledTask sync; probes streams with IMediaEncoder; Playwright e2e against jellyfin/jellyfin:<tag> in docker-compose with the plugin bind-mounted at /config/plugins/Chaosflix; Android and Android TV e2e flows. README: Android/Android TV apps need an app restart to show a newly installed channel; channel items can't be downloaded offline; same item in two folders duplicates watch state. Supported 10.11 until v0.4.0 (now 12.1); meta.json-based packaging, no jprm.
  • jellyfin-plugin-MediathekViewDL PR #179 "virtual subscriptions as channel" (https://github.com/CatNoir2006/jellyfin-plugin-MediathekViewDL/pull/179): minimal IChannel + IRequiresMediaInfoCallback, returns new MediaSourceInfo { Protocol = Http, Id = <guid from hash>, Path = url, IsRemote = true, SupportsDirectPlay/DirectStream/Transcoding = true }, no MediaStreams; follow-up issues #180–#185 document the thread-safety and cache-expiry traps (listing cache 1 day vs media-info cache 30 min → playback failures).
  • NextPVR (ILiveTvService): GetChannelStream returns MediaSourceInfo { Path = http://…, Protocol = Http, RequiresOpening = true, Container = "mpegts", SupportsProbing = true, MediaStreams = [video/audio with Index = -1] }; SharedHttpStream copies the HTTP stream to a temp file and re-serves /LiveTv/LiveStreamFiles/{id}/stream.ts, without applying RequiredHttpHeaders.
  • Not channel plugins, but relevant: BingleP/jellyfin-youtube-feed (writes .strm files pointing to a local proxy on 127.0.0.1:3003 that 302s to the CDN; 10.11, system install only), Bakeneko/jellyfin-plugin-vod (12.1+, virtual library + unauthenticated stream endpoint resolving yt-dlp HLS at play time), kingschnulli/jellyfin-youtube-plugin (10.11.6; "simple" mode hands the client a direct stream URL, "enhanced" mode re-streams via local ffmpeg as HLS), tubearchivist/tubearchivist-jf-plugin (metadata provider for on-disk files).

4. Integration approaches and trade-offs (what works in 10.10 with official clients)

Approach Works in 10.10 web + apps? Pros Cons / gotchas
(a) IChannel (+ callback) Yes — native UI in web, Android, Android TV (chaosflix e2e); no client changes Browsable folders, metadata, images, watched state, SyncPlay, search; nothing on disk One parent per item; cache quirks (3 h listing cache, 5 min media-info cache); items are DB entities; must supply codec info to avoid transcodes; Android TV app restart to see new channel
(b) Live TV ILiveTvService/tuner Yes Built for linear streams, EPG Wrong model for VOD; only sensible for Floatplane livestreams (scenario=live)
(c) Download to disk + metadata Yes (ordinary library) Full seeking, trickplay, offline, no CDN/auth at play time; Floatplane-Downloader already produces a Plex-style Series/Season layout that Jellyfin can scan Disk and bandwidth, download latency, two moving parts; needs NFO/images or a metadata provider; IScheduledTask + ILibraryManager scan trigger if done in-plugin
(d) .strm files Yes but fragile Library items with NFO metadata Jellyfin forces a remote ffprobe on first play (GetPlaybackMediaSources → EnableRemoteContentProbe, UA only); static relay has Accept-Ranges: none (seeking issues #13974), bitrate cap ignored (#18438), subtitle path bugs (#13976); URLs with expiring tokens must point at a stable local endpoint anyway
(e) Plugin ASP.NET controller (+ web injection) Controllers: yes (since PR #3871; 10.10 registers plugin assemblies with AddApplicationPart; no fallback auth policy, so endpoints without [Authorize] are anonymous — use [Authorize]/[Authorize(Policy = Policies.RequiresElevation)] for admin endpoints and HMAC-signed URLs for media). Web UI injection: only via jellyfin-plugin-file-transformation (one build per Jellyfin version, web client only, reflection-based) Full control of streaming/auth Custom pages never reach the Android/TV apps; file-transformation is version-locked

Recommendation for Floatplane: (a)+(e) — IChannel for the catalog (creators → channels → videos as Episode/Clip items, folders per creator/channel/year), IRequiresMediaInfoCallback returning a signed URL to your own proxy controller, which holds the OAuth session, calls delivery/info and relays (with Range) from the CDN. Keep (c) as an optional "archive to library" scheduled task for favourites if offline/trickplay matters. Legacy api_key/ApiKey query auth still works in 10.10 (AuthorizationContext reads both), but signed URLs avoid leaking the user's token into ffmpeg command lines and logs (Jellyfin itself already puts api_key in its HLS sub-playlist URLs).


5. Existing Floatplane + Jellyfin work

  • GitHub repository search jellyfin floatplane → 0 results (API, 2026-10-25). floatplane plugin → VoxelPrismatic/grayjay-floatplane (archived), JWolvers/plugin.video.floatplaneclub (Kodi, 2018, archived), a GrayJay test plugin. No Reddit/forum thread about a Jellyfin integration surfaced.
  • Inrixia/Floatplane-Downloader (https://github.com/Inrixia/Floatplane-Downloader, v5.19.9, AGPL-3.0, actively maintained, Docker inrix/floatplane-downloader): downloads via fApi.cdn.delivery("download", attachmentId) → groups[0].origins + variants (sorted by order), rate-limits delivery calls to ~2/min, writes Plex-friendly Series/Season folders with thumbnails and can trigger a Plex refresh; no Jellyfin support, but its output is a normal TV-shows folder tree (inferred: usable by a Jellyfin "Shows" library). Auth is fApi.deviceLogin() (npm floatplane ^5.2.8, openid-client device-authorization against https://auth.floatplane.com/realms/floatplane, scopes openid profile email offline_access, Bearer access token + refresh token). The OIDC discovery document (in scratchpad oidc.json) confirms device_authorization_endpoint, urn:ietf:params:oauth:grant-type:device_code, DPoP algorithms, scopes videos:watch, videos:download. FloatNative (fn_README.md) describes "official OAuth flow; tokens are DPoP-bound".
  • yt-dlp Floatplane extractor (yt_dlp/extractor/floatplane.py): still requires the sails.sid cookie; calls /api/v3/content/post, /api/v3/content/{video|audio}, /api/v3/delivery/info?scenario=onDemand&entityId=…; builds origins[].url + variants[].url → HLS m3u8 (may be HLS-AES, needs Origin/Referer, sometimes TLS impersonation).
  • Floatplane OpenAPI spec (jamamp/FloatplaneAPI, https://jamamp.github.io/FloatplaneAPIDocs/; full spec in scratchpad spec_full.json): GET /api/v3/delivery/info params scenario ∈ {onDemand,download,live}, entityId (video/audio attachment id or liveStream.id), optional outputKind ∈ {hls.mpegts,hls.fmp4,dash.mpegts,dash.m4s,flat}; response groups[]{ origins[]{url, queryUrl, datacenter}, variants[]{name, label, url, origins, order, enabled, hidden, mimeType, meta{video, audio, …}} }. Legacy cookie login endpoints: /api/v2/auth/login, /api/v3/auth/login, /api/v3/auth/checkFor2faLogin, plus /api/v3/user/subscriptions, /api/v3/content/creator?id&limit&fetchAfter, /api/v3/creator/info.
  • Reference clients with reusable logic already in the scratchpad from earlier work: Wasserflug (tvOS, wf_DeliveryHelper.swift), FloatNative (Kotlin/Swift OAuth+DPoP), floatplaneAPI.ts, Hydra (Roku).

6. Scheduled tasks, HTTP client, config page, secrets (verified)

  • IScheduledTask (MediaBrowser.Model.Tasks): Name, Key, Description, Category, Task ExecuteAsync(IProgress<double> progress, CancellationToken ct), IEnumerable<TaskTriggerInfo> GetDefaultTriggers(); optional IConfigurableScheduledTask (IsHidden, IsEnabled, IsLogged). In 10.10 TaskTriggerInfo.Type is a string (TaskTriggerInfo.TriggerDaily/TriggerWeekly/TriggerInterval/TriggerStartup) with IntervalTicks, TimeOfDayTicks, DayOfWeek, MaxRuntimeTicks (10.11 changed this to an enum — one of the things that will need touching when you upgrade). Discovered by type scanning (GetExports<IScheduledTask>()), constructor-injected; no DI registration. Appears in Dashboard → Scheduled Tasks. Example trigger: new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(6).Ticks }.
  • HTTP: inject IHttpClientFactory and use CreateClient(NamedClient.Default) (constants Default, MusicBrainz, Dlna, DirectIp), or register your own named client in the registrator (serviceCollection.AddHttpClient("Floatplane").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false, UseCookies = … }), as chaosflix does).
  • Config storage: BasePlugin<T> serializes Configuration with IXmlSerializer to <plugins>/configurations/<PluginName>.xml (Plugin.Instance.Configuration, SaveConfiguration()). It is plain XML on disk — there is no secret store or encryption API in 10.10. Store the OAuth refresh token (and DPoP private key, if you implement DPoP) there or in a file under IApplicationPaths.PluginConfigurationsPath/DataPath with 0600 permissions; never a password. Note the XML serializer needs public settable properties and simple types.
  • Dashboard page: embed Configuration/configPage.html; the page root is <div id="…" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">; JS uses ApiClient.getPluginConfiguration(guid), ApiClient.updatePluginConfiguration(guid, cfg), Dashboard.showLoadingMsg()/hideLoadingMsg()/processPluginConfigurationUpdateResult(result) on pageshow/submit (template verbatim). For the device-code login you can call your own controller from that page (ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl('Floatplane/Login/Start') }) — the ApiClient attaches the auth header), show verification_uri_complete + user code, and poll a status endpoint. PluginPageInfo.EnableInMainMenu = true adds a sidebar entry.
  • Logging: inject ILogger<T>; appears in the server log with your namespace as SourceContext.

6.1 Minimal 10.10 channel skeleton (illustrative, not a file)

// Registrator (parameterless ctor, found by type scanning)
public class ServiceRegistrator : IPluginServiceRegistrator {
    public void RegisterServices(IServiceCollection s, IServerApplicationHost host) {
        s.AddHttpClient("Floatplane");
        s.AddSingleton<FloatplaneClient>();              // OAuth session + API wrapper
        s.AddSingleton<FloatplaneChannel>();
        s.AddSingleton<IChannel>(sp => sp.GetRequiredService<FloatplaneChannel>()); // required: ChannelManager takes IEnumerable<IChannel>
    }
}

public class FloatplaneChannel : IChannel, IRequiresMediaInfoCallback, IHasCacheKey {
    public string Name => "Floatplane";                  // keep stable: channel entity id derives from it
    public string Description => "Floatplane subscriptions";
    public string DataVersion => "1";                    // bump to invalidate the 3h listing cache
    public string HomePageUrl => "https://www.floatplane.com";
    public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
    public bool IsEnabledFor(string userId) => true;
    public string? GetCacheKey(string? userId) => _syncGeneration.ToString();
    public InternalChannelFeatures GetChannelFeatures() => new() {
        MediaTypes = [ChannelMediaType.Video],
        ContentTypes = [ChannelMediaContentType.Episode, ChannelMediaContentType.Clip],
        DefaultSortFields = [ChannelItemSortField.PremiereDate, ChannelItemSortField.Name],
        SupportsSortOrderToggle = true, MaxPageSize = 100 };
    public IEnumerable<ImageType> GetSupportedChannelImages() => [ImageType.Primary];
    public Task<DynamicImageResponse> GetChannelImage(ImageType t, CancellationToken ct)
        => Task.FromResult(new DynamicImageResponse { Path = "https://…/logo.png", Protocol = MediaProtocol.Http, HasImage = true });

    public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery q, CancellationToken ct) {
        if (string.IsNullOrEmpty(q.FolderId))            // root: one folder per creator/channel
            return new ChannelItemResult { Items = creators.Select(c => new ChannelItemInfo {
                Id = $"creator:{c.Id}", Name = c.Title, Type = ChannelItemType.Folder,
                FolderType = ChannelFolderType.Container, ImageUrl = c.IconUrl }).ToList() };
        // folder: videos (unique Id per video, never reused under another folder)
        return new ChannelItemResult { Items = posts.Select(p => new ChannelItemInfo {
            Id = $"video:{p.VideoAttachmentId}", Name = p.Title, Overview = p.Text,
            Type = ChannelItemType.Media, MediaType = ChannelMediaType.Video,
            ContentType = ChannelMediaContentType.Clip, ImageUrl = p.ThumbnailUrl,
            PremiereDate = p.ReleaseDate, RunTimeTicks = TimeSpan.FromSeconds(p.Duration).Ticks }).ToList(),
            TotalRecordCount = total };
    }

    public Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken ct) {
        var proxy = $"{_host.GetApiUrlForLocalAccess()}/FloatplaneStream/proxy/{attachmentId}?t={ProxySignature.Create(attachmentId)}";
        return Task.FromResult<IEnumerable<MediaSourceInfo>>([ new MediaSourceInfo {
            Id = StableGuid(id).ToString("N"), Name = "1080p",
            Path = proxy, Protocol = MediaProtocol.Http, Container = "mp4",   // or "ts"/"hls" per outputKind
            IsRemote = false, SupportsProbing = false,
            SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true,
            MediaStreams = [ new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264", Width = 1920, Height = 1080 },
                             new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac", Channels = 2 } ] } ]);
    }
}

(IServerApplicationHost.GetApiUrlForLocalAccess() is what SharedHttpStream uses to build server-local URLs in 10.10.)


7. Testing locally on x86 (Arch) with Docker

Host status checked: dotnet SDK 8.0.130 installed (pacman package dotnet-sdk-8.0 8.0.31), Docker 29.7.2, Python 3.14 (jprm not installed — pip install --user jprm or skip it).

  • Image: jellyfin/jellyfin:10.10.7 exists as a multi-arch manifest (linux/amd64, arm64/v8, arm/v7; pushed 2025-04-05). For parity with the Pi use lscr.io/linuxserver/jellyfin:10.10.7ubu2404-ls80 (amd64+arm64) — same package, different paths (see 1.4).
  • Loop: dotnet publish Jellyfin.Plugin.Floatplane -c Release -o ./dist, then copy dist/Jellyfin.Plugin.Floatplane.dll (+ meta.json) into the mounted plugin folder and restart the container (plugins are loaded only at startup). Example (official image):
    docker run -d --name jf-dev -p 8096:8096 \
      -v $PWD/jfdev/config:/config -v $PWD/jfdev/cache:/cache \
      jellyfin/jellyfin:10.10.7
    # plugin folder: $PWD/jfdev/config/plugins/Floatplane_1.0.0.0/   (LSIO: /config/data/plugins/…)
    
    Chaosflix's e2e compose bind-mounts its build output directly at /config/plugins/Chaosflix with JELLYFIN_PublishedServerUrl set; prefer a writable copy over a :ro mount because PluginManager may write a synthesized meta.json. A folder named Floatplane_1.0.0.0 without meta.json is accepted (manifest synthesized from the name).
  • Verify: Dashboard → Plugins shows the plugin with status (NotSupported = targetAbi/.NET mismatch; Malfunctioned = load error, see log); Dashboard → Users → Access shows "Enable access to all channels"; the channel tile appears under My Media after the first GET /Channels (user views). Use GET /Channels/{id}/Items and POST /Items/{id}/PlaybackInfo to inspect the MediaSources the server actually hands out (SupportsDirectPlay/DirectStream, TranscodingUrl).
  • Logs: docker logs -f jf-dev (console sink) or jfdev/config/log/log_YYYYMMDD.log (LSIO: /config/log/). To get Debug from your namespace, copy logging.default.json to logging.json in the config dir (official: /config/config/, LSIO: /config/) and add "Jellyfin.Plugin.Floatplane": "Debug" under Serilog.MinimumLevel.Override. ffmpeg transcode logs land in <log dir>/FFmpeg.Transcode-*.log.
  • Pi 5 deployment: zip → Gitea release/raw manifest.json → add repo URL in Dashboard, or scp the folder into /config/data/plugins/Floatplane_1.0.0.0/ and docker restart. Pin the image tag before touching the Pi (:latest = 12.3 now).

Sources