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,94 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
namespace Jellyfin.Plugin.Floatplane.Playback;
/// <summary>
/// Rewrites an HLS playlist so that (a) every media/segment URI is absolute and
/// (b) the AES-128 key URI (which needs the Floatplane session cookie) and any nested
/// playlist URIs point back at the plugin's proxy.
/// </summary>
public static partial class HlsRewriter
{
[GeneratedRegex("URI=\"([^\"]*)\"", RegexOptions.CultureInvariant)]
private static partial Regex UriAttribute();
/// <param name="playlist">Playlist text as served by the CDN.</param>
/// <param name="playlistUrl">Absolute URL the playlist was fetched from (base for relative URIs).</param>
/// <param name="mapKeyUri">Maps the absolute key URI to the proxy key URL.</param>
/// <param name="mapPlaylistUri">Maps a nested playlist URI (master playlists) to a proxy URL; null = leave absolute.</param>
/// <param name="mapSegmentUri">Maps a segment URI; null = leave absolute (segments need no cookie).</param>
public static string Rewrite(
string playlist,
Uri playlistUrl,
Func<string, string> mapKeyUri,
Func<string, string>? mapPlaylistUri = null,
Func<string, string>? mapSegmentUri = null)
{
var sb = new StringBuilder(playlist.Length + 512);
var isMaster = playlist.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal);
var nextIsVariantPlaylist = false;
foreach (var rawLine in playlist.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
if (line.Length == 0)
{
sb.Append('\n');
continue;
}
if (line.StartsWith('#'))
{
if (line.StartsWith("#EXT-X-KEY", StringComparison.Ordinal) || line.StartsWith("#EXT-X-SESSION-KEY", StringComparison.Ordinal))
{
line = UriAttribute().Replace(line, m => "URI=\"" + mapKeyUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1);
}
else if (line.StartsWith("#EXT-X-MAP", StringComparison.Ordinal))
{
line = UriAttribute().Replace(line, m => "URI=\"" + MapSegment(Absolute(playlistUrl, m.Groups[1].Value), mapSegmentUri) + "\"", 1);
}
else if (line.StartsWith("#EXT-X-MEDIA", StringComparison.Ordinal) && mapPlaylistUri != null)
{
line = UriAttribute().Replace(line, m => "URI=\"" + mapPlaylistUri(Absolute(playlistUrl, m.Groups[1].Value)) + "\"", 1);
}
else if (line.StartsWith("#EXT-X-STREAM-INF", StringComparison.Ordinal))
{
nextIsVariantPlaylist = true;
}
sb.Append(line).Append('\n');
continue;
}
var absolute = Absolute(playlistUrl, line);
if (isMaster && nextIsVariantPlaylist)
{
nextIsVariantPlaylist = false;
sb.Append(mapPlaylistUri != null ? mapPlaylistUri(absolute) : absolute).Append('\n');
}
else
{
sb.Append(MapSegment(absolute, mapSegmentUri)).Append('\n');
}
}
return sb.ToString().TrimEnd('\n') + "\n";
}
private static string MapSegment(string absolute, Func<string, string>? mapSegmentUri)
=> mapSegmentUri != null ? mapSegmentUri(absolute) : absolute;
/// <summary>Resolves <paramref name="reference"/> against <paramref name="baseUrl"/> (RFC 3986).</summary>
public static string Absolute(Uri baseUrl, string reference)
{
// Only http(s) is "absolute" here: on Linux Uri.TryCreate("/x.ts", Absolute) yields file:///x.ts.
if (Api.Models.DeliveryInfoModel.IsHttpUrl(reference))
{
return reference;
}
return new Uri(baseUrl, reference).ToString();
}
}