Some checks failed
release / build-release (push) Has been cancelled
- per-creator "All videos" folder (release order across sub-channels); sub-channel copies use cpost: ids and UserDataMirror keeps played/resume state in sync - SortNameUpdater forces release-order sort names so every client lists newest first by default - official Floatplane icon as channel image (Primary only — a channel Thumb hid video thumbnails in Continue Watching) and as 16:9 plugin thumbnail; one-time image replacement on upgrade - OAuth device-flow login (FloatplaneAuthService + settings UI); needs a Floatplane client id; Turnstile verified domain-bound so password login inside Jellyfin is impossible - generic install.sh (linuxserver / official image / native), thumb.png in package + manifest - README rewritten for a general audience, doc/usage.md with screenshots, 60 tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
311 lines
21 KiB
HTML
311 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Floatplane</title>
|
|
</head>
|
|
<body>
|
|
<div id="FloatplaneConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
|
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
<div data-role="content">
|
|
<div class="content-primary">
|
|
<form id="FloatplaneConfigForm">
|
|
<div class="verticalSection">
|
|
<h2 class="sectionTitle">Floatplane</h2>
|
|
<div id="fpStatus" class="fieldDescription" style="margin-bottom:1em">Loading status…</div>
|
|
</div>
|
|
|
|
<div class="verticalSection">
|
|
<h3 class="sectionTitle">Session</h3>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="SessionCookie">sails.sid cookie</label>
|
|
<input is="emby-input" type="password" id="SessionCookie" autocomplete="off" />
|
|
<div class="fieldDescription">
|
|
Floatplane's password login needs a browser captcha, so paste the session instead:
|
|
log in at floatplane.com, open DevTools → Application (Chrome) / Storage (Firefox) → Cookies →
|
|
<code>https://www.floatplane.com</code> → copy the <b>Value</b> of <code>sails.sid</code>.
|
|
Browsing works without it; playback and subscriptions need it.
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<button is="emby-button" type="button" id="btnValidate" class="raised"><span>Validate cookie</span></button>
|
|
<span id="validateResult" class="fieldDescription" style="margin-left:1em"></span>
|
|
</div>
|
|
|
|
<h3 class="sectionTitle" style="margin-top:1.5em">Login with Floatplane (OAuth, optional)</h3>
|
|
<div class="fieldDescription" style="margin-bottom:0.5em">
|
|
Floatplane's own apps and third-party clients log in through an OAuth device flow instead of a cookie.
|
|
That needs a <b>client id</b> issued by Floatplane for this plugin — there is no self-service registration.
|
|
If you have one, enter it and press "Login with Floatplane"; the OAuth session then replaces the cookie
|
|
(refresh tokens last about 90 days and renew themselves).
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="OAuthClientId">OAuth client id</label>
|
|
<input is="emby-input" type="text" id="OAuthClientId" placeholder="(none)" />
|
|
</div>
|
|
<div>
|
|
<button is="emby-button" type="button" id="btnOAuth" class="raised"><span>Login with Floatplane</span></button>
|
|
<button is="emby-button" type="button" id="btnOAuthLogout" class="raised" hidden><span>Forget OAuth session</span></button>
|
|
<div id="oauthResult" class="fieldDescription" style="margin-top:0.5em"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="verticalSection">
|
|
<h3 class="sectionTitle">Catalogue</h3>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="ExtraCreators">Extra creators (url-names, comma separated)</label>
|
|
<input is="emby-input" type="text" id="ExtraCreators" placeholder="linustechtips, bitwit" />
|
|
<div class="fieldDescription">Shown in addition to your subscriptions — or instead of them when no cookie is set (public listings only, no playback).</div>
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="MaxPostsAllVideos">Videos listed in "All videos"</label>
|
|
<input is="emby-input" type="number" id="MaxPostsAllVideos" min="20" max="5000" step="20" />
|
|
<div class="fieldDescription">Each creator gets an "All videos" folder with every channel's videos in release order, newest first. Floatplane pages 20 at a time, so 200 = 10 requests.</div>
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="MaxPostsPerChannel">Videos listed per sub-channel</label>
|
|
<input is="emby-input" type="number" id="MaxPostsPerChannel" min="20" max="2000" step="20" />
|
|
<div class="fieldDescription">Newest first, per sub-channel folder (TechLinked, ShortCircuit, …).</div>
|
|
</div>
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label class="emby-checkbox-label">
|
|
<input is="emby-checkbox" type="checkbox" id="ShowLockedPosts" />
|
|
<span>Show posts above my tier (locked)</span>
|
|
</label>
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="CatalogCacheMinutes">Listing cache (minutes)</label>
|
|
<input is="emby-input" type="number" id="CatalogCacheMinutes" min="1" max="1440" />
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="SyncIntervalHours">Automatic refresh (hours)</label>
|
|
<input is="emby-input" type="number" id="SyncIntervalHours" min="1" max="168" />
|
|
<div class="fieldDescription">Also available as the scheduled task "Refresh Floatplane catalogue".</div>
|
|
</div>
|
|
<div>
|
|
<button is="emby-button" type="button" id="btnRefresh" class="raised"><span>Refresh now</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="verticalSection">
|
|
<h3 class="sectionTitle">Playback</h3>
|
|
<div class="selectContainer">
|
|
<label class="selectLabel" for="PreferredQuality">Preferred quality</label>
|
|
<select is="emby-select" id="PreferredQuality" class="emby-select-withcolor">
|
|
<option value="360">360p</option>
|
|
<option value="480">480p</option>
|
|
<option value="720">720p</option>
|
|
<option value="1080">1080p</option>
|
|
<option value="2160">4K (needs the 4K tier)</option>
|
|
</select>
|
|
<div class="fieldDescription">Listed first as the default version; other qualities appear as extra versions in the player.</div>
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="MaxHeight">Highest quality offered (pixels)</label>
|
|
<input is="emby-input" type="number" id="MaxHeight" min="360" max="2160" step="1" />
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="DeliveryCallsPerMinute">Delivery lookups per minute</label>
|
|
<input is="emby-input" type="number" id="DeliveryCallsPerMinute" min="1" max="10" />
|
|
<div class="fieldDescription">Floatplane rate-limits the stream-URL endpoint to about 2 per minute. Results are cached for the token's lifetime (6 h).</div>
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="ProxyBaseUrl">Proxy base URL (optional)</label>
|
|
<input is="emby-input" type="text" id="ProxyBaseUrl" placeholder="http://127.0.0.1:8096" />
|
|
<div class="fieldDescription">How the server's own ffmpeg reaches this plugin. Leave empty to use Jellyfin's local address.</div>
|
|
</div>
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label class="emby-checkbox-label">
|
|
<input is="emby-checkbox" type="checkbox" id="EnableMp4Sources" />
|
|
<span>Also offer progressive MP4 versions (experimental)</span>
|
|
</label>
|
|
<div class="fieldDescription checkboxFieldDescription">Uses Floatplane's download delivery. Untested — may not play.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<button is="emby-button" type="submit" class="raised button-submit block"><span>Save</span></button>
|
|
</div>
|
|
</form>
|
|
|
|
<div class="verticalSection" style="margin-top:2em">
|
|
<h3 class="sectionTitle">Search test (Floatplane API)</h3>
|
|
<div class="selectContainer">
|
|
<label class="selectLabel" for="searchCreator">Creator</label>
|
|
<select is="emby-select" id="searchCreator" class="emby-select-withcolor"></select>
|
|
</div>
|
|
<div class="inputContainer">
|
|
<label class="inputLabel" for="searchQuery">Query</label>
|
|
<input is="emby-input" type="text" id="searchQuery" placeholder="framework" />
|
|
</div>
|
|
<div>
|
|
<button is="emby-button" type="button" id="btnSearch" class="raised"><span>Search</span></button>
|
|
</div>
|
|
<div id="searchResults" class="fieldDescription" style="margin-top:1em; white-space:pre-wrap"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
(function () {
|
|
var pluginId = "7f2b6c1e-5a3d-4e8f-9b0a-2c4d6e8f0a1b";
|
|
var fields = ["SessionCookie", "ExtraCreators", "MaxPostsPerChannel", "MaxPostsAllVideos", "CatalogCacheMinutes", "SyncIntervalHours",
|
|
"PreferredQuality", "MaxHeight", "DeliveryCallsPerMinute", "ProxyBaseUrl", "OAuthClientId"];
|
|
var checks = ["ShowLockedPosts", "EnableMp4Sources"];
|
|
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", "\"": """ }[c]; }); }
|
|
|
|
function loadStatus() {
|
|
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Status")).then(function (s) {
|
|
var lines = [];
|
|
lines.push("Plugin " + esc(s.Version));
|
|
var kind = s.SessionKind === "oauth" ? "OAuth" : "cookie";
|
|
if (!s.HasSessionCookie) {
|
|
lines.push("Session: <b>none</b> — browsing only (public listings); paste a cookie or log in to play.");
|
|
} else if (s.SessionState === "ok") {
|
|
lines.push("Session: <b>ok</b> via " + kind + (s.SessionUsername ? " (" + esc(s.SessionUsername) + ")" : ""));
|
|
} else if (s.SessionState === "invalid") {
|
|
lines.push("Session (" + kind + "): <b style='color:#e55'>rejected</b> — " + (s.SessionKind === "oauth" ? "log in again." : "paste a fresh cookie. ") + esc(s.LastError || ""));
|
|
} else {
|
|
lines.push("Session: " + kind + " configured, not checked yet.");
|
|
}
|
|
el("btnOAuthLogout").hidden = s.SessionKind !== "oauth";
|
|
lines.push("Creators: " + s.Creators.length + (s.LastRefresh ? " (refreshed " + new Date(s.LastRefresh).toLocaleString() + ")" : " (not loaded yet)"));
|
|
s.Creators.forEach(function (c) { lines.push(" • " + esc(c.Title) + " — " + esc(c.Channels.join(", "))); });
|
|
lines.push("Cached post lists: " + s.CachedPostLists + ", cached deliveries: " + s.CachedDeliveries + ", generation " + s.Generation);
|
|
el("fpStatus").innerHTML = lines.join("<br/>");
|
|
|
|
var sel = el("searchCreator");
|
|
sel.innerHTML = s.Creators.map(function (c) { return "<option value='" + esc(c.Id) + "'>" + esc(c.Title) + "</option>"; }).join("");
|
|
}).catch(function () { el("fpStatus").textContent = "Status unavailable"; });
|
|
}
|
|
|
|
function loadConfig() {
|
|
Dashboard.showLoadingMsg();
|
|
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
|
fields.forEach(function (f) { el(f).value = config[f] == null ? "" : config[f]; });
|
|
checks.forEach(function (f) { el(f).checked = !!config[f]; });
|
|
Dashboard.hideLoadingMsg();
|
|
loadStatus();
|
|
});
|
|
}
|
|
|
|
function saveConfig(e) {
|
|
e.preventDefault();
|
|
Dashboard.showLoadingMsg();
|
|
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
|
fields.forEach(function (f) {
|
|
var v = el(f).value;
|
|
config[f] = (el(f).type === "number") ? parseInt(v || "0", 10) : v;
|
|
});
|
|
checks.forEach(function (f) { config[f] = el(f).checked; });
|
|
config.SessionCookie = (config.SessionCookie || "").replace(/^sails\.sid=/, "").trim();
|
|
var validated = el("validateResult").getAttribute("data-username");
|
|
if (validated && el("validateResult").getAttribute("data-cookie") === config.SessionCookie) {
|
|
config.SessionUsername = validated;
|
|
}
|
|
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
|
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
// Reload creators with the new settings.
|
|
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
|
});
|
|
});
|
|
return false;
|
|
}
|
|
|
|
function validate() {
|
|
var out = el("validateResult");
|
|
out.textContent = "Checking…";
|
|
ApiClient.ajax({
|
|
type: "POST",
|
|
url: ApiClient.getUrl("Floatplane/Session/Validate"),
|
|
data: JSON.stringify({ Cookie: el("SessionCookie").value }),
|
|
contentType: "application/json",
|
|
dataType: "json"
|
|
}).then(function (r) {
|
|
out.textContent = r.Message + (r.Subscriptions && r.Subscriptions.length ? " — " + r.Subscriptions.join("; ") : "");
|
|
out.style.color = r.Ok ? "#4c4" : "#e55";
|
|
if (r.Ok) {
|
|
out.setAttribute("data-username", r.Username);
|
|
out.setAttribute("data-cookie", r.Cookie);
|
|
el("SessionCookie").value = r.Cookie;
|
|
}
|
|
}).catch(function (err) {
|
|
out.textContent = "Request failed (" + (err && err.status ? err.status : "?") + ")";
|
|
out.style.color = "#e55";
|
|
});
|
|
}
|
|
|
|
var oauthTimer = null;
|
|
function oauthStart() {
|
|
var out = el("oauthResult");
|
|
var clientId = el("OAuthClientId").value.trim();
|
|
if (!clientId) { out.textContent = "Enter the client id Floatplane issued for this plugin first."; return; }
|
|
out.textContent = "Contacting auth.floatplane.com…";
|
|
ApiClient.ajax({
|
|
type: "POST", url: ApiClient.getUrl("Floatplane/OAuth/Start"),
|
|
data: JSON.stringify({ ClientId: clientId }), contentType: "application/json", dataType: "json"
|
|
}).then(function (r) {
|
|
var link = r.VerificationUriComplete || r.VerificationUri;
|
|
out.innerHTML = "Open <a href='" + esc(link) + "' target='_blank' rel='noopener'>" + esc(link) + "</a> and enter code <b style='font-size:1.3em'>" + esc(r.UserCode) + "</b> (valid " + Math.round(r.ExpiresIn / 60) + " min). Waiting…";
|
|
var interval = (r.Interval || 5) * 1000;
|
|
clearInterval(oauthTimer);
|
|
oauthTimer = setInterval(function () {
|
|
ApiClient.getJSON(ApiClient.getUrl("Floatplane/OAuth/Poll")).then(function (s) {
|
|
if (s.State === "pending") { return; }
|
|
clearInterval(oauthTimer);
|
|
out.textContent = s.Message || s.State;
|
|
out.style.color = s.State === "done" ? "#4c4" : "#e55";
|
|
if (s.State === "done") { loadStatus(); }
|
|
}).catch(function () { clearInterval(oauthTimer); out.textContent = "Polling failed."; });
|
|
}, interval);
|
|
}).catch(function (err) {
|
|
var msg = "Start failed";
|
|
try { msg += ": " + (err.responseText || ("HTTP " + err.status)); } catch (e) { /* ignore */ }
|
|
out.textContent = msg + " — usually the client id is not registered with Floatplane (invalid_client).";
|
|
out.style.color = "#e55";
|
|
});
|
|
}
|
|
|
|
function oauthLogout() {
|
|
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/OAuth/Logout") }).then(function () {
|
|
el("oauthResult").textContent = "OAuth session forgotten.";
|
|
loadStatus();
|
|
});
|
|
}
|
|
|
|
function refresh() {
|
|
el("fpStatus").textContent = "Refreshing…";
|
|
ApiClient.ajax({ type: "POST", url: ApiClient.getUrl("Floatplane/Refresh") }).then(loadStatus, loadStatus);
|
|
}
|
|
|
|
function search() {
|
|
var creator = el("searchCreator").value;
|
|
var q = el("searchQuery").value;
|
|
var out = el("searchResults");
|
|
if (!creator) { out.textContent = "No creator loaded yet."; return; }
|
|
out.textContent = "Searching…";
|
|
ApiClient.getJSON(ApiClient.getUrl("Floatplane/Search", { creatorId: creator, q: q, limit: 20 })).then(function (hits) {
|
|
if (!hits.length) { out.textContent = "No results."; return; }
|
|
out.innerHTML = hits.map(function (h) {
|
|
var d = h.ReleaseDate ? new Date(h.ReleaseDate).toISOString().slice(0, 10) : "";
|
|
return esc(d) + " [" + esc(h.Channel || "") + "] " + (h.IsAccessible ? "" : "🔒 ") + esc(h.Title) + " (" + Math.round(h.DurationSeconds / 60) + " min)";
|
|
}).join("\n");
|
|
}).catch(function () { out.textContent = "Search failed."; });
|
|
}
|
|
|
|
var page = el("FloatplaneConfigPage");
|
|
page.addEventListener("pageshow", loadConfig);
|
|
el("FloatplaneConfigForm").addEventListener("submit", saveConfig);
|
|
el("btnValidate").addEventListener("click", validate);
|
|
el("btnRefresh").addEventListener("click", refresh);
|
|
el("btnOAuth").addEventListener("click", oauthStart);
|
|
el("btnOAuthLogout").addEventListener("click", oauthLogout);
|
|
el("btnSearch").addEventListener("click", search);
|
|
})();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|