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

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

291 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 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`.