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:
@@ -0,0 +1,76 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred video quality.
|
||||
/// </summary>
|
||||
public VideoQuality PreferredQuality { get; set; } = VideoQuality.High;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred language (ISO 639 code, e.g. "deu", "eng").
|
||||
/// Empty means original language.
|
||||
/// </summary>
|
||||
public string PreferredLanguage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred video format.
|
||||
/// </summary>
|
||||
public VideoFormat PreferredFormat { get; set; } = VideoFormat.Mp4;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether a talk starts on a version every client
|
||||
/// can stream without a re-encode, leaving <see cref="PreferredFormat"/> to order
|
||||
/// the versions behind it.
|
||||
/// </summary>
|
||||
public bool CompatibleDefaultVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the conferences to show, separated by commas or newlines. An entry
|
||||
/// is either an acronym ("38c3") or a series as media.ccc.de files it in the slug
|
||||
/// ("congress", "gpn"), which also covers the editions not published yet.
|
||||
/// Empty means every conference, which is what the plugin did before this existed.
|
||||
/// </summary>
|
||||
public string ConferenceFilter { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the streaming endpoint that reports which rooms are on air.
|
||||
/// Empty means the public one at streaming.media.ccc.de.
|
||||
/// </summary>
|
||||
public string StreamingBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the CCC API endpoint. Empty means the public API at media.ccc.de;
|
||||
/// set it to use a mirror or, in the e2e tests, a local stand-in.
|
||||
/// </summary>
|
||||
public string ApiBaseUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Video quality preference.
|
||||
/// </summary>
|
||||
public enum VideoQuality
|
||||
{
|
||||
/// <summary>High quality (1080p).</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Standard quality (576p).</summary>
|
||||
Standard
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Video format preference.
|
||||
/// </summary>
|
||||
public enum VideoFormat
|
||||
{
|
||||
/// <summary>H.264 MP4.</summary>
|
||||
Mp4,
|
||||
|
||||
/// <summary>WebM/VP9.</summary>
|
||||
WebM
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Chaosflix</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ChaosflixConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-select,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="ChaosflixConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Chaosflix Settings</h2>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredQuality">Preferred Quality</label>
|
||||
<select is="emby-select" id="PreferredQuality" class="emby-select-withcolor">
|
||||
<option value="High">High (1080p)</option>
|
||||
<option value="Standard">Standard (576p)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredFormat">Preferred Format</label>
|
||||
<select is="emby-select" id="PreferredFormat" class="emby-select-withcolor">
|
||||
<option value="Mp4">MP4 (H.264)</option>
|
||||
<option value="WebM">WebM (VP9)</option>
|
||||
</select>
|
||||
<div class="fieldDescription">WebM costs a full re-encode on clients that can only transcode to H.264 — the Android app is one of them.</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="CompatibleDefaultVersion" />
|
||||
<span>Start on a version every client can play untouched</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Jellyfin hands every client the same list of versions, so the plugin cannot order it per device. With this on a talk starts on its H.264 MP4, which no client has to re-encode, and the preferred format only orders the versions behind it. Leave it off if your clients decode VP9 but not H.264.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="PreferredLanguage">Preferred Language</label>
|
||||
<select is="emby-select" id="PreferredLanguage" class="emby-select-withcolor">
|
||||
<option value="">Original</option>
|
||||
<option value="deu">Deutsch</option>
|
||||
<option value="eng">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="ConferenceFilter">Conferences</label>
|
||||
<input is="emby-input" type="text" id="ConferenceFilter" placeholder="congress, gpn, 38c3" />
|
||||
<div class="fieldDescription">Leave empty for every conference media.ccc.de publishes. Otherwise list what you follow, separated by commas: a series as it appears in the media.ccc.de address ("congress", "gpn", "easterhegg") keeps every edition including future ones, an acronym ("38c3") keeps exactly that one. This also narrows Popular and Recommended.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="StreamingBaseUrl">Streaming Endpoint</label>
|
||||
<input is="emby-input" type="text" id="StreamingBaseUrl" placeholder="https://streaming.media.ccc.de" />
|
||||
<div class="fieldDescription">Where the plugin asks which rooms are on air. Leave empty for streaming.media.ccc.de. A "🔴 Live now" folder appears while a congress is running and disappears again afterwards.</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel" for="ApiBaseUrl">API Endpoint</label>
|
||||
<input is="emby-input" type="text" id="ApiBaseUrl" placeholder="https://api.media.ccc.de/public" />
|
||||
<div class="fieldDescription">Leave empty for media.ccc.de. Set it to use a mirror.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Status</h2>
|
||||
<div class="fieldDescription">What the plugin thinks is true right now. Nothing here talks to media.ccc.de until you ask it to.</div>
|
||||
|
||||
<div id="ChaosflixStatus" class="paperList" style="padding: 0.5em 1em;">Loading…</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="ChaosflixRefresh" class="raised emby-button">
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="ChaosflixCheckApi" class="raised emby-button">
|
||||
<span>Check endpoint now</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="ChaosflixClearCaches" class="raised emby-button">
|
||||
<span>Clear caches</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ChaosflixApiCheck" class="fieldDescription"></div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Check a talk</h2>
|
||||
<div class="fieldDescription">Resolves one talk the way playback does — API lookup, recording choice, signed proxy url, proxy request, probe — and names the stage that fails.</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ChaosflixTalkId">Talk guid, slug or media.ccc.de url</label>
|
||||
<input is="emby-input" type="text" id="ChaosflixTalkId" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="ChaosflixCheckTalk" class="raised emby-button">
|
||||
<span>Check this talk</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ChaosflixTalkCheck" class="fieldDescription"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var ChaosflixConfig = {
|
||||
pluginUniqueId: 'c4a05f11-4ccc-4b00-bdea-dbeef1337000',
|
||||
statusEndpoint: 'api/ChaosflixStatus'
|
||||
};
|
||||
|
||||
(function () {
|
||||
var page = document.querySelector('#ChaosflixConfigPage');
|
||||
|
||||
function statusUrl(action) {
|
||||
return ApiClient.getUrl(ChaosflixConfig.statusEndpoint + (action ? '/' + action : ''));
|
||||
}
|
||||
|
||||
function post(action, query) {
|
||||
return ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: statusUrl(action) + (query || ''),
|
||||
dataType: 'json'
|
||||
});
|
||||
}
|
||||
|
||||
// Talk titles and error messages come from media.ccc.de, so every value
|
||||
// is written as text and never as markup.
|
||||
function row(container, label, value) {
|
||||
var line = document.createElement('div');
|
||||
var name = document.createElement('strong');
|
||||
name.textContent = label + ': ';
|
||||
line.appendChild(name);
|
||||
line.appendChild(document.createTextNode(value));
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
function percent(value) {
|
||||
return value === null || value === undefined ? 'n/a' : value + '%';
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
var target = page.querySelector('#ChaosflixStatus');
|
||||
target.textContent = '';
|
||||
|
||||
row(target, 'Plugin version', status.Version);
|
||||
row(target, 'Endpoint', status.ApiBaseUrl + (status.ApiBaseUrlIsDefault ? ' (default)' : ' (configured)'));
|
||||
|
||||
var sync = status.Sync;
|
||||
if (!sync.Registered) {
|
||||
row(target, 'Sync task', 'not registered with this server');
|
||||
} else if (!sync.LastRunUtc) {
|
||||
row(target, 'Sync task', sync.State + ', has not run yet');
|
||||
} else {
|
||||
row(target, 'Sync task', sync.LastResult + ' — ' + new Date(sync.LastRunUtc).toLocaleString() +
|
||||
', took ' + sync.DurationSeconds + 's (now ' + sync.State + ')');
|
||||
if (sync.Error) {
|
||||
row(target, 'Sync error', sync.Error);
|
||||
}
|
||||
}
|
||||
|
||||
var cache = status.ApiCache;
|
||||
row(target, 'API cache', cache.Entries + '/' + cache.Capacity + ' entries — ' +
|
||||
cache.Conferences + ' conferences, ' + cache.Events + ' talks, ' +
|
||||
cache.Searches + ' searches, ' + cache.Redirects + ' CDN mirrors');
|
||||
row(target, 'API cache hit rate', percent(cache.HitRatePercent) +
|
||||
' (' + cache.Hits + ' hits, ' + cache.Misses + ' misses since server start)');
|
||||
|
||||
var probe = status.ProbeCache;
|
||||
row(target, 'Probe cache', probe.Entries + '/' + probe.Capacity + ' stream layouts, hit rate ' +
|
||||
percent(probe.HitRatePercent) + ' (' + probe.Hits + ' hits, ' + probe.Misses + ' misses)');
|
||||
row(target, 'Media sources handed out', probe.ServedItems +
|
||||
' talks a configuration change or a cache clear would invalidate');
|
||||
}
|
||||
|
||||
function loadStatus() {
|
||||
return ApiClient.getJSON(statusUrl()).then(renderStatus, function () {
|
||||
page.querySelector('#ChaosflixStatus').textContent = 'The status endpoint did not answer.';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTalkCheck(result) {
|
||||
var target = page.querySelector('#ChaosflixTalkCheck');
|
||||
target.textContent = '';
|
||||
|
||||
row(target, result.Ok ? 'Result' : 'Failed at',
|
||||
result.Ok ? 'resolved end to end' : result.FailedStage);
|
||||
if (result.Title) {
|
||||
row(target, 'Talk', result.Title);
|
||||
}
|
||||
|
||||
result.Stages.forEach(function (stage) {
|
||||
row(target, (stage.Ok ? '✓ ' : '✗ ') + stage.Name,
|
||||
stage.Detail + (stage.ElapsedMs ? ' (' + stage.ElapsedMs + ' ms)' : ''));
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(ChaosflixConfig.pluginUniqueId).then(function (config) {
|
||||
page.querySelector('#PreferredQuality').value = config.PreferredQuality || 'High';
|
||||
page.querySelector('#PreferredFormat').value = config.PreferredFormat || 'Mp4';
|
||||
page.querySelector('#PreferredLanguage').value = config.PreferredLanguage || '';
|
||||
page.querySelector('#CompatibleDefaultVersion').checked = config.CompatibleDefaultVersion === true;
|
||||
page.querySelector('#ConferenceFilter').value = config.ConferenceFilter || '';
|
||||
page.querySelector('#StreamingBaseUrl').value = config.StreamingBaseUrl || '';
|
||||
page.querySelector('#ApiBaseUrl').value = config.ApiBaseUrl || '';
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
||||
// Cheap by construction: the endpoint only reads counters the plugin
|
||||
// already keeps. The checks below are the ones that cost something,
|
||||
// and they run on click.
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixRefresh').addEventListener('click', loadStatus);
|
||||
|
||||
page.querySelector('#ChaosflixCheckApi').addEventListener('click', function () {
|
||||
var target = page.querySelector('#ChaosflixApiCheck');
|
||||
target.textContent = 'Asking the endpoint…';
|
||||
post('CheckApi').then(function (result) {
|
||||
target.textContent = '';
|
||||
row(target, result.Ok ? 'Reachable' : 'Unreachable', result.Url);
|
||||
row(target, 'Round trip', result.ElapsedMs + ' ms');
|
||||
if (result.Ok) {
|
||||
row(target, 'Answer', result.StatusCode + ', ' + result.ConferenceCount + ' conferences listed');
|
||||
} else {
|
||||
row(target, 'Error', (result.StatusCode ? result.StatusCode + ' ' : '') + (result.Error || 'no answer'));
|
||||
}
|
||||
}, function () {
|
||||
target.textContent = 'The check could not be run.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixClearCaches').addEventListener('click', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
post('ClearCaches').then(function (status) {
|
||||
renderStatus(status);
|
||||
Dashboard.hideLoadingMsg();
|
||||
}, function () {
|
||||
Dashboard.hideLoadingMsg();
|
||||
page.querySelector('#ChaosflixStatus').textContent = 'Clearing the caches failed.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixCheckTalk').addEventListener('click', function () {
|
||||
var id = page.querySelector('#ChaosflixTalkId').value.trim();
|
||||
var target = page.querySelector('#ChaosflixTalkCheck');
|
||||
if (!id) {
|
||||
target.textContent = 'Enter a talk guid, slug or media.ccc.de url first.';
|
||||
return;
|
||||
}
|
||||
|
||||
target.textContent = 'Resolving ' + id + '…';
|
||||
post('CheckTalk', '?id=' + encodeURIComponent(id)).then(renderTalkCheck, function () {
|
||||
target.textContent = 'The check could not be run.';
|
||||
});
|
||||
});
|
||||
|
||||
page.querySelector('#ChaosflixConfigForm').addEventListener('submit', function (e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(ChaosflixConfig.pluginUniqueId).then(function (config) {
|
||||
config.PreferredQuality = page.querySelector('#PreferredQuality').value;
|
||||
config.PreferredFormat = page.querySelector('#PreferredFormat').value;
|
||||
config.PreferredLanguage = page.querySelector('#PreferredLanguage').value;
|
||||
config.CompatibleDefaultVersion = page.querySelector('#CompatibleDefaultVersion').checked;
|
||||
config.ConferenceFilter = page.querySelector('#ConferenceFilter').value;
|
||||
config.StreamingBaseUrl = page.querySelector('#StreamingBaseUrl').value;
|
||||
config.ApiBaseUrl = page.querySelector('#ApiBaseUrl').value;
|
||||
ApiClient.updatePluginConfiguration(ChaosflixConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
loadStatus();
|
||||
});
|
||||
});
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user