Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- 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>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,290 @@
<!-- Research report produced 2026-10-25 by a Claude research agent, verified against Jellyfin release-10.10.z sources, jellyfin-web, jprm, chaosflix-jellyfin, MediathekViewDL, NextPVR. -->
# 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):
```xml
<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:
```yaml
---
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 install`s 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`.
```csharp
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 `Id`s, 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)
```csharp
// 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
- Template: https://github.com/jellyfin/jellyfin-plugin-template (README, `build.yaml`, csproj, `Plugin.cs`, `configPage.html`, commit history of `build.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 for `jellyfin/jellyfin:10.10.7` and `linuxserver/jellyfin` 10.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 scratchpad `spec_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 API `q=jellyfin+floatplane` (0 results) and `q=floatplane+plugin`
- Local artifacts consulted (scratchpad `src/`): cloned repos above plus earlier-session files `oidc.json`, `fn_README.md`, `fpts_index.ts`, `fpts_cdn.ts`, `fpdl_onDeviceCode.ts`, `ytdlp_floatplane.py`.