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
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:
94
Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs
Normal file
94
Jellyfin.Plugin.Floatplane/Playback/HlsRewriter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user