The dashboard listed every share ever made, including ones revoked or expired months ago, and nothing ever pruned them. There is now a "Clean up finished links" button next to Refresh that runs a normal cleanup pass and then drops the records that are done with, reporting how many went. Only revoked, expired and failed records are removed. A link that can still be used is left alone, including a spent one-use link whose guest is still watching until it expires, since that record is Redeemed rather than finished. Folded into 1.0.3.0 rather than shipped as 1.0.3.1: the ceiling fix and this are going out as one replacement of that version.
461 lignes
22 KiB
HTML
461 lignes
22 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>ShareLinks</title>
|
|
<style>
|
|
#ShareLinksConfigPage .sl-section {
|
|
margin-bottom: 1.25rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid rgba(127, 127, 127, 0.18);
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 0.75rem 1rem;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-field .fieldDescription {
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-muted {
|
|
color: var(--theme-secondary-color, #666);
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table th,
|
|
#ShareLinksConfigPage .sl-table td {
|
|
padding: 0.45rem 0.4rem;
|
|
border-bottom: 1px solid rgba(127, 127, 127, 0.14);
|
|
vertical-align: top;
|
|
text-align: left;
|
|
word-break: break-word;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table th {
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-table .sl-right {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-inline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#ShareLinksConfigPage .sl-list-status {
|
|
min-height: 1.25rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
#ShareLinksConfigPage .sl-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="ShareLinksConfigPage" 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="ShareLinksConfigForm">
|
|
<div class="sl-section">
|
|
<div class="sectionTitleContainer flex align-items-center">
|
|
<h2 class="sectionTitle">ShareLinks</h2>
|
|
</div>
|
|
<p class="fieldDescription">
|
|
Creates short-lived guest links for movies and episodes, then keeps the active records visible for revocation.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<h3 class="sectionTitle">General</h3>
|
|
<div class="sl-grid">
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="Enabled" />
|
|
<span>Enable ShareLinks</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="OneUseDefault" />
|
|
<span>New links are single use by default</span>
|
|
</label>
|
|
<div class="fieldDescription checkboxFieldDescription">This only decides how the "Let several people use this link" box starts out in the create popup; you can change it for every link you make. A single-use link stops working the moment the first person opens it, and only that person keeps access until it expires. A multi-use link can be opened by everyone you send it to, for as long as it is valid.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="MaxConcurrentViewers" label="Maximum viewers per multi-use link" min="0" max="500" />
|
|
<div class="fieldDescription">How many people may watch a multi-use link at the same time. 0 means no limit. Someone arriving once the limit is reached is asked to try again later; nobody already watching is disturbed. Single-use links are always one viewer.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="DefaultExpiryHours" label="Default expiry (hours)" min="1" max="8760" />
|
|
<div class="fieldDescription">Used by the menu action when the admin accepts the default.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="MaxExpiryHours" label="Maximum expiry (hours)" min="1" max="8760" />
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="text" id="PublicBaseUrlOverride" label="Public base URL override" />
|
|
<div class="fieldDescription">Leave empty to derive the public URL from the current request.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="text" id="GuestUsernamePrefix" label="Guest username prefix" />
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer" style="grid-column: 1 / -1;">
|
|
<input is="emby-input" type="text" id="GuestHiddenSelectors" label="Guest hidden selectors (CSS, comma-separated)" />
|
|
<div class="fieldDescription">Elements hidden from guest sessions, e.g. other plugins' buttons. Empty by default.</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input is="emby-input" type="number" id="CleanupIntervalMinutes" label="Cleanup interval (minutes)" min="5" max="10080" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<h3 class="sectionTitle">Playback policy</h3>
|
|
<div class="sl-grid">
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="AllowTranscoding" />
|
|
<span>Allow transcoding</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="AllowRemuxing" />
|
|
<span>Allow remuxing</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="GuestModeLockdownEnabled" />
|
|
<span>Guest lockdown</span>
|
|
</label>
|
|
<div class="fieldDescription">Hides primary navigation for guest sessions in the web client. This is UX only, not the security boundary.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<div class="sl-inline">
|
|
<h3 class="sectionTitle">Share links</h3>
|
|
<button is="emby-button" type="button" id="RefreshLinks" class="raised">
|
|
<span>Refresh</span>
|
|
</button>
|
|
<button is="emby-button" type="button" id="CleanupLinks" class="raised">
|
|
<span>Clean up finished links</span>
|
|
</button>
|
|
<span id="LinksStatus" class="sl-muted sl-list-status">Loading…</span>
|
|
</div>
|
|
<div style="overflow-x:auto; margin-top:0.5rem;">
|
|
<table class="sl-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 9rem;">Status</th>
|
|
<th>Item</th>
|
|
<th style="width: 14rem;">Link</th>
|
|
<th style="width: 11rem;">Guest</th>
|
|
<th style="width: 11rem;">Expires</th>
|
|
<th style="width: 9rem;" class="sl-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="LinksBody">
|
|
<tr>
|
|
<td colspan="6" class="sl-muted">No links loaded yet.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<button is="emby-button" type="submit" class="raised button-submit block">
|
|
<span>Save</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
(function () {
|
|
var ShareLinksPluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
|
|
var page;
|
|
|
|
function loadConfig() {
|
|
Dashboard.showLoadingMsg();
|
|
return ApiClient.getPluginConfiguration(ShareLinksPluginId).then(function (cfg) {
|
|
page.querySelector('#Enabled').checked = cfg.Enabled !== false;
|
|
page.querySelector('#DefaultExpiryHours').value = cfg.DefaultExpiryHours || 24;
|
|
page.querySelector('#MaxExpiryHours').value = cfg.MaxExpiryHours || 720;
|
|
page.querySelector('#PublicBaseUrlOverride').value = cfg.PublicBaseUrlOverride || '';
|
|
page.querySelector('#GuestUsernamePrefix').value = cfg.GuestUsernamePrefix || 'share-';
|
|
page.querySelector('#GuestHiddenSelectors').value = cfg.GuestHiddenSelectors != null ? cfg.GuestHiddenSelectors : '';
|
|
page.querySelector('#AllowTranscoding').checked = cfg.AllowTranscoding !== false;
|
|
page.querySelector('#AllowRemuxing').checked = cfg.AllowRemuxing !== false;
|
|
page.querySelector('#CleanupIntervalMinutes').value = cfg.CleanupIntervalMinutes || 60;
|
|
page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false;
|
|
page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers;
|
|
page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false;
|
|
}).finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
}
|
|
|
|
function fmtDate(value) {
|
|
if (!value) { return 'n/a'; }
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderLinks(list) {
|
|
var body = page.querySelector('#LinksBody');
|
|
var items = Array.isArray(list) ? list.slice().sort(function (a, b) {
|
|
return new Date(b.CreatedAtUtc || b.CreatedAt || 0) - new Date(a.CreatedAtUtc || a.CreatedAt || 0);
|
|
}) : [];
|
|
|
|
if (!items.length) {
|
|
body.innerHTML = '<tr><td colspan="6" class="sl-muted">No active links.</td></tr>';
|
|
page.querySelector('#LinksStatus').textContent = 'No share links.';
|
|
return;
|
|
}
|
|
|
|
body.innerHTML = items.map(function (record) {
|
|
var status = record.Status || 'Unknown';
|
|
var itemName = escapeHtml(record.ItemNameSnapshot || record.ItemId || '');
|
|
var guestName = escapeHtml(record.GuestUserName || 'n/a');
|
|
var expires = fmtDate(record.ExpiresAtUtc || record.ExpiresAt);
|
|
var shareUrl = record.ShareUrl ? escapeHtml(record.ShareUrl) : '';
|
|
var linkCell = shareUrl
|
|
? '<button is="emby-button" type="button" class="raised" data-copy="' + shareUrl + '"><span>Copy</span></button> ' +
|
|
'<a href="' + shareUrl + '" target="_blank" rel="noopener">Open</a>'
|
|
: '<span class="sl-muted">n/a</span>';
|
|
return [
|
|
'<tr>',
|
|
'<td>', escapeHtml(status), '</td>',
|
|
'<td>',
|
|
'<div><strong>', itemName, '</strong></div>',
|
|
'</td>',
|
|
'<td>', linkCell, '</td>',
|
|
'<td>', guestName, '</td>',
|
|
'<td>', escapeHtml(expires), '</td>',
|
|
'<td class="sl-right">',
|
|
'<button is="emby-button" type="button" class="raised" data-id="', escapeHtml(record.Id), '">',
|
|
'<span>Revoke</span>',
|
|
'</button>',
|
|
'</td>',
|
|
'</tr>'
|
|
].join('');
|
|
}).join('');
|
|
|
|
Array.from(body.querySelectorAll('button[data-id]')).forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
revokeLink(button.getAttribute('data-id'));
|
|
});
|
|
});
|
|
|
|
Array.from(body.querySelectorAll('button[data-copy]')).forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
copyShareUrl(button);
|
|
});
|
|
});
|
|
|
|
page.querySelector('#LinksStatus').textContent = items.length + ' link' + (items.length === 1 ? '' : 's') + ' loaded.';
|
|
}
|
|
|
|
function loadLinks() {
|
|
var status = page.querySelector('#LinksStatus');
|
|
status.textContent = 'Loading share links…';
|
|
return ApiClient.ajax({
|
|
type: 'GET',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/List'),
|
|
dataType: 'json'
|
|
}).then(function (list) {
|
|
renderLinks(list || []);
|
|
}).catch(function (error) {
|
|
status.textContent = 'Could not load share links.';
|
|
page.querySelector('#LinksBody').innerHTML = '<tr><td colspan="6" class="sl-muted">' + escapeHtml(error && error.message ? error.message : 'Load failed.') + '</td></tr>';
|
|
});
|
|
}
|
|
|
|
function copyShareUrl(button) {
|
|
var url = button.getAttribute('data-copy');
|
|
if (!url) {
|
|
return;
|
|
}
|
|
|
|
copyText(url).then(function () {
|
|
var span = button.querySelector('span');
|
|
if (!span) {
|
|
return;
|
|
}
|
|
span.textContent = 'Copied';
|
|
window.setTimeout(function () {
|
|
span.textContent = 'Copy';
|
|
}, 2000);
|
|
});
|
|
}
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
return navigator.clipboard.writeText(text).catch(function () {
|
|
return fallbackCopyText(text);
|
|
});
|
|
}
|
|
|
|
return fallbackCopyText(text);
|
|
}
|
|
|
|
function fallbackCopyText(text) {
|
|
var textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.setAttribute('readonly', 'readonly');
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.top = '-1000px';
|
|
textarea.style.left = '-1000px';
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
} catch (error) {
|
|
// Best effort only.
|
|
}
|
|
textarea.remove();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
function revokeLink(id) {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
|
|
var button = page.querySelector('button[data-id="' + id + '"]');
|
|
if (button && button.getAttribute('data-confirm') !== '1') {
|
|
button.setAttribute('data-confirm', '1');
|
|
button.querySelector('span').textContent = 'Confirm revoke';
|
|
window.setTimeout(function () {
|
|
if (button.isConnected && button.getAttribute('data-confirm') === '1') {
|
|
button.removeAttribute('data-confirm');
|
|
button.querySelector('span').textContent = 'Revoke';
|
|
}
|
|
}, 3500);
|
|
return;
|
|
}
|
|
|
|
Dashboard.showLoadingMsg();
|
|
ApiClient.ajax({
|
|
type: 'POST',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/Revoke/' + id),
|
|
dataType: 'json'
|
|
}).then(function () {
|
|
return loadLinks();
|
|
}).finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
}
|
|
|
|
function save(e) {
|
|
e.preventDefault();
|
|
Dashboard.showLoadingMsg();
|
|
ApiClient.getPluginConfiguration(ShareLinksPluginId).then(function (cfg) {
|
|
cfg.Enabled = page.querySelector('#Enabled').checked;
|
|
cfg.DefaultExpiryHours = parseInt(page.querySelector('#DefaultExpiryHours').value, 10) || 24;
|
|
cfg.MaxExpiryHours = parseInt(page.querySelector('#MaxExpiryHours').value, 10) || 720;
|
|
cfg.PublicBaseUrlOverride = page.querySelector('#PublicBaseUrlOverride').value.trim();
|
|
cfg.GuestUsernamePrefix = page.querySelector('#GuestUsernamePrefix').value.trim() || 'share-';
|
|
cfg.GuestHiddenSelectors = page.querySelector('#GuestHiddenSelectors').value;
|
|
cfg.AllowTranscoding = page.querySelector('#AllowTranscoding').checked;
|
|
cfg.AllowRemuxing = page.querySelector('#AllowRemuxing').checked;
|
|
cfg.CleanupIntervalMinutes = parseInt(page.querySelector('#CleanupIntervalMinutes').value, 10) || 60;
|
|
cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked;
|
|
cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0);
|
|
cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked;
|
|
ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
|
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
return loadLinks();
|
|
}).finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
});
|
|
return false;
|
|
}
|
|
|
|
document.querySelector('#ShareLinksConfigPage').addEventListener('pageshow', function () {
|
|
page = this;
|
|
loadConfig().then(loadLinks);
|
|
});
|
|
|
|
document.querySelector('#ShareLinksConfigForm').addEventListener('submit', save);
|
|
document.querySelector('#RefreshLinks').addEventListener('click', function () {
|
|
loadLinks();
|
|
});
|
|
|
|
document.querySelector('#CleanupLinks').addEventListener('click', function () {
|
|
var button = page.querySelector('#CleanupLinks');
|
|
var status = page.querySelector('#LinksStatus');
|
|
button.disabled = true;
|
|
status.textContent = 'Cleaning up…';
|
|
ApiClient.ajax({
|
|
type: 'POST',
|
|
url: ApiClient.getUrl('ShareLinks/Admin/Cleanup'),
|
|
dataType: 'json'
|
|
}).then(function (result) {
|
|
var removed = result && (result.removed !== undefined ? result.removed : result.Removed);
|
|
removed = parseInt(removed, 10) || 0;
|
|
return loadLinks().then(function () {
|
|
page.querySelector('#LinksStatus').textContent = removed === 1
|
|
? '1 finished link removed.'
|
|
: removed + ' finished links removed.';
|
|
});
|
|
}).catch(function () {
|
|
status.textContent = 'Cleanup failed.';
|
|
}).finally(function () {
|
|
button.disabled = false;
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|