Sharing a series or season now tags the entire tree (series, seasons, episodes) so the guest can browse and play everything inside it, and strips it all again at cleanup. Redeeming re-tags the tree, so episodes added after the link was created show up on the next redemption. The guest lockdown in the web client now asks the server whether a page's item is visible to the guest instead of hard-coding the single shared id, so guests can navigate inside the shared tree but nowhere else. Libraries and collections are still rejected. Bumps the version to 1.0.1.0.
1296 lignes
45 KiB
JavaScript
1296 lignes
45 KiB
JavaScript
(function () {
|
|
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
|
|
var copyLabel = 'Copy Stream URL';
|
|
var actionLabel = 'ShareLink';
|
|
var clientVersion = '1.0.1-ui-1';
|
|
var allowedItemStorageKey = 'sharelinks.allowedItemId';
|
|
var guestClassName = 'sharelinks-guest';
|
|
var hiddenAttr = 'data-sharelinks-hidden';
|
|
var injectedAttr = 'data-sharelinks-injected';
|
|
var configPromise = null;
|
|
var userPromise = null;
|
|
var guestStatePromise = null;
|
|
var booted = false;
|
|
var scanQueued = false;
|
|
var bootRetry = null;
|
|
var observer = null;
|
|
var lastContextItemId = null;
|
|
var lastContextItemTs = 0;
|
|
var historyPatched = false;
|
|
var itemMenuActionIds = [
|
|
'moreinfo', 'mediainfo', 'editmetadata', 'editimages', 'editsubtitles',
|
|
'editlyrics', 'identify', 'refreshmetadata', 'refresh', 'playlist',
|
|
'addtoplaylist', 'addtocollection', 'instantmix', 'shuffle', 'resume',
|
|
'copy-stream', 'copystream', 'share', 'download', 'delete',
|
|
'edit'
|
|
];
|
|
var durationOptions = [
|
|
{ label: '1 hour', hours: 1 },
|
|
{ label: '2 hours', hours: 2 },
|
|
{ label: '4 hours', hours: 4 },
|
|
{ label: '6 hours', hours: 6 },
|
|
{ label: '12 hours', hours: 12 },
|
|
{ label: '1 day', hours: 24 },
|
|
{ label: '2 days', hours: 48 },
|
|
{ label: '7 days', hours: 168 }
|
|
];
|
|
|
|
function isFrench() {
|
|
var lang = (document.documentElement.getAttribute('lang')
|
|
|| (navigator && (navigator.language || navigator.userLanguage))
|
|
|| 'en');
|
|
return /^fr/i.test(lang);
|
|
}
|
|
|
|
var STRINGS = {
|
|
en: {
|
|
modalTitle: 'Create guest link',
|
|
modalBody: 'Choose how long this link should stay valid.',
|
|
dateLabel: 'Or pick an exact expiry date and time:',
|
|
create: 'Create',
|
|
cancel: 'Cancel',
|
|
copy: 'Copy',
|
|
done: 'Done',
|
|
pickDateFirst: 'Pick a date and time first.',
|
|
dateInvalid: 'That date is not valid.',
|
|
pickFuture: 'Pick a time in the future.',
|
|
cannotDetermineItem: 'Could not determine which item to share. Open the item page and retry.',
|
|
adminOnly: 'ShareLinks is available to administrators only.',
|
|
disabled: 'ShareLinks is disabled.',
|
|
noShareUrl: 'The server did not return a share URL.',
|
|
couldNotCreate: 'Could not create a guest link.',
|
|
copiedNote: 'The link was copied to your clipboard.',
|
|
notCopiedNote: 'The link was created, but the browser blocked automatic clipboard access.',
|
|
resultCopiedTitle: 'Share link copied',
|
|
resultCreatedTitle: 'Share link created',
|
|
toastCopied: 'Share link copied.',
|
|
toastManual: 'Select and copy the link manually.',
|
|
hour: 'hour', hours: 'hours', day: 'day', days: 'days'
|
|
},
|
|
fr: {
|
|
modalTitle: 'Créer un lien invité',
|
|
modalBody: 'Choisissez la durée de validité de ce lien.',
|
|
dateLabel: 'Ou choisissez une date et une heure d\'expiration précises :',
|
|
create: 'Créer',
|
|
cancel: 'Annuler',
|
|
copy: 'Copier',
|
|
done: 'Terminé',
|
|
pickDateFirst: 'Choisissez d\'abord une date et une heure.',
|
|
dateInvalid: 'Cette date n\'est pas valide.',
|
|
pickFuture: 'Choisissez une date dans le futur.',
|
|
cannotDetermineItem: 'Impossible de déterminer l\'élément à partager. Ouvrez la page du média et réessayez.',
|
|
adminOnly: 'ShareLinks est réservé aux administrateurs.',
|
|
disabled: 'ShareLinks est désactivé.',
|
|
noShareUrl: 'Le serveur n\'a pas renvoyé de lien de partage.',
|
|
couldNotCreate: 'Impossible de créer le lien invité.',
|
|
copiedNote: 'Le lien a été copié dans le presse-papiers.',
|
|
notCopiedNote: 'Le lien a été créé, mais le navigateur a bloqué l\'accès automatique au presse-papiers.',
|
|
resultCopiedTitle: 'Lien de partage copié',
|
|
resultCreatedTitle: 'Lien de partage créé',
|
|
toastCopied: 'Lien de partage copié.',
|
|
toastManual: 'Sélectionnez et copiez le lien manuellement.',
|
|
hour: 'heure', hours: 'heures', day: 'jour', days: 'jours'
|
|
}
|
|
};
|
|
|
|
function t(key) {
|
|
var lang = isFrench() ? 'fr' : 'en';
|
|
return (STRINGS[lang] && STRINGS[lang][key]) || STRINGS.en[key] || key;
|
|
}
|
|
|
|
function durationLabel(hours) {
|
|
if (hours < 24) {
|
|
return hours + ' ' + t(hours === 1 ? 'hour' : 'hours');
|
|
}
|
|
var d = hours / 24;
|
|
return d + ' ' + t(d === 1 ? 'day' : 'days');
|
|
}
|
|
|
|
window.ShareLinksClientVersion = clientVersion;
|
|
|
|
function ready() {
|
|
return !!window.ApiClient && !!window.document && !!document.body;
|
|
}
|
|
|
|
function start() {
|
|
if (booted) {
|
|
return;
|
|
}
|
|
|
|
if (!ready()) {
|
|
if (!bootRetry) {
|
|
bootRetry = window.setTimeout(function () {
|
|
bootRetry = null;
|
|
start();
|
|
}, 250);
|
|
}
|
|
return;
|
|
}
|
|
|
|
booted = true;
|
|
installHooks();
|
|
scheduleWork();
|
|
}
|
|
|
|
function installHooks() {
|
|
if (!historyPatched) {
|
|
historyPatched = true;
|
|
patchHistory();
|
|
}
|
|
|
|
window.addEventListener('hashchange', scheduleWork, true);
|
|
window.addEventListener('popstate', scheduleWork, true);
|
|
|
|
document.addEventListener('pointerdown', function (event) {
|
|
var node = event.target;
|
|
while (node && node !== document) {
|
|
var id = readItemIdFromNode(node);
|
|
if (id) {
|
|
lastContextItemId = id;
|
|
lastContextItemTs = Date.now();
|
|
return;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
}, true);
|
|
|
|
observer = new MutationObserver(scheduleWork);
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
|
|
window.setInterval(scheduleWork, 3000);
|
|
}
|
|
|
|
function patchHistory() {
|
|
var pushState = history.pushState;
|
|
var replaceState = history.replaceState;
|
|
|
|
history.pushState = function () {
|
|
var result = pushState.apply(this, arguments);
|
|
scheduleWork();
|
|
return result;
|
|
};
|
|
|
|
history.replaceState = function () {
|
|
var result = replaceState.apply(this, arguments);
|
|
scheduleWork();
|
|
return result;
|
|
};
|
|
}
|
|
|
|
function scheduleWork() {
|
|
if (scanQueued || !ready()) {
|
|
return;
|
|
}
|
|
|
|
scanQueued = true;
|
|
window.requestAnimationFrame(function () {
|
|
scanQueued = false;
|
|
refresh().catch(function () {
|
|
// Best effort only. The menu hook should never block the web UI.
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refresh() {
|
|
rememberAllowedItemFromRoute();
|
|
await applyGuestLockdown();
|
|
await scanForMoreMenuActions();
|
|
}
|
|
|
|
function apiGet(path) {
|
|
return ApiClient.ajax({
|
|
type: 'GET',
|
|
url: ApiClient.getUrl(path),
|
|
dataType: 'json'
|
|
});
|
|
}
|
|
|
|
function apiPost(path, body) {
|
|
return ApiClient.ajax({
|
|
type: 'POST',
|
|
url: ApiClient.getUrl(path),
|
|
dataType: 'json',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(body || {})
|
|
});
|
|
}
|
|
|
|
function getConfig() {
|
|
if (!configPromise) {
|
|
configPromise = ApiClient.getPluginConfiguration(pluginId).catch(function () {
|
|
return {};
|
|
});
|
|
}
|
|
return configPromise;
|
|
}
|
|
|
|
function getCurrentUser() {
|
|
if (!userPromise) {
|
|
userPromise = apiGet('Users/Me').catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
return userPromise;
|
|
}
|
|
|
|
function getGuestState() {
|
|
if (!guestStatePromise) {
|
|
guestStatePromise = apiGet('ShareLinks/GuestState').catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
return guestStatePromise;
|
|
}
|
|
|
|
function hideBlockedGuestMenuItems() {
|
|
var nodes = document.querySelectorAll('.actionSheetMenuItem');
|
|
Array.prototype.forEach.call(nodes, function (node) {
|
|
if (!node || node.getAttribute('data-sharelinks-blocked') === '1') {
|
|
return;
|
|
}
|
|
|
|
var dataId = String(node.getAttribute('data-id') || '').toLowerCase();
|
|
var label = getVisibleLabel(node).toLowerCase();
|
|
var icon = node.querySelector('.material-icons');
|
|
var iconClass = icon ? String(icon.className || '') : '';
|
|
|
|
var blocked = dataId === 'playlist'
|
|
|| dataId === 'addtoplaylist'
|
|
|| dataId === 'addtocollection'
|
|
|| label.indexOf('liste de lecture') >= 0
|
|
|| label.indexOf('playlist') >= 0
|
|
|| label.indexOf('add to collection') >= 0
|
|
|| label.indexOf('ajouter à la collection') >= 0
|
|
|| (iconClass.indexOf('playlist_add') >= 0 && dataId !== 'queue' && dataId !== 'queuenext');
|
|
|
|
if (blocked) {
|
|
node.setAttribute('data-sharelinks-blocked', '1');
|
|
node.style.setProperty('display', 'none', 'important');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function applyGuestLockdown() {
|
|
var context = await getGuestContext();
|
|
if (!context.locked) {
|
|
return;
|
|
}
|
|
|
|
ensureGuestStyle();
|
|
ensurePluginHideStyle(context.hiddenSelectors);
|
|
hideGuestControls();
|
|
hideBlockedGuestMenuItems();
|
|
|
|
// UX-only lockdown: the guest user's real access boundary is still the
|
|
// server-side policy and item tags. This just keeps the web client out
|
|
// of the user's way, while still letting the guest browse into the
|
|
// shared tree (series -> season -> episode) when the server says the
|
|
// item is visible to them.
|
|
if (!context.allowedItemId) {
|
|
return;
|
|
}
|
|
|
|
var verdict = await checkAllowedLocation(context.allowedItemId);
|
|
if (verdict === false) {
|
|
navigateToItem(context.allowedItemId);
|
|
}
|
|
// verdict === true: allowed, do nothing.
|
|
// verdict === null: check still in flight or route is not item-scoped
|
|
// in a way we can verify yet; do nothing to avoid flicker.
|
|
}
|
|
|
|
async function getGuestContext() {
|
|
var config = await getConfig();
|
|
var user = await getCurrentUser();
|
|
var state = await getGuestState();
|
|
var prefix = config && config.GuestUsernamePrefix ? String(config.GuestUsernamePrefix) : 'share-';
|
|
var username = user && user.Name ? String(user.Name) : '';
|
|
var lockdownEnabled = config && config.GuestModeLockdownEnabled !== false;
|
|
if (state && state.lockdownEnabled === false) {
|
|
lockdownEnabled = false;
|
|
}
|
|
|
|
var locked = lockdownEnabled && (
|
|
(username && username.indexOf(prefix) === 0)
|
|
|| !!(state && (state.IsGuest === true || state.isGuest === true || state.GuestUserId || state.guestUserId))
|
|
);
|
|
return {
|
|
locked: locked,
|
|
allowedItemId: extractAllowedItemId(state) || sessionStorage.getItem(allowedItemStorageKey) || null,
|
|
username: username,
|
|
prefix: prefix,
|
|
lockdownEnabled: lockdownEnabled,
|
|
hiddenSelectors: (state && (state.HiddenSelectors || state.hiddenSelectors)) || ''
|
|
};
|
|
}
|
|
|
|
function extractAllowedItemId(state) {
|
|
if (!state) {
|
|
return null;
|
|
}
|
|
|
|
return state.AllowedItemId || state.allowedItemId || state.ItemId || state.itemId || state.ShareItemId || state.shareItemId || null;
|
|
}
|
|
|
|
function ensureGuestStyle() {
|
|
if (document.body.classList.contains(guestClassName)) {
|
|
return;
|
|
}
|
|
|
|
document.body.classList.add(guestClassName);
|
|
if (document.getElementById('ShareLinksGuestStyle')) {
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'ShareLinksGuestStyle';
|
|
style.textContent = 'body.' + guestClassName + ' [' + hiddenAttr + '="1"],'
|
|
+ ' body.' + guestClassName + ' .headerHomeButton,'
|
|
+ ' body.' + guestClassName + ' .mainDrawerButton,'
|
|
+ ' body.' + guestClassName + ' .headerSearchButton,'
|
|
+ ' body.' + guestClassName + ' [data-action="addtoplaylist"],'
|
|
+ ' body.' + guestClassName + ' [data-action="addtocollection"],'
|
|
+ ' body.' + guestClassName + ' [data-id="playlist"],'
|
|
+ ' body.' + guestClassName + ' [data-id="addtocollection"] { display: none !important; }'
|
|
+ ' body.' + guestClassName + ' .card,'
|
|
+ ' body.' + guestClassName + ' #castCollapsible a,'
|
|
+ ' body.' + guestClassName + ' .detailsGroupItem a,'
|
|
+ ' body.' + guestClassName + ' .genresGroup a,'
|
|
+ ' body.' + guestClassName + ' .studiosGroup a,'
|
|
+ ' body.' + guestClassName + ' .itemTags a,'
|
|
+ ' body.' + guestClassName + ' .mediaInfoItem a { pointer-events: none !important; cursor: default !important; }';
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function ensurePluginHideStyle(selectors) {
|
|
var value = String(selectors || '').split(',').map(function (part) {
|
|
return part.trim();
|
|
}).filter(Boolean);
|
|
var existing = document.getElementById('ShareLinksPluginHideStyle');
|
|
if (value.length === 0) {
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var css = value.join(', ') + ' { display: none !important; }';
|
|
if (existing) {
|
|
if (existing.textContent !== css) {
|
|
existing.textContent = css;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'ShareLinksPluginHideStyle';
|
|
style.textContent = css;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function hideGuestControls() {
|
|
var roots = [
|
|
document.querySelector('.skinHeader'),
|
|
document.querySelector('.mainDrawer'),
|
|
document.querySelector('.mainDrawerPanel'),
|
|
document.querySelector('.pageContainer'),
|
|
document.body
|
|
].filter(Boolean);
|
|
var keywords = ['home', 'search', 'library', 'settings', 'download', 'share'];
|
|
|
|
roots.forEach(function (root) {
|
|
Array.from(root.querySelectorAll('button, a, [role="button"], [role="menuitem"]')).forEach(function (node) {
|
|
if (shouldHideNode(node, keywords)) {
|
|
node.setAttribute(hiddenAttr, '1');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function shouldHideNode(node, keywords) {
|
|
if (!node || node.getAttribute(hiddenAttr) === '1') {
|
|
return false;
|
|
}
|
|
|
|
var label = getVisibleLabel(node).toLowerCase();
|
|
if (!label) {
|
|
return false;
|
|
}
|
|
|
|
return keywords.some(function (word) {
|
|
return label.indexOf(word) >= 0;
|
|
});
|
|
}
|
|
|
|
function getVisibleLabel(node) {
|
|
return normalizeText(node.getAttribute('aria-label') || node.getAttribute('title') || node.textContent || '');
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function isItemGuid(value) {
|
|
return /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(String(value || '').trim());
|
|
}
|
|
|
|
async function scanForMoreMenuActions() {
|
|
var user = await getCurrentUser();
|
|
if (!isAdministrator(user)) {
|
|
return;
|
|
}
|
|
|
|
var copyNodes = [];
|
|
Array.from(document.querySelectorAll('button, a, [role="menuitem"], [role="option"], .actionsheetMenuItem, .paperListButton')).forEach(function (node) {
|
|
if (isCopyStreamUrlLabel(getVisibleLabel(node))) {
|
|
copyNodes.push(node);
|
|
insertActionAfter(node);
|
|
}
|
|
});
|
|
|
|
if (copyNodes.length === 0) {
|
|
insertIntoOpenMenuFallback();
|
|
}
|
|
}
|
|
|
|
function isCopyStreamUrlLabel(label) {
|
|
var value = normalizeText(label).toLowerCase();
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
|
|
return value === copyLabel.toLowerCase()
|
|
|| (value.indexOf('copy') >= 0 && value.indexOf('stream') >= 0 && value.indexOf('url') >= 0)
|
|
|| (value.indexOf('copier') >= 0 && value.indexOf('url') >= 0 && (value.indexOf('flux') >= 0 || value.indexOf('stream') >= 0));
|
|
}
|
|
|
|
function insertIntoOpenMenuFallback() {
|
|
var itemId = resolveItemId(document);
|
|
if (!itemId) {
|
|
return;
|
|
}
|
|
|
|
var container = findOpenActionContainer();
|
|
if (!container || container.querySelector('[' + injectedAttr + '="1"]')) {
|
|
return;
|
|
}
|
|
|
|
var template = findBestActionTemplate(container);
|
|
if (!template) {
|
|
return;
|
|
}
|
|
|
|
insertActionAfter(template, itemId, true);
|
|
}
|
|
|
|
function isItemMenuAction(node) {
|
|
if (!node || !node.getAttribute) {
|
|
return false;
|
|
}
|
|
|
|
var id = String(node.getAttribute('data-id') || '').toLowerCase();
|
|
if (id && itemMenuActionIds.indexOf(id) >= 0) {
|
|
return true;
|
|
}
|
|
|
|
return isCopyStreamUrlLabel(getVisibleLabel(node));
|
|
}
|
|
|
|
function findOpenActionContainer() {
|
|
var selectors = [
|
|
'.actionSheet',
|
|
'.actionsheet',
|
|
'[role="menu"]'
|
|
];
|
|
|
|
for (var i = 0; i < selectors.length; i += 1) {
|
|
var nodes = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < nodes.length; j += 1) {
|
|
var node = nodes[j];
|
|
if (isVisible(node) && looksLikeActionContainer(node)) {
|
|
return node;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function looksLikeActionContainer(node) {
|
|
if (node.querySelector('select, input:not([type="checkbox"]):not([type="radio"]), textarea')) {
|
|
return false;
|
|
}
|
|
|
|
var items = Array.from(node.querySelectorAll('.actionSheetMenuItem, .actionsheetMenuItem'))
|
|
.filter(isVisible);
|
|
if (items.length < 2 || items.length > 40) {
|
|
return false;
|
|
}
|
|
|
|
return items.some(function (item) {
|
|
return isItemMenuAction(item);
|
|
});
|
|
}
|
|
|
|
function findBestActionTemplate(container) {
|
|
var actions = Array.from(container.querySelectorAll('.actionSheetMenuItem, .actionsheetMenuItem'))
|
|
.filter(isVisible);
|
|
for (var i = 0; i < actions.length; i += 1) {
|
|
if (isCopyStreamUrlLabel(getVisibleLabel(actions[i]))) {
|
|
return actions[i];
|
|
}
|
|
}
|
|
|
|
return actions.length ? actions[actions.length - 1] : null;
|
|
}
|
|
|
|
function isVisible(node) {
|
|
return !!(node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length));
|
|
}
|
|
|
|
function insertActionAfter(copyNode, explicitItemId, appendToParent) {
|
|
var parent = copyNode.parentElement;
|
|
if (!parent || parent.querySelector('[' + injectedAttr + '="1"]')) {
|
|
return;
|
|
}
|
|
|
|
var itemId = explicitItemId || resolveItemId(copyNode) || resolveItemId(document);
|
|
if (!itemId) {
|
|
return;
|
|
}
|
|
|
|
var sourceLabel = getVisibleLabel(copyNode);
|
|
var injected = copyNode.cloneNode(true);
|
|
injected.setAttribute(injectedAttr, '1');
|
|
injected.setAttribute('type', 'button');
|
|
injected.removeAttribute('id');
|
|
injected.removeAttribute('href');
|
|
injected.removeAttribute('onclick');
|
|
injected.removeAttribute('target');
|
|
injected.removeAttribute('download');
|
|
injected.setAttribute('aria-label', actionLabel);
|
|
injected.setAttribute('title', actionLabel);
|
|
injected.dataset.sharelinksItemId = itemId;
|
|
|
|
var injectedIcon = injected.querySelector('.material-icons');
|
|
if (injectedIcon) {
|
|
injectedIcon.classList.remove('content_copy');
|
|
injectedIcon.classList.add('share');
|
|
}
|
|
|
|
injected.addEventListener('click', function (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void createGuestLink(itemId);
|
|
}, true);
|
|
|
|
if (!replaceText(injected, sourceLabel, actionLabel)) {
|
|
injected.textContent = actionLabel;
|
|
}
|
|
|
|
if (appendToParent) {
|
|
parent.appendChild(injected);
|
|
} else {
|
|
copyNode.insertAdjacentElement('afterend', injected);
|
|
}
|
|
}
|
|
|
|
function replaceText(root, from, to) {
|
|
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
while (walker.nextNode()) {
|
|
var node = walker.currentNode;
|
|
if (normalizeText(node.nodeValue) === from) {
|
|
node.nodeValue = node.nodeValue.replace(from, to);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveItemId(node) {
|
|
var direct = parseItemIdFromUrl();
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
|
|
var current = node;
|
|
while (current && current !== document) {
|
|
var id = readItemIdFromNode(current);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
|
|
if (lastContextItemId && (Date.now() - lastContextItemTs) < 8000) {
|
|
return lastContextItemId;
|
|
}
|
|
|
|
return findItemIdInDocument();
|
|
}
|
|
|
|
function parseItemIdFromUrl() {
|
|
var sources = [location.hash, location.search, location.href];
|
|
for (var i = 0; i < sources.length; i += 1) {
|
|
var text = sources[i];
|
|
var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i);
|
|
if (match && match[1]) {
|
|
var decoded = decodeURIComponent(match[1]);
|
|
if (isItemGuid(decoded)) {
|
|
return decoded;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findItemIdInDocument() {
|
|
var selectors = [
|
|
'#itemDetailPage',
|
|
'.detailPage',
|
|
'.itemDetailPage',
|
|
'[data-itemid]',
|
|
'[data-id]'
|
|
];
|
|
|
|
for (var i = 0; i < selectors.length; i += 1) {
|
|
var nodes = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < nodes.length; j += 1) {
|
|
var id = readItemIdFromNode(nodes[j]);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readItemIdFromNode(node) {
|
|
if (!node || !node.getAttribute) {
|
|
return null;
|
|
}
|
|
|
|
var candidates = [
|
|
node.getAttribute('data-itemid'),
|
|
node.getAttribute('data-id'),
|
|
node.getAttribute('data-item-id')
|
|
];
|
|
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
if (isItemGuid(candidates[i])) {
|
|
return candidates[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isAdministrator(user) {
|
|
return !!(user && user.Policy && user.Policy.IsAdministrator === true);
|
|
}
|
|
|
|
function rememberAllowedItemFromRoute() {
|
|
if (!isDetailsOrPlaybackRoute()) {
|
|
return;
|
|
}
|
|
|
|
var itemId = parseItemIdFromUrl() || findItemIdInDocument();
|
|
if (itemId) {
|
|
sessionStorage.setItem(allowedItemStorageKey, itemId);
|
|
}
|
|
}
|
|
|
|
function isDetailsOrPlaybackRoute() {
|
|
return /#\/(?:details|video|playback|list|item)/i.test(location.hash || '');
|
|
}
|
|
|
|
var itemVisibilityCache = {};
|
|
var itemVisibilityInFlight = {};
|
|
|
|
function parseCandidateIdsFromUrl() {
|
|
var sources = [location.hash, location.search, location.href];
|
|
var keys = ['id', 'seriesId', 'parentId', 'topParentId'];
|
|
var found = [];
|
|
|
|
for (var k = 0; k < keys.length; k += 1) {
|
|
var pattern = new RegExp('[?&]' + keys[k] + '=([^&#]+)', 'i');
|
|
for (var i = 0; i < sources.length; i += 1) {
|
|
var text = sources[i];
|
|
var match = text.match(pattern);
|
|
if (match && match[1]) {
|
|
var decoded = decodeURIComponent(match[1]);
|
|
if (isItemGuid(decoded) && found.indexOf(decoded) < 0) {
|
|
found.push(decoded);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Returns true/false when the verdict for the current location is already
|
|
* known, or null while it is still being resolved (or the route is not one
|
|
* we verify). A true/false verdict for a given id is cached so the mutation
|
|
* observer re-running this on every DOM tick does not spam the API.
|
|
*/
|
|
function checkAllowedLocation(allowedItemId) {
|
|
var hash = location.hash || '';
|
|
if (/#\/(?:video|playback)/i.test(hash)) {
|
|
return true;
|
|
}
|
|
|
|
if (!/#\/(?:details|item|list)/i.test(hash)) {
|
|
return false;
|
|
}
|
|
|
|
var candidates = parseCandidateIdsFromUrl();
|
|
var normalizedAllowed = String(allowedItemId || '').toLowerCase();
|
|
|
|
if (candidates.length === 0) {
|
|
// Details/list route we could not extract an id from: fall back to
|
|
// the strict same-item check rather than guessing.
|
|
return false;
|
|
}
|
|
|
|
if (candidates.some(function (id) { return id.toLowerCase() === normalizedAllowed; })) {
|
|
return true;
|
|
}
|
|
|
|
// None of the candidate ids is the shared item itself. Ask the server
|
|
// whether the current user (the guest) can actually fetch any of them -
|
|
// the AllowedTags policy is the real access boundary, so a successful
|
|
// fetch means the guest is allowed to be here (e.g. a season/episode
|
|
// inside a shared series or season).
|
|
return resolveServerVisibility(candidates);
|
|
}
|
|
|
|
function resolveServerVisibility(candidates) {
|
|
var pending = false;
|
|
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
var id = candidates[i].toLowerCase();
|
|
if (Object.prototype.hasOwnProperty.call(itemVisibilityCache, id)) {
|
|
if (itemVisibilityCache[id] === true) {
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
pending = true;
|
|
if (!itemVisibilityInFlight[id]) {
|
|
itemVisibilityInFlight[id] = fetchItemVisibility(id);
|
|
}
|
|
}
|
|
|
|
// No definitive "allowed" verdict yet. If at least one candidate is
|
|
// still being checked, stay neutral (no redirect) until it settles.
|
|
// Only report a definitive rejection once every candidate has a
|
|
// cached, negative verdict.
|
|
return pending ? null : false;
|
|
}
|
|
|
|
function fetchItemVisibility(id) {
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
var userId = ApiClient.getCurrentUserId();
|
|
return ApiClient.getItem(userId, id);
|
|
})
|
|
.then(function () {
|
|
itemVisibilityCache[id] = true;
|
|
return true;
|
|
})
|
|
.catch(function () {
|
|
itemVisibilityCache[id] = false;
|
|
return false;
|
|
})
|
|
.finally(function () {
|
|
delete itemVisibilityInFlight[id];
|
|
scheduleWork();
|
|
});
|
|
}
|
|
|
|
function navigateToItem(itemId) {
|
|
var target = '#/details?id=' + encodeURIComponent(itemId);
|
|
if (location.hash !== target) {
|
|
location.hash = target;
|
|
}
|
|
}
|
|
|
|
async function createGuestLink(itemId) {
|
|
if (!isItemGuid(itemId)) {
|
|
notify(t('cannotDetermineItem'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var config = await getConfig();
|
|
var user = await getCurrentUser();
|
|
if (!isAdministrator(user)) {
|
|
notify(t('adminOnly'));
|
|
return;
|
|
}
|
|
|
|
if (config && config.Enabled === false) {
|
|
notify(t('disabled'));
|
|
return;
|
|
}
|
|
|
|
var result = await chooseExpiryHours(config, function (expiryHours) {
|
|
var payload = {
|
|
itemId: itemId,
|
|
expiryHours: expiryHours,
|
|
oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true
|
|
};
|
|
|
|
var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) {
|
|
var shareUrl = response && (response.ShareUrl || response.shareUrl);
|
|
if (!shareUrl) {
|
|
throw new Error(t('noShareUrl'));
|
|
}
|
|
|
|
return shareUrl;
|
|
});
|
|
|
|
var copiedPromise = copyTextWhenReady(shareUrlPromise);
|
|
return shareUrlPromise.then(function (shareUrl) {
|
|
return copiedPromise.catch(function () {
|
|
return false;
|
|
}).then(function (copied) {
|
|
return {
|
|
shareUrl: shareUrl,
|
|
copied: copied
|
|
};
|
|
});
|
|
});
|
|
});
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
showShareResult(result.shareUrl, result.copied);
|
|
} catch (error) {
|
|
notify(extractErrorMessage(error, t('couldNotCreate')));
|
|
}
|
|
}
|
|
|
|
function pad2(value) {
|
|
return (value < 10 ? '0' : '') + value;
|
|
}
|
|
|
|
function toLocalDatetimeValue(date) {
|
|
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate())
|
|
+ 'T' + pad2(date.getHours()) + ':' + pad2(date.getMinutes());
|
|
}
|
|
|
|
function chooseExpiryHours(config, onChoose) {
|
|
var options = durationOptions.map(function (option) {
|
|
return {
|
|
label: durationLabel(option.hours),
|
|
hours: option.hours
|
|
};
|
|
});
|
|
|
|
var maxHours = Math.max(clampPositiveInteger(config && config.MaxExpiryHours, 720), 720);
|
|
var nowMs = Date.now();
|
|
var minDate = new Date(nowMs + 5 * 60000);
|
|
var maxDate = new Date(nowMs + maxHours * 3600000);
|
|
var defaultDate = new Date(nowMs + 168 * 3600000);
|
|
if (defaultDate.getTime() > maxDate.getTime()) {
|
|
defaultDate = maxDate;
|
|
}
|
|
|
|
return openModal({
|
|
title: t('modalTitle'),
|
|
body: t('modalBody'),
|
|
options: options,
|
|
onChoose: onChoose,
|
|
cancelText: t('cancel'),
|
|
datePicker: {
|
|
min: toLocalDatetimeValue(minDate),
|
|
max: toLocalDatetimeValue(maxDate),
|
|
value: toLocalDatetimeValue(defaultDate),
|
|
maxHours: maxHours
|
|
}
|
|
});
|
|
}
|
|
|
|
function copyTextWhenReady(textPromise) {
|
|
if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) {
|
|
try {
|
|
var blobPromise = Promise.resolve(textPromise).then(function (text) {
|
|
return new Blob([text], { type: 'text/plain' });
|
|
});
|
|
return navigator.clipboard.write([
|
|
new ClipboardItem({ 'text/plain': blobPromise })
|
|
]).then(function () {
|
|
return true;
|
|
}).catch(function () {
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
});
|
|
} catch (error) {
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
}
|
|
}
|
|
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
}
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
return navigator.clipboard.writeText(text).then(function () {
|
|
return true;
|
|
}).catch(function () {
|
|
return fallbackCopy(text);
|
|
});
|
|
}
|
|
|
|
return Promise.resolve(fallbackCopy(text));
|
|
}
|
|
|
|
function notify(message) {
|
|
ensureShareLinksUi();
|
|
var toast = document.createElement('div');
|
|
toast.className = 'sharelinks-toast';
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
window.setTimeout(function () {
|
|
toast.classList.add('is-visible');
|
|
}, 20);
|
|
window.setTimeout(function () {
|
|
toast.classList.remove('is-visible');
|
|
window.setTimeout(function () {
|
|
toast.remove();
|
|
}, 180);
|
|
}, 3600);
|
|
}
|
|
|
|
function showShareResult(shareUrl, copied) {
|
|
ensureShareLinksUi();
|
|
var body = document.createElement('div');
|
|
var note = document.createElement('p');
|
|
note.className = 'sharelinks-note';
|
|
note.textContent = copied
|
|
? t('copiedNote')
|
|
: t('notCopiedNote');
|
|
body.appendChild(note);
|
|
|
|
var urlBox = document.createElement('textarea');
|
|
urlBox.className = 'sharelinks-url';
|
|
urlBox.readOnly = true;
|
|
urlBox.value = shareUrl;
|
|
body.appendChild(urlBox);
|
|
|
|
openModalElement({
|
|
title: copied ? t('resultCopiedTitle') : t('resultCreatedTitle'),
|
|
bodyElement: body,
|
|
actions: [
|
|
{
|
|
label: t('copy'),
|
|
primary: true,
|
|
handler: function () {
|
|
return copyText(shareUrl).then(function (ok) {
|
|
if (ok) {
|
|
notify(t('toastCopied'));
|
|
} else {
|
|
urlBox.focus();
|
|
urlBox.select();
|
|
notify(t('toastManual'));
|
|
}
|
|
return ok;
|
|
});
|
|
}
|
|
},
|
|
{
|
|
label: t('done'),
|
|
close: true
|
|
}
|
|
],
|
|
onOpen: function () {
|
|
urlBox.focus();
|
|
urlBox.select();
|
|
}
|
|
});
|
|
}
|
|
|
|
function openModal(settings) {
|
|
var body = document.createElement('div');
|
|
var text = document.createElement('p');
|
|
text.className = 'sharelinks-note';
|
|
text.textContent = settings.body;
|
|
body.appendChild(text);
|
|
|
|
var grid = document.createElement('div');
|
|
grid.className = 'sharelinks-duration-grid';
|
|
body.appendChild(grid);
|
|
|
|
var dateInput = null;
|
|
if (settings.datePicker) {
|
|
var dateRow = document.createElement('div');
|
|
dateRow.className = 'sharelinks-date-row';
|
|
|
|
var dateLabel = document.createElement('label');
|
|
dateLabel.className = 'sharelinks-date-label';
|
|
dateLabel.textContent = t('dateLabel');
|
|
|
|
dateInput = document.createElement('input');
|
|
dateInput.type = 'datetime-local';
|
|
dateInput.className = 'sharelinks-date-input';
|
|
dateInput.min = settings.datePicker.min;
|
|
dateInput.max = settings.datePicker.max;
|
|
dateInput.value = settings.datePicker.value;
|
|
|
|
dateLabel.setAttribute('for', 'sharelinksExpiryDate');
|
|
dateInput.id = 'sharelinksExpiryDate';
|
|
|
|
dateRow.appendChild(dateLabel);
|
|
dateRow.appendChild(dateInput);
|
|
body.appendChild(dateRow);
|
|
}
|
|
|
|
return new Promise(function (resolve) {
|
|
var modal;
|
|
var actions = [];
|
|
|
|
if (settings.datePicker) {
|
|
actions.push({
|
|
label: t('create'),
|
|
primary: true,
|
|
handler: function () {
|
|
var raw = dateInput && dateInput.value;
|
|
if (!raw) {
|
|
notify(t('pickDateFirst'));
|
|
return;
|
|
}
|
|
|
|
var target = new Date(raw);
|
|
if (isNaN(target.getTime())) {
|
|
notify(t('dateInvalid'));
|
|
return;
|
|
}
|
|
|
|
var hours = Math.ceil((target.getTime() - Date.now()) / 3600000);
|
|
if (hours < 1) {
|
|
notify(t('pickFuture'));
|
|
return;
|
|
}
|
|
|
|
if (settings.datePicker.maxHours && hours > settings.datePicker.maxHours) {
|
|
hours = settings.datePicker.maxHours;
|
|
}
|
|
|
|
if (modal) {
|
|
modal.close();
|
|
}
|
|
resolve(settings.onChoose ? settings.onChoose(hours) : hours);
|
|
}
|
|
});
|
|
}
|
|
|
|
actions.push({
|
|
label: settings.cancelText || t('cancel'),
|
|
close: true,
|
|
handler: function () {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
modal = openModalElement({
|
|
title: settings.title,
|
|
bodyElement: body,
|
|
actions: actions,
|
|
onDismiss: function () {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
settings.options.forEach(function (option) {
|
|
var button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'sharelinks-duration-button';
|
|
button.textContent = option.label;
|
|
|
|
button.addEventListener('click', function () {
|
|
modal.close();
|
|
if (settings.onChoose) {
|
|
resolve(settings.onChoose(option.hours));
|
|
} else {
|
|
resolve(option.hours);
|
|
}
|
|
});
|
|
grid.appendChild(button);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openModalElement(settings) {
|
|
ensureShareLinksUi();
|
|
var closed = false;
|
|
var overlay = document.createElement('div');
|
|
overlay.className = 'sharelinks-overlay';
|
|
overlay.setAttribute('role', 'presentation');
|
|
|
|
var dialog = document.createElement('div');
|
|
dialog.className = 'sharelinks-dialog';
|
|
dialog.setAttribute('role', 'dialog');
|
|
dialog.setAttribute('aria-modal', 'true');
|
|
dialog.setAttribute('aria-label', settings.title);
|
|
overlay.appendChild(dialog);
|
|
|
|
var title = document.createElement('h3');
|
|
title.className = 'sharelinks-title';
|
|
title.textContent = settings.title;
|
|
dialog.appendChild(title);
|
|
|
|
dialog.appendChild(settings.bodyElement);
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'sharelinks-actions';
|
|
dialog.appendChild(actions);
|
|
|
|
function close() {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
overlay.classList.remove('is-visible');
|
|
window.setTimeout(function () {
|
|
overlay.remove();
|
|
}, 160);
|
|
}
|
|
|
|
(settings.actions || []).forEach(function (action) {
|
|
var button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = action.primary ? 'sharelinks-action primary' : 'sharelinks-action';
|
|
button.textContent = action.label;
|
|
button.addEventListener('click', function () {
|
|
var result = action.handler ? action.handler() : null;
|
|
Promise.resolve(result).finally(function () {
|
|
if (action.close) {
|
|
close();
|
|
}
|
|
});
|
|
});
|
|
actions.appendChild(button);
|
|
});
|
|
|
|
overlay.addEventListener('click', function (event) {
|
|
if (event.target === overlay) {
|
|
close();
|
|
if (settings.onDismiss) {
|
|
settings.onDismiss();
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('keydown', function onKeyDown(event) {
|
|
if (event.key === 'Escape' && !closed) {
|
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
close();
|
|
if (settings.onDismiss) {
|
|
settings.onDismiss();
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
document.body.appendChild(overlay);
|
|
window.setTimeout(function () {
|
|
overlay.classList.add('is-visible');
|
|
var firstButton = overlay.querySelector('button:not([disabled])');
|
|
if (firstButton) {
|
|
firstButton.focus();
|
|
}
|
|
if (settings.onOpen) {
|
|
settings.onOpen();
|
|
}
|
|
}, 20);
|
|
|
|
return { close: close, element: overlay };
|
|
}
|
|
|
|
function ensureShareLinksUi() {
|
|
if (document.getElementById('ShareLinksUiStyle')) {
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'ShareLinksUiStyle';
|
|
style.textContent = [
|
|
'.sharelinks-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.58);display:grid;place-items:center;padding:24px;opacity:0;transition:opacity .16s ease;}',
|
|
'.sharelinks-overlay.is-visible{opacity:1;}',
|
|
'.sharelinks-dialog{width:min(520px,100%);background:var(--background-color,#202020);color:var(--text-color,#fff);box-shadow:0 18px 60px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:22px;}',
|
|
'.sharelinks-title{font-size:1.25rem;line-height:1.3;margin:0 0 14px;font-weight:600;}',
|
|
'.sharelinks-note{margin:0 0 16px;color:var(--text-secondary-color,#cfcfcf);line-height:1.45;}',
|
|
'.sharelinks-duration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:4px;}',
|
|
'.sharelinks-duration-button,.sharelinks-action{border:0;border-radius:6px;background:rgba(255,255,255,.12);color:inherit;padding:10px 12px;min-height:42px;cursor:pointer;font:inherit;}',
|
|
'.sharelinks-duration-button:hover:not(:disabled),.sharelinks-action:hover{background:rgba(255,255,255,.18);}',
|
|
'.sharelinks-duration-button:disabled{opacity:.35;cursor:not-allowed;}',
|
|
'.sharelinks-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;}',
|
|
'.sharelinks-action.primary{background:var(--theme-primary-color,#00a4dc);color:#fff;}',
|
|
'.sharelinks-url{width:100%;min-height:88px;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px;font:inherit;resize:vertical;}',
|
|
'.sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}',
|
|
'.sharelinks-date-label{color:var(--text-secondary-color,#cfcfcf);font-size:.92rem;line-height:1.4;}',
|
|
'.sharelinks-date-input{width:100%;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px 12px;min-height:42px;font:inherit;color-scheme:dark;}',
|
|
'.sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}',
|
|
'.sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}',
|
|
'@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}'
|
|
].join('');
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function fallbackCopy(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();
|
|
var copied = false;
|
|
try {
|
|
copied = document.execCommand('copy');
|
|
} catch (error) {
|
|
copied = false;
|
|
}
|
|
textarea.remove();
|
|
return copied;
|
|
}
|
|
|
|
function clampPositiveInteger(value, fallback) {
|
|
var parsed = parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function extractErrorMessage(error, fallback) {
|
|
if (!error) {
|
|
return fallback;
|
|
}
|
|
|
|
if (typeof error === 'string') {
|
|
return error;
|
|
}
|
|
|
|
if (error.responseJSON && error.responseJSON.error) {
|
|
return error.responseJSON.error;
|
|
}
|
|
|
|
if (error.responseText) {
|
|
return error.responseText;
|
|
}
|
|
|
|
if (error.message) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
start();
|
|
})();
|