- 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>
47 KiB
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 anet8.0class library against NuGetJellyfin.Controller/Jellyfin.Model10.10.7 withExcludeAssets=runtime; settargetAbi: "10.10.0.0"andframework: "net8.0"inbuild.yaml/meta.json. The upstream template onmasterhas 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 insrc/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). ChannelMediaInfono longer exists in 10.10.IRequiresMediaInfoCallback.GetChannelItemMediaInforeturnsIEnumerable<MediaSourceInfo>directly;RequiredHttpHeadersis aDictionary<string,string>onMediaSourceInfo.- Jellyfin forwards only
User-Agent(andRefererfor ffmpeg) fromRequiredHttpHeadersto ffmpeg/ffprobe/its own HTTP relay — never cookies orAuthorization. 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'sfApi.deviceLogin()and the OIDC discovery doc in the scratchpad); yt-dlp still uses the legacysails.sidcookie. The plugin's "secrets" are therefore a refresh token (+ DPoP key), stored in the plain-XMLPluginConfiguration— 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:latestis now 12.3 (release 2026-10-23). Adocker pullon the Pi would jump from 10.10.7 to 12.x and make a 10.10 pluginNotSupported(12 needs .NET 10 plugins). Pin10.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.csprojandMediaBrowser.Controller.csprojonrelease-10.10.z:<TargetFramework>net8.0</TargetFramework>.- NuGet
Jellyfin.Controller 10.10.7exists (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 statusNotSupported. So10.10.0.0is right;10.10.7.0also 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>, IHasWebPageswith ctor(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(...),override string Name,override Guid Id, staticInstance, andGetPages()returningPluginPageInfo { Name, EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html" }.PluginPageInfo(10.10:MediaBrowser.Model.Plugins) hasName, 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.10ChannelManager's constructor takesIEnumerable<IChannel> channelsfrom DI, so a channel must be registered here (serviceCollection.AddSingleton<IChannel, FloatplaneChannel>()).IScheduledTaskimplementations, by contrast, are found byGetExports<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]→ rewritesVersion/TargetFrameworkin the csproj(s), runsdotnet clean,dotnet restore --no-cache,dotnet publish --nologo --no-restore --configuration=Release --framework=net8.0 -p:PublishDir=<tmp> -p:Version=<v>, then copies theartifactslist + generatedmeta.json(+ optionalimage) flat into<slug>_<version>.zipand writes a.md5sumfile.jprm repo init <dir>creates an emptymanifest.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>.zipand appends a version entry.- Generated
meta.jsonkeys:guid, name, description, overview, owner, category, version, changelog, targetAbi, timestamp(+image/imageUrl). The server'sPluginManifestadditionally understandsassemblies(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, inputspath, output, version, dotnet-config, dotnet-target, max-cpu-count, verbosity; outputartifact) is a composite action thatpip installs requirements and runsjprm 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/PluginManagerenumerates top-level subdirectories of the plugins path, readsmeta.jsonif present, otherwise synthesizes a manifest from the folder name (legacyPluginName_1.0.0.0form). Loads every*.dllfound recursively (Directory.GetFiles(plugin.Path, "*.dll", AllDirectories)) unlessmeta.jsonassemblieswhitelists specific ones; each DLL isLoadFromAssemblyPath'd into a collectiblePluginLoadContext : AssemblyLoadContext(usesAssemblyDependencyResolver, noLoadUnmanagedDlloverride). 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 publishwith no-r/RuntimeIdentifier, no--self-contained, noPublishReadyToRun/PublishAotproduces 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 onubuntu-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-pluginswith per-platform manifests — exists only because that author publishes withpackage.ps1 -Runtime linux-x64(RID-specific builds), not because Jellyfin needs it. - Native dependencies:
PluginLoadContexthas noLoadUnmanagedDlloverride and is constructed with the plugin directory (so.deps.json-drivenruntimes/<rid>/nativeresolution is effectively absent — inferred). .NET's defaultDllImportprobing still includes the directory of the calling assembly, so a native.soplaced 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 onlySystem.Net.Http/System.Text.Jsonfrom the shared framework. - Building on an arm64 Gitea runner:
mcr.microsoft.com/dotnet/sdk:8.0is multi-arch (tags8.0-bookworm-slim-arm64v8,8.0-noble-arm64v8,8.0-alpine3.24-arm64v8verified 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_runnerhas an arm64 binary. jprm needspython3/pipin 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; APIJellyfin.Api/Controllers/ChannelsController.cs(GET /Channels,/Channels/Features,/Channels/{id}/Features,/Channels/{id}/Items,/Channels/Items/Latest). - Channels are injected into user views:
UserViewManager.GetUserViewswithIncludeExternalContentdoes_channelManager.GetChannelsInternalAsync(new ChannelQuery{UserId})andlist.AddRange(channels)→ each channel shows up as a library tile under "My Media" in web and apps;jellyfin-weblist.jshandlesChannelFolderItem. - Per-user gating:
GetChannelsInternalAsyncfilters byChannel.IsVisible(user)(user policyEnableAllChannelsdefaulttrue, elseEnabledChannelsGUID list) and thenIChannel.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-iptvis 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; keepNamestable. - Listing cache:
GetChannelItemswritesChannelItemResultJSON to<cache>/channels/<channelId>/<md5(DataVersion+"2")>/<md5(folderId|root + GetCacheKey(userId) + sort)>.jsonand 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: bumpDataVersion, varyIHasCacheKey.GetCacheKey(e.g. include a counter you increment after a sync), or delete the cache folder. (MediathekViewDL bucketsDataVersionby day for exactly this reason.) - Entity sync: every
ChannelItemInfobecomes a realBaseItem(Folder/Series/Season/Video/Episode/Movie/Trailer/Audio/AudioBookbyFolderType/ContentType/MediaType) withSourceType.Channel,ChannelId,ExternalId = info.Id,ParentId = <folder you listed it under>; internal id derived frommd5(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).ImageUrlbecomes the primary image. → Design stable, uniqueIds, and do not list the sameIdunder 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.GetMediaSourcesforSourceType.Channelfirst returnsChannelManager.GetStaticMediaSources(sources you put inChannelItemInfo.MediaSources, persisted tochannelmediasourceinfos.json); thenMediaSourceManager.GetPlaybackMediaSourcesappends dynamic sources fromChannelDynamicMediaSourceProvider→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 checksSupportsDirectStream = SupportsDirectStream(path, protocol)(falseif the path contains.m3u) and user permissions (EnableVideoPlaybackTranscoding,EnablePlaybackRemuxing).ISupportsMediaProbein 10.10 only changes whetherRunTimeTicksis re-copied on refresh — it does not trigger a probe. MediaSourceInforelevant 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:
ISupportsLatestMediafeeds/Channels/Items/LatestandRefreshChannelsScheduledTask("RefreshInternetChannels", default every 24 h, hidden when no channels). The 10.10 web client never calls that endpoint and excludes thechannelsview 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):
- Browser direct play —
supportsDirectPlay()requiresSupportsDirectPlay(server says codecs/container fit),IsRemote+appHost.supports('remotevideo')(true in browsers except Tizen/webOS/Opera TV/Orsay/Edge UWP),Protocol === 'Http'andRequiredHttpHeadersempty; thenmediaUrl = 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. - Server relay ("Static") — otherwise if
SupportsDirectPlay || SupportsDirectStream:GET /Videos/{id}/stream.{container}?Static=true&mediaSourceId=…&api_key=…;VideosControllerseesInputProtocol == Httpand callsFileStreamResponseHelpers.GetStaticRemoteStreamResult: Jellyfin'sHttpClient(NamedClient.Default) GETs the URL, forwards onlyUser-AgentfromRequiredHttpHeaders, setsAccept-Ranges: noneand streams the body → no seeking. Not available for.m3u8paths (SupportsDirectStreamforced false). - ffmpeg (remux or transcode) —
TranscodingUrl→EncodingHelper.AttachMediaSourceInfocopiesRequiredHttpHeadersintostate.RemoteHttpHeaders;GetInputModifieremits only-user_agentand-referer;MediaEncoder.GetMediaInfo(ffprobe) emits only-user_agent. Nothing else (noCookie, noAuthorization, no custom-headers) reaches ffmpeg. Whether ffmpeg copies streams (cheap HLS remux) or re-encodes depends onCanStreamCopyVideo(state, videoStream), which needs a probed/declaredMediaStream— with an emptyMediaStreamslist 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 supplyingMediaStreams(codech264/aac, width/height, bitrate — Floatplane's deliveryvariants[].meta.video/audiocarry this) or probing yourself viaIMediaEncoder.GetMediaInfoas 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 : ControllerBasewith[Route("api/[controller]")],[HttpGet("proxy/{eventGuid}")], HMAC-signed anonymous URLs (ProxySignature), Range passthrough, mirror failover; registrator doesAddHttpClient(...),AddSingleton<ChaosflixChannel>(),AddSingleton<IChannel>(sp => sp.GetRequiredService<ChaosflixChannel>()),AddHostedService<...>; anIScheduledTasksync; probes streams withIMediaEncoder; Playwright e2e againstjellyfin/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, returnsnew MediaSourceInfo { Protocol = Http, Id = <guid from hash>, Path = url, IsRemote = true, SupportsDirectPlay/DirectStream/Transcoding = true }, noMediaStreams; 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):GetChannelStreamreturnsMediaSourceInfo { Path = http://…, Protocol = Http, RequiresOpening = true, Container = "mpegts", SupportsProbing = true, MediaStreams = [video/audio with Index = -1] };SharedHttpStreamcopies the HTTP stream to a temp file and re-serves/LiveTv/LiveStreamFiles/{id}/stream.ts, without applyingRequiredHttpHeaders. - Not channel plugins, but relevant:
BingleP/jellyfin-youtube-feed(writes.strmfiles pointing to a local proxy on127.0.0.1:3003that 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 viafApi.cdn.delivery("download", attachmentId)→groups[0].origins+variants(sorted byorder), 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 isfApi.deviceLogin()(npmfloatplane^5.2.8,openid-clientdevice-authorization againsthttps://auth.floatplane.com/realms/floatplane, scopesopenid profile email offline_access, Bearer access token + refresh token). The OIDC discovery document (in scratchpadoidc.json) confirmsdevice_authorization_endpoint,urn:ietf:params:oauth:grant-type:device_code, DPoP algorithms, scopesvideos: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 thesails.sidcookie; calls/api/v3/content/post,/api/v3/content/{video|audio},/api/v3/delivery/info?scenario=onDemand&entityId=…; buildsorigins[].url + variants[].url→ HLS m3u8 (may be HLS-AES, needsOrigin/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/infoparamsscenario∈ {onDemand,download,live},entityId(video/audio attachment id orliveStream.id), optionaloutputKind∈ {hls.mpegts,hls.fmp4,dash.mpegts,dash.m4s,flat}; responsegroups[]{ 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(); optionalIConfigurableScheduledTask(IsHidden,IsEnabled,IsLogged). In 10.10TaskTriggerInfo.Typeis a string (TaskTriggerInfo.TriggerDaily/TriggerWeekly/TriggerInterval/TriggerStartup) withIntervalTicks,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
IHttpClientFactoryand useCreateClient(NamedClient.Default)(constantsDefault,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>serializesConfigurationwithIXmlSerializerto<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 underIApplicationPaths.PluginConfigurationsPath/DataPathwith 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 usesApiClient.getPluginConfiguration(guid),ApiClient.updatePluginConfiguration(guid, cfg),Dashboard.showLoadingMsg()/hideLoadingMsg()/processPluginConfigurationUpdateResult(result)onpageshow/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), showverification_uri_complete+ user code, and poll a status endpoint.PluginPageInfo.EnableInMainMenu = trueadds a sidebar entry. - Logging: inject
ILogger<T>; appears in the server log with your namespace asSourceContext.
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.7exists as a multi-arch manifest (linux/amd64, arm64/v8, arm/v7; pushed 2025-04-05). For parity with the Pi uselscr.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 copydist/Jellyfin.Plugin.Floatplane.dll(+meta.json) into the mounted plugin folder and restart the container (plugins are loaded only at startup). Example (official image):Chaosflix's e2e compose bind-mounts its build output directly atdocker 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/…)/config/plugins/ChaosflixwithJELLYFIN_PublishedServerUrlset; prefer a writable copy over a:romount becausePluginManagermay write a synthesizedmeta.json. A folder namedFloatplane_1.0.0.0withoutmeta.jsonis 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). UseGET /Channels/{id}/ItemsandPOST /Items/{id}/PlaybackInfoto inspect theMediaSourcesthe server actually hands out (SupportsDirectPlay/DirectStream,TranscodingUrl). - Logs:
docker logs -f jf-dev(console sink) orjfdev/config/log/log_YYYYMMDD.log(LSIO:/config/log/). To get Debug from your namespace, copylogging.default.jsontologging.jsonin the config dir (official:/config/config/, LSIO:/config/) and add"Jellyfin.Plugin.Floatplane": "Debug"underSerilog.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/anddocker restart. Pin the image tag before touching the Pi (:latest= 12.3 now).
Sources
- Template: https://github.com/jellyfin/jellyfin-plugin-template (README,
build.yaml, csproj,Plugin.cs,configPage.html, commit history ofbuild.yaml/csproj) - Jellyfin source
release-10.10.z:SharedVersion.cs,Jellyfin.Server.csproj,MediaBrowser.Controller/Channels/*,MediaBrowser.Model/Channels/*,MediaBrowser.Model/Dto/MediaSourceInfo.cs,src/Jellyfin.LiveTv/Channels/ChannelManager.cs,ChannelDynamicMediaSourceProvider.cs,RefreshChannelsScheduledTask.cs,Emby.Server.Implementations/Library/UserViewManager.cs,MediaSourceManager.cs,Plugins/PluginManager.cs,Plugins/PluginLoadContext.cs,AppBase/BaseApplicationPaths.cs,ApplicationHost.cs,MediaBrowser.Common/Plugins/PluginManifest.cs,MediaBrowser.Common/Api/Policies.cs,MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs,EncodingJobInfo.cs,MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs,EncodingUtils.cs,Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs,StreamingHelpers.cs,MediaInfoHelper.cs,Jellyfin.Api/Controllers/VideosController.cs,ChannelsController.cs,Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs,Jellyfin.Server.Implementations/Security/AuthorizationContext.cs,MediaBrowser.Model/Tasks/IScheduledTask.cs,TaskTriggerInfo.cs,MediaBrowser.Controller/Plugins/IPluginServiceRegistrator.cs,MediaBrowser.Model/Plugins/PluginPageInfo.cs,src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs,MediaBrowser.Controller/LiveTv/ILiveTvService.cs,Jellyfin.Server/Resources/Configuration/logging.json— https://github.com/jellyfin/jellyfin/tree/release-10.10.z - jellyfin-web
release-10.10.z:src/components/playback/playbackmanager.js,src/components/apphost.js,src/controllers/list.js,src/components/homesections/sections/recentlyAdded.ts— https://github.com/jellyfin/jellyfin-web/tree/release-10.10.z - targetAbi forum answer: https://forum.jellyfin.org/t-question-about-targetabi
- NuGet: https://www.nuget.org/packages/Jellyfin.Controller/10.10.7
- Plugins docs: https://jellyfin.org/docs/general/server/plugins/ ; Docker docs: https://jellyfin.org/docs/general/installation/container/ ; 10.10.7 release: https://github.com/jellyfin/jellyfin/releases/tag/v10.10.7 ; 12.0 post: https://jellyfin.org/posts/jellyfin-release-12.0/ ; auth gist: https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f
- jprm: https://github.com/oddstr13/jellyfin-plugin-repository-manager (README,
Metadata.md,build_plugin.sh,action.yaml,jprm/__init__.py), https://pypi.org/project/jprm/ - Plugin controllers PR: https://github.com/jellyfin/jellyfin/pull/3871 ; IChannel parent issue: https://github.com/jellyfin/jellyfin/issues/18127
- Examples: https://github.com/praetorianer777/chaosflix-jellyfin ; https://github.com/CatNoir2006/jellyfin-plugin-MediathekViewDL/pull/179 (+ issues #180–#185) ; https://github.com/jellyfin/jellyfin-plugin-nextpvr ; https://github.com/BingleP/jellyfin-youtube-feed ; https://github.com/Bakeneko/jellyfin-plugin-vod ; https://github.com/kingschnulli/jellyfin-youtube-plugin ; https://github.com/tubearchivist/tubearchivist-jf-plugin ; https://github.com/IAmParadox27/jellyfin-plugin-file-transformation ; https://github.com/someRandomDude-a/jellyfin-plugins ; https://github.com/jellyfin-archive/jellyfin-plugin-iptv
- .strm issues: https://github.com/jellyfin/jellyfin/issues/13974 , https://github.com/jellyfin/jellyfin/issues/18438 , https://github.com/jellyfin/jellyfin/issues/13976
- Images: https://github.com/jellyfin/jellyfin-packaging/blob/master/docker/Dockerfile ; https://github.com/linuxserver/docker-jellyfin (Dockerfile,
root/etc/s6-overlay/s6-rc.d/svc-jellyfin/run, releases) ; Docker Hub tag APIs forjellyfin/jellyfin:10.10.7andlinuxserver/jellyfin10.10.7 tags ; https://docs.linuxserver.io/images/docker-jellyfin/ ; https://github.com/dotnet/dotnet-docker/blob/main/README.sdk.md - Pi 5 transcoding: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/ , https://github.com/jellyfin/jellyfin/releases/tag/v10.10.0 (V4L2 deprecation)
- Gitea arm64 runners: https://gitea.com/actions/setup-dotnet , https://github.com/actions/setup-dotnet
- Floatplane: https://github.com/Inrixia/Floatplane-Downloader (
src/logins.ts,src/lib/Video.ts,package.json) ; https://github.com/jamamp/FloatplaneAPI and https://jamamp.github.io/FloatplaneAPIDocs/ (spec in scratchpadspec_full.json) ; https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/floatplane.py ; https://github.com/ytdl-org/youtube-dl/pull/21504 (legacy v2 cdn/delivery) ; https://github.com/Inrixia/floatplaneAPI.ts ; GitHub search APIq=jellyfin+floatplane(0 results) andq=floatplane+plugin - Local artifacts consulted (scratchpad
src/): cloned repos above plus earlier-session filesoidc.json,fn_README.md,fpts_index.ts,fpts_cdn.ts,fpdl_onDeviceCode.ts,ytdlp_floatplane.py.