1040 lignes
41 KiB
HTML
1040 lignes
41 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" data-i18n="intro">
|
|
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" data-i18n="sectionGeneral">General</h3>
|
|
<div class="sl-grid">
|
|
<div
|
|
class="checkboxContainer checkboxContainer-withDescription"
|
|
>
|
|
<label>
|
|
<input is="emby-checkbox" type="checkbox" id="Enabled" />
|
|
<span data-i18n="enabled">Enable ShareLinks</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
class="checkboxContainer checkboxContainer-withDescription"
|
|
>
|
|
<label>
|
|
<input
|
|
is="emby-checkbox"
|
|
type="checkbox"
|
|
id="OneUseDefault"
|
|
/>
|
|
<span data-i18n="oneUseDefault"
|
|
>New links are single use by default</span
|
|
>
|
|
</label>
|
|
<div
|
|
class="fieldDescription checkboxFieldDescription"
|
|
data-i18n="oneUseDefaultDesc"
|
|
>
|
|
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"
|
|
data-i18n-label="maxConcurrentViewers"
|
|
label="Maximum viewers per multi-use link"
|
|
min="0"
|
|
max="500"
|
|
/>
|
|
<div
|
|
class="fieldDescription"
|
|
data-i18n="maxConcurrentViewersDesc"
|
|
>
|
|
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"
|
|
data-i18n-label="defaultExpiryHours"
|
|
label="Default expiry (hours)"
|
|
min="1"
|
|
max="8760"
|
|
/>
|
|
<div
|
|
class="fieldDescription"
|
|
data-i18n="defaultExpiryHoursDesc"
|
|
>
|
|
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"
|
|
data-i18n-label="maxExpiryHours"
|
|
label="Maximum expiry (hours)"
|
|
min="1"
|
|
max="8760"
|
|
/>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input
|
|
is="emby-input"
|
|
type="text"
|
|
id="PublicBaseUrlOverride"
|
|
data-i18n-label="publicBaseUrlOverride"
|
|
label="Public base URL override"
|
|
/>
|
|
<div
|
|
class="fieldDescription"
|
|
data-i18n="publicBaseUrlOverrideDesc"
|
|
>
|
|
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"
|
|
data-i18n-label="guestUsernamePrefix"
|
|
label="Guest username prefix"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="sl-field inputContainer"
|
|
style="grid-column: 1 / -1"
|
|
>
|
|
<input
|
|
is="emby-input"
|
|
type="text"
|
|
id="GuestHiddenSelectors"
|
|
data-i18n-label="guestHiddenSelectors"
|
|
label="Cosmetic: hide elements from guests (CSS, comma-separated)"
|
|
/>
|
|
<div
|
|
class="fieldDescription"
|
|
data-i18n="guestHiddenSelectorsDesc"
|
|
>
|
|
Tidies the guest's view only. This runs in the browser and
|
|
blocks nothing, so do not rely on it to keep a guest out of
|
|
anything. Use the plugin access section below for that.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-field inputContainer">
|
|
<input
|
|
is="emby-input"
|
|
type="number"
|
|
id="CleanupIntervalMinutes"
|
|
data-i18n-label="cleanupIntervalMinutes"
|
|
label="Cleanup interval (minutes)"
|
|
min="5"
|
|
max="10080"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<h3 class="sectionTitle" data-i18n="sectionPlayback">
|
|
Playback policy
|
|
</h3>
|
|
<div class="sl-grid">
|
|
<div
|
|
class="checkboxContainer checkboxContainer-withDescription"
|
|
>
|
|
<label>
|
|
<input
|
|
is="emby-checkbox"
|
|
type="checkbox"
|
|
id="AllowTranscoding"
|
|
/>
|
|
<span data-i18n="allowTranscoding">Allow transcoding</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
class="checkboxContainer checkboxContainer-withDescription"
|
|
>
|
|
<label>
|
|
<input
|
|
is="emby-checkbox"
|
|
type="checkbox"
|
|
id="AllowRemuxing"
|
|
/>
|
|
<span data-i18n="allowRemuxing">Allow remuxing</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
class="checkboxContainer checkboxContainer-withDescription"
|
|
>
|
|
<label>
|
|
<input
|
|
is="emby-checkbox"
|
|
type="checkbox"
|
|
id="GuestModeLockdownEnabled"
|
|
/>
|
|
<span data-i18n="guestLockdown">Guest lockdown</span>
|
|
</label>
|
|
<div class="fieldDescription" data-i18n="guestLockdownDesc">
|
|
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">
|
|
<h3 class="sectionTitle" data-i18n="sectionPluginAccess">
|
|
Plugin access for guests
|
|
</h3>
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label>
|
|
<input
|
|
is="emby-checkbox"
|
|
type="checkbox"
|
|
id="GuestPluginApiGuardEnabled"
|
|
/>
|
|
<span data-i18n="blockOtherPlugins"
|
|
>Block other plugins for guests</span
|
|
>
|
|
</label>
|
|
<div class="fieldDescription" data-i18n="blockOtherPluginsDesc">
|
|
Refuses guest accounts on other plugins' API endpoints, on the
|
|
server. A guest holds a real Jellyfin token, so without this
|
|
any installed plugin answers them directly, whatever the web
|
|
client shows. Jellyfin's own API stays available: the share
|
|
tag already limits it to the shared title, and playback needs
|
|
it.
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="sl-muted"
|
|
style="margin-top: 0.75rem"
|
|
data-i18n="pluginAccessHint"
|
|
>
|
|
Tick a plugin to let guests reach it anyway. Leave everything
|
|
unticked unless a plugin needs to serve guests during playback,
|
|
such as an intro skipper.
|
|
</div>
|
|
<div id="GuestAllowedPlugins" style="margin-top: 0.5rem">
|
|
<div class="sl-muted" data-i18n="loadingPlugins">
|
|
Loading plugins…
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sl-section">
|
|
<div class="sl-inline">
|
|
<h3 class="sectionTitle" data-i18n="sectionShareLinks">
|
|
Share links
|
|
</h3>
|
|
<button
|
|
is="emby-button"
|
|
type="button"
|
|
id="RefreshLinks"
|
|
class="raised"
|
|
>
|
|
<span data-i18n="refresh">Refresh</span>
|
|
</button>
|
|
<button
|
|
is="emby-button"
|
|
type="button"
|
|
id="CleanupLinks"
|
|
class="raised"
|
|
>
|
|
<span data-i18n="cleanupFinished"
|
|
>Clean up finished links</span
|
|
>
|
|
</button>
|
|
<span
|
|
id="LinksStatus"
|
|
class="sl-muted sl-list-status"
|
|
data-i18n="loading"
|
|
>Loading…</span
|
|
>
|
|
</div>
|
|
<div style="overflow-x: auto; margin-top: 0.5rem">
|
|
<table class="sl-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 9rem" data-i18n="colStatus">Status</th>
|
|
<th data-i18n="colItem">Item</th>
|
|
<th style="width: 14rem" data-i18n="colLink">Link</th>
|
|
<th style="width: 11rem" data-i18n="colGuest">Guest</th>
|
|
<th style="width: 11rem" data-i18n="colExpires">
|
|
Expires
|
|
</th>
|
|
<th
|
|
style="width: 9rem"
|
|
class="sl-right"
|
|
data-i18n="colActions"
|
|
>
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="LinksBody">
|
|
<tr>
|
|
<td colspan="6" class="sl-muted" data-i18n="noLinksYet">
|
|
No links loaded yet.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
is="emby-button"
|
|
type="submit"
|
|
class="raised button-submit block"
|
|
>
|
|
<span data-i18n="save">Save</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
(function () {
|
|
var ShareLinksPluginId = "68540b76-ee74-436d-85ff-2abc884bbea6";
|
|
var page;
|
|
|
|
function isFrench() {
|
|
var lang =
|
|
document.documentElement.getAttribute("lang") ||
|
|
(navigator && (navigator.language || navigator.userLanguage)) ||
|
|
"en";
|
|
return /^fr/i.test(lang);
|
|
}
|
|
|
|
var STRINGS = {
|
|
en: {
|
|
intro:
|
|
"Creates short-lived guest links for movies and episodes, then keeps the active records visible for revocation.",
|
|
sectionGeneral: "General",
|
|
enabled: "Enable ShareLinks",
|
|
oneUseDefault: "New links are single use by default",
|
|
oneUseDefaultDesc:
|
|
'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.',
|
|
maxConcurrentViewers: "Maximum viewers per multi-use link",
|
|
maxConcurrentViewersDesc:
|
|
"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.",
|
|
defaultExpiryHours: "Default expiry (hours)",
|
|
defaultExpiryHoursDesc:
|
|
"Used by the menu action when the admin accepts the default.",
|
|
maxExpiryHours: "Maximum expiry (hours)",
|
|
publicBaseUrlOverride: "Public base URL override",
|
|
publicBaseUrlOverrideDesc:
|
|
"Leave empty to derive the public URL from the current request.",
|
|
guestUsernamePrefix: "Guest username prefix",
|
|
guestHiddenSelectors:
|
|
"Cosmetic: hide elements from guests (CSS, comma-separated)",
|
|
guestHiddenSelectorsDesc:
|
|
"Tidies the guest's view only. This runs in the browser and blocks nothing, so do not rely on it to keep a guest out of anything. Use the plugin access section below for that.",
|
|
cleanupIntervalMinutes: "Cleanup interval (minutes)",
|
|
sectionPlayback: "Playback policy",
|
|
allowTranscoding: "Allow transcoding",
|
|
allowRemuxing: "Allow remuxing",
|
|
guestLockdown: "Guest lockdown",
|
|
guestLockdownDesc:
|
|
"Hides primary navigation for guest sessions in the web client. This is UX only, not the security boundary.",
|
|
sectionPluginAccess: "Plugin access for guests",
|
|
blockOtherPlugins: "Block other plugins for guests",
|
|
blockOtherPluginsDesc:
|
|
"Refuses guest accounts on other plugins' API endpoints, on the server. A guest holds a real Jellyfin token, so without this any installed plugin answers them directly, whatever the web client shows. Jellyfin's own API stays available: the share tag already limits it to the shared title, and playback needs it.",
|
|
pluginAccessHint:
|
|
"Tick a plugin to let guests reach it anyway. Leave everything unticked unless a plugin needs to serve guests during playback, such as an intro skipper.",
|
|
loadingPlugins: "Loading plugins…",
|
|
noOtherPlugins: "No other plugins installed.",
|
|
couldNotLoadPlugins: "Could not load the plugin list.",
|
|
sectionShareLinks: "Share links",
|
|
refresh: "Refresh",
|
|
cleanupFinished: "Clean up finished links",
|
|
loading: "Loading…",
|
|
colStatus: "Status",
|
|
colItem: "Item",
|
|
colLink: "Link",
|
|
colGuest: "Guest",
|
|
colExpires: "Expires",
|
|
colActions: "Actions",
|
|
noLinksYet: "No links loaded yet.",
|
|
noActiveLinks: "No active links.",
|
|
noShareLinks: "No share links.",
|
|
save: "Save",
|
|
copy: "Copy",
|
|
copied: "Copied",
|
|
open: "Open",
|
|
revoke: "Revoke",
|
|
confirmRevoke: "Confirm revoke",
|
|
notAvailable: "n/a",
|
|
loadingShareLinks: "Loading share links…",
|
|
couldNotLoadLinks: "Could not load share links.",
|
|
loadFailed: "Load failed.",
|
|
cleaningUp: "Cleaning up…",
|
|
cleanupFailed: "Cleanup failed.",
|
|
linkCountOne: "1 link loaded.",
|
|
linkCountOther: "{count} links loaded.",
|
|
removedCountOne: "1 finished link removed.",
|
|
removedCountOther: "{count} finished links removed.",
|
|
},
|
|
fr: {
|
|
intro:
|
|
"Crée des liens invités temporaires pour les films et épisodes, et garde les liens actifs visibles pour pouvoir les révoquer.",
|
|
sectionGeneral: "Général",
|
|
enabled: "Activer ShareLinks",
|
|
oneUseDefault:
|
|
"Les nouveaux liens sont à usage unique par défaut",
|
|
oneUseDefaultDesc:
|
|
"Ceci ne détermine que l'état initial de la case \"Autoriser plusieurs personnes à utiliser ce lien\" dans la fenêtre de création ; vous pouvez le changer pour chaque lien créé. Un lien à usage unique cesse de fonctionner dès qu'une première personne l'ouvre, et seule cette personne garde l'accès jusqu'à expiration. Un lien multi-usage peut être ouvert par toutes les personnes à qui vous l'envoyez, tant qu'il reste valide.",
|
|
maxConcurrentViewers:
|
|
"Nombre maximal de spectateurs par lien multi-usage",
|
|
maxConcurrentViewersDesc:
|
|
"Combien de personnes peuvent regarder un lien multi-usage en même temps. 0 signifie aucune limite. Une personne arrivant une fois la limite atteinte est invitée à réessayer plus tard ; personne parmi celles déjà en train de regarder n'est interrompu. Les liens à usage unique sont toujours limités à un spectateur.",
|
|
defaultExpiryHours: "Expiration par défaut (heures)",
|
|
defaultExpiryHoursDesc:
|
|
"Utilisé par l'action du menu lorsque l'administrateur accepte la valeur par défaut.",
|
|
maxExpiryHours: "Expiration maximale (heures)",
|
|
publicBaseUrlOverride: "URL publique personnalisée",
|
|
publicBaseUrlOverrideDesc:
|
|
"Laissez vide pour déduire l'URL publique à partir de la requête en cours.",
|
|
guestUsernamePrefix: "Préfixe du nom d'utilisateur invité",
|
|
guestHiddenSelectors:
|
|
"Esthétique : masquer des éléments pour les invités (CSS, séparés par des virgules)",
|
|
guestHiddenSelectorsDesc:
|
|
"N'améliore que l'apparence pour l'invité. Ceci s'exécute dans le navigateur et ne bloque rien : ne vous en servez pas pour empêcher un invité d'accéder à quoi que ce soit. Utilisez la section \"Accès aux extensions\" ci-dessous pour cela.",
|
|
cleanupIntervalMinutes: "Intervalle de nettoyage (minutes)",
|
|
sectionPlayback: "Politique de lecture",
|
|
allowTranscoding: "Autoriser le transcodage",
|
|
allowRemuxing: "Autoriser le remuxage",
|
|
guestLockdown: "Verrouillage invité",
|
|
guestLockdownDesc:
|
|
"Masque la navigation principale pour les sessions invitées dans le client web. Ceci est purement cosmétique, ce n'est pas la barrière de sécurité.",
|
|
sectionPluginAccess: "Accès aux extensions pour les invités",
|
|
blockOtherPlugins:
|
|
"Bloquer les autres extensions pour les invités",
|
|
blockOtherPluginsDesc:
|
|
"Refuse les comptes invités sur les points d'accès API des autres extensions, côté serveur. Un invité possède un vrai jeton Jellyfin : sans cette option, toute extension installée lui répond directement, quoi qu'affiche le client web. L'API propre à Jellyfin reste accessible : le tag de partage la limite déjà au titre partagé, et la lecture en a besoin.",
|
|
pluginAccessHint:
|
|
'Cochez une extension pour permettre malgré tout aux invités d\'y accéder. Laissez tout décoché sauf si une extension doit servir les invités pendant la lecture, comme un "intro skipper".',
|
|
loadingPlugins: "Chargement des extensions…",
|
|
noOtherPlugins: "Aucune autre extension installée.",
|
|
couldNotLoadPlugins:
|
|
"Impossible de charger la liste des extensions.",
|
|
sectionShareLinks: "Liens de partage",
|
|
refresh: "Actualiser",
|
|
cleanupFinished: "Nettoyer les liens terminés",
|
|
loading: "Chargement…",
|
|
colStatus: "Statut",
|
|
colItem: "Élément",
|
|
colLink: "Lien",
|
|
colGuest: "Invité",
|
|
colExpires: "Expire",
|
|
colActions: "Actions",
|
|
noLinksYet: "Aucun lien chargé pour le moment.",
|
|
noActiveLinks: "Aucun lien actif.",
|
|
noShareLinks: "Aucun lien de partage.",
|
|
save: "Enregistrer",
|
|
copy: "Copier",
|
|
copied: "Copié",
|
|
open: "Ouvrir",
|
|
revoke: "Révoquer",
|
|
confirmRevoke: "Confirmer la révocation",
|
|
notAvailable: "n/d",
|
|
loadingShareLinks: "Chargement des liens de partage…",
|
|
couldNotLoadLinks: "Impossible de charger les liens de partage.",
|
|
loadFailed: "Échec du chargement.",
|
|
cleaningUp: "Nettoyage en cours…",
|
|
cleanupFailed: "Échec du nettoyage.",
|
|
linkCountOne: "1 lien chargé.",
|
|
linkCountOther: "{count} liens chargés.",
|
|
removedCountOne: "1 lien terminé supprimé.",
|
|
removedCountOther: "{count} liens terminés supprimés.",
|
|
},
|
|
};
|
|
|
|
function t(key, replacements) {
|
|
var lang = isFrench() ? "fr" : "en";
|
|
var value =
|
|
(STRINGS[lang] && STRINGS[lang][key]) || STRINGS.en[key] || key;
|
|
if (replacements) {
|
|
Object.keys(replacements).forEach(function (name) {
|
|
value = value.replace("{" + name + "}", replacements[name]);
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function applyStaticTranslations() {
|
|
Array.prototype.forEach.call(
|
|
page.querySelectorAll("[data-i18n]"),
|
|
function (node) {
|
|
node.textContent = t(node.getAttribute("data-i18n"));
|
|
}
|
|
);
|
|
Array.prototype.forEach.call(
|
|
page.querySelectorAll("[data-i18n-label]"),
|
|
function (node) {
|
|
node.setAttribute(
|
|
"label",
|
|
t(node.getAttribute("data-i18n-label"))
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
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;
|
|
page.querySelector("#GuestPluginApiGuardEnabled").checked =
|
|
cfg.GuestPluginApiGuardEnabled !== false;
|
|
})
|
|
.finally(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
}
|
|
|
|
// The ticked state comes from the server rather than from the config we
|
|
// just loaded, so a plugin that has since been uninstalled drops out of
|
|
// the list instead of lingering as a stale checkbox.
|
|
function loadPlugins() {
|
|
var host = page.querySelector("#GuestAllowedPlugins");
|
|
return ApiClient.ajax({
|
|
type: "GET",
|
|
url: ApiClient.getUrl("ShareLinks/Admin/Plugins"),
|
|
dataType: "json",
|
|
})
|
|
.then(function (list) {
|
|
var items = Array.isArray(list) ? list : [];
|
|
if (!items.length) {
|
|
host.innerHTML =
|
|
'<div class="sl-muted">' + t("noOtherPlugins") + "</div>";
|
|
return;
|
|
}
|
|
|
|
host.innerHTML = items
|
|
.map(function (plugin) {
|
|
return (
|
|
'<div class="checkboxContainer">' +
|
|
"<label>" +
|
|
'<input is="emby-checkbox" type="checkbox" class="sl-plugin-allow" data-plugin-id="' +
|
|
escapeHtml(plugin.Id) +
|
|
'"' +
|
|
(plugin.AllowedForGuests ? " checked" : "") +
|
|
" />" +
|
|
"<span>" +
|
|
escapeHtml(plugin.Name) +
|
|
"</span>" +
|
|
"</label>" +
|
|
"</div>"
|
|
);
|
|
})
|
|
.join("");
|
|
})
|
|
.catch(function () {
|
|
host.innerHTML =
|
|
'<div class="sl-muted">' +
|
|
t("couldNotLoadPlugins") +
|
|
"</div>";
|
|
});
|
|
}
|
|
|
|
function collectAllowedPluginIds() {
|
|
// A failed plugin load leaves no checkboxes to read. Returning the saved
|
|
// config untouched in that case avoids silently clearing the exceptions.
|
|
var boxes = page.querySelectorAll(".sl-plugin-allow");
|
|
if (!boxes.length) {
|
|
return null;
|
|
}
|
|
|
|
return Array.prototype.filter
|
|
.call(boxes, function (box) {
|
|
return box.checked;
|
|
})
|
|
.map(function (box) {
|
|
return box.getAttribute("data-plugin-id");
|
|
});
|
|
}
|
|
|
|
function fmtDate(value) {
|
|
if (!value) {
|
|
return t("notAvailable");
|
|
}
|
|
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">' +
|
|
t("noActiveLinks") +
|
|
"</td></tr>";
|
|
page.querySelector("#LinksStatus").textContent = t(
|
|
"noShareLinks"
|
|
);
|
|
return;
|
|
}
|
|
|
|
body.innerHTML = items
|
|
.map(function (record) {
|
|
var status = record.Status || "Unknown";
|
|
var itemName = escapeHtml(
|
|
record.ItemNameSnapshot || record.ItemId || ""
|
|
);
|
|
var guestName = escapeHtml(
|
|
record.GuestUserName || t("notAvailable")
|
|
);
|
|
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>' +
|
|
t("copy") +
|
|
"</span></button> " +
|
|
'<a href="' +
|
|
shareUrl +
|
|
'" target="_blank" rel="noopener">' +
|
|
t("open") +
|
|
"</a>"
|
|
: '<span class="sl-muted">' + t("notAvailable") + "</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>",
|
|
t("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 === 1
|
|
? t("linkCountOne")
|
|
: t("linkCountOther", { count: items.length });
|
|
}
|
|
|
|
function loadLinks() {
|
|
var status = page.querySelector("#LinksStatus");
|
|
status.textContent = t("loadingShareLinks");
|
|
return ApiClient.ajax({
|
|
type: "GET",
|
|
url: ApiClient.getUrl("ShareLinks/Admin/List"),
|
|
dataType: "json",
|
|
})
|
|
.then(function (list) {
|
|
renderLinks(list || []);
|
|
})
|
|
.catch(function (error) {
|
|
status.textContent = t("couldNotLoadLinks");
|
|
page.querySelector("#LinksBody").innerHTML =
|
|
'<tr><td colspan="6" class="sl-muted">' +
|
|
escapeHtml(
|
|
error && error.message ? error.message : t("loadFailed")
|
|
) +
|
|
"</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 = t("copied");
|
|
window.setTimeout(function () {
|
|
span.textContent = t("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 = t("confirmRevoke");
|
|
window.setTimeout(function () {
|
|
if (
|
|
button.isConnected &&
|
|
button.getAttribute("data-confirm") === "1"
|
|
) {
|
|
button.removeAttribute("data-confirm");
|
|
button.querySelector("span").textContent = t("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;
|
|
cfg.GuestPluginApiGuardEnabled = page.querySelector(
|
|
"#GuestPluginApiGuardEnabled"
|
|
).checked;
|
|
|
|
var allowedPluginIds = collectAllowedPluginIds();
|
|
if (allowedPluginIds !== null) {
|
|
cfg.GuestAllowedPluginIds = allowedPluginIds;
|
|
}
|
|
|
|
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;
|
|
applyStaticTranslations();
|
|
loadConfig().then(loadPlugins).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 = t("cleaningUp");
|
|
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
|
|
? t("removedCountOne")
|
|
: t("removedCountOther", { count: removed });
|
|
});
|
|
})
|
|
.catch(function () {
|
|
status.textContent = t("cleanupFailed");
|
|
})
|
|
.finally(function () {
|
|
button.disabled = false;
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|