Jellyfin-VideoOSD-Download-Button-v1.js
Rama main · réplica de lectura del repositorio original.
function dlIsSupportedPlatform() {
const ua = navigator.userAgent.toLowerCase();
const isMobile = ['mobi', 'ipad', 'iphone', 'ipod', 'silk', 'opera mini'].some((term) => ua.includes(term));
const isTv = ['tv', 'samsungbrowser', 'viera', 'web0s'].some((term) => ua.includes(term));
const isTizen = ua.includes('tizen') || window.tizen != null;
const isAndroid = ua.includes('android');
const isIOS = ['ipad', 'iphone', 'ipod'].some((term) => ua.includes(term)) || (ua.includes('macintosh') && navigator.maxTouchPoints > 1);
return !(isMobile || isTv || isTizen || isAndroid || isIOS);
}
function dlMain() {
'use strict';
// ---- PLUGIN ADAPTER: config source for VideoOSD Tweaks and Candy ----
const CONFIG = {
// ============================================================
// == SHARED VALUES (both standalone and plugin usage) ==
// Hand-edit these for standalone use; the plugin overwrites
// them via applyPluginConfig() once fetched. No separate
// standalone branch needed, same as the other bottom-right
// addons. No Centered Gap field here: the General/Individual
// Centered Gap only applies to the 3 bottom-left addons
// (A-B Loop, Speed, FrameByFrame), never to
// Download/Screenshot in the bottom-right zone.
// ============================================================
hideOnNarrowWindow: true,
// 'original' | 'library'. 'original' leaves a.download empty,
// so the browser uses the server's own Content-Disposition
// filename.
filenameChoice: 'original',
// Only relevant when filenameChoice is 'library'. Same
// defaults and same colon-replacement/year logic as
// Screenshot's matching setting.
includeYearMovies: true,
includeYearEpisodes: false,
includeYearVideos: false
};
async function fetchPluginConfig() {
const maxAttempts = 120;
const delayMs = 250;
let failures = 0;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// No ApiClient yet (jellyfin-web creates it once a server is
// known, e.g. after the server selection page): wait without
// using up an attempt, like the not-logged-in case below.
if (!window.ApiClient) attempt--;
if (window.ApiClient && typeof ApiClient.getJSON === 'function') {
// Not logged in yet (e.g. still on the login page): every
// request would only fail with 401, so wait without using up
// an attempt.
if (typeof ApiClient.accessToken === 'function' && !ApiClient.accessToken()) {
attempt--;
await new Promise(function (resolve) { setTimeout(resolve, delayMs); });
continue;
}
try {
// The plugin's own endpoint, readable for every signed-in user.
const config = await ApiClient.getJSON(ApiClient.getUrl('VideoOSDTweaksCandy/ClientConfiguration'));
if (config) return config;
throw new Error('empty configuration');
} catch (err) {
// 403: no access; 404: plugin not installed (standalone
// use). Retrying can't change
// either, so stop and use the defaults instead of sending
// up to 120 failing requests.
if (err && (err.status === 403 || err.status === 404)) return null;
// Server error (5xx), network error or empty answer: at most 3
// retries, 0.5 / 1 / 2 s apart, then the defaults until the next
// fetch.
if (++failures > 3) return null;
await new Promise(function (resolve) { setTimeout(resolve, delayMs * Math.pow(2, failures)); });
continue;
}
}
await new Promise(function (resolve) { setTimeout(resolve, delayMs); });
}
return null;
}
// Maps the plugin's Download settings onto CONFIG, same pattern as
// the other addons. Guarded per field, so a missing or partial
// config never wipes a default.
function applyPluginConfig(pluginConfig) {
if (!pluginConfig) return;
if (typeof pluginConfig.DownloadHideOnNarrowWindow === 'boolean') {
CONFIG.hideOnNarrowWindow = pluginConfig.DownloadHideOnNarrowWindow;
}
if (typeof pluginConfig.DownloadFilenameChoice === 'string' && pluginConfig.DownloadFilenameChoice) {
CONFIG.filenameChoice = pluginConfig.DownloadFilenameChoice;
}
if (typeof pluginConfig.DownloadIncludeYearMovies === 'boolean') {
CONFIG.includeYearMovies = pluginConfig.DownloadIncludeYearMovies;
}
if (typeof pluginConfig.DownloadIncludeYearEpisodes === 'boolean') {
CONFIG.includeYearEpisodes = pluginConfig.DownloadIncludeYearEpisodes;
}
if (typeof pluginConfig.DownloadIncludeYearVideos === 'boolean') {
CONFIG.includeYearVideos = pluginConfig.DownloadIncludeYearVideos;
}
}
// ---- END PLUGIN ADAPTER ----
const ADDON_ID = 'downloadButton';
const ADDON_NAME = 'Download Button';
const RESPONSIVE_STYLE_ID = 'downloadButtonResponsiveStyle';
const CUSTOMS_API_NAME = 'JellyfinVideoOSDCustomsMenu';
const CUSTOMS_WAIT_MS = 300;
const CUSTOMS_WAIT_TRIES = 120;
const CUSTOMS_STORAGE_KEY =
CUSTOMS_API_NAME + '.addon.' + ADDON_ID;
let btn = null;
let lastVideoId = null;
let observer = null;
let pollInterval = null;
let enabled = false;
let registeredWithCustoms = false;
let customsRegisterTimer = null;
let ignoreStoredCustomsState = false;
const isCustomsAvailable = () => {
const api = window[CUSTOMS_API_NAME];
return !!api && typeof api.registerAddon === 'function';
};
const isEnabledByCustomsState = () =>
localStorage.getItem(CUSTOMS_STORAGE_KEY) !== 'false';
// Adds or removes the "hide on narrow window" style according to
// CONFIG.hideOnNarrowWindow, same pattern as the other addons.
const refreshResponsiveStyle = () => {
const existing = document.getElementById(RESPONSIVE_STYLE_ID);
if (!CONFIG.hideOnNarrowWindow) {
if (existing) existing.remove();
return;
}
if (existing) return;
const style = document.createElement('style');
style.id = RESPONSIVE_STYLE_ID;
style.textContent = `
@media all and (max-width: 50em) {
.videoOsdBottom .btnDownload { display: none !important; }
}
`;
document.head.appendChild(style);
};
const getCurrentVideoId = () => {
const ratingBtn = document.querySelector('#videoOsdPage:not(.hide) .btnUserRating');
if (ratingBtn && ratingBtn.dataset && ratingBtn.dataset.id) {
return ratingBtn.dataset.id;
}
// No logging here: checkVideoChange() below calls this on every
// MutationObserver tick (class/style changes happen constantly
// during playback, e.g. the progress slider), and "no id yet"
// (OSD not showing, rating button without data-id) is a normal,
// frequent outcome. A warning here would flood the console with
// tens of thousands of lines, which costs performance even with
// devtools closed.
return null;
};
const sanitizeFilename = str =>
str.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim();
// Name for a download from the stream endpoint, which sends no file name
// of its own: the item's name made safe for Windows (at most 150
// characters, no trailing dot or space) plus its container as extension.
const streamFilename = item => {
let name = sanitizeFilename(String(item.Name || '').replace(/\s*:\s*/g, ' - ').replace(/[\u0000-\u001f]/g, ''))
.slice(0, 150).replace(/[. ]+$/, '');
if (!name) return '';
const containers = String(item.Container || '').toLowerCase().split(',');
const ext = containers.includes('mp4') ? 'mp4' : containers[0];
if (/^[a-z0-9]+$/.test(ext) && !name.toLowerCase().endsWith('.' + ext)) name += '.' + ext;
return name;
};
const getItemInfo = async (id) => {
if (!window.ApiClient || !id) return null;
try {
const userId = ApiClient.getCurrentUserId();
const item = await ApiClient.getItem(userId, id);
if (!item) return null;
let kind = 'video';
if (item.Type === 'Movie') kind = 'movie';
else if (item.Type === 'Episode') kind = 'episode';
const includeYear = kind === 'movie' ? CONFIG.includeYearMovies
: kind === 'episode' ? CONFIG.includeYearEpisodes
: CONFIG.includeYearVideos;
let label = item.Name || '';
if (item.Type === 'Episode' && item.SeriesName) {
// "!= null" (not "||"): season 0 (specials) / episode 0 are real numbers.
const s = String(item.ParentIndexNumber != null ? item.ParentIndexNumber : 1).padStart(2, '0');
const e = String(item.IndexNumber != null ? item.IndexNumber : 1).padStart(2, '0');
label = `${item.SeriesName} - S${s}E${e} - ${item.Name || ''}`;
}
if (includeYear && item.ProductionYear) {
label += ` (${item.ProductionYear})`;
}
label = label.replace(/\s*:\s*/g, ' - ');
label = sanitizeFilename(label);
let extension = null;
if (item.Path) {
const m = item.Path.match(/\.([^./\\]+)$/);
if (m) extension = m[1].toLowerCase();
}
return { label: label, extension: extension };
} catch (err) {
console.error('[VideoOSD Download Button] getItemInfo failed:', err);
return null;
}
};
const downloadCurrentVideo = async () => {
const id = getCurrentVideoId();
if (!id || !window.ApiClient) return;
// Respect the account's download permission, like Jellyfin's own
// download entries. The "library" filename mode uses the stream
// endpoint, which the server serves regardless of the download
// permission, and in the default mode the download would just
// fail silently. Only the permission itself is checked here (not
// item.CanDownload, which is also false for e.g. .strm items
// that the library mode can still download).
try {
const user = await ApiClient.getCurrentUser();
if (user && user.Policy && user.Policy.EnableContentDownloading === false) {
if (window.Dashboard && typeof Dashboard.alert === 'function') {
Dashboard.alert('Downloading is not allowed for your account.');
}
return;
}
} catch (err) {
// User lookup failed: download without the permission check.
}
const a = document.createElement('a');
if (CONFIG.filenameChoice === 'library') {
const info = await getItemInfo(id);
const filename = (info && info.label)
? (info.extension ? `${info.label}.${info.extension}` : info.label)
: null;
if (filename) {
a.href = `${ApiClient.serverAddress()}/Videos/${id}/stream?ApiKey=${ApiClient.accessToken()}&static=true`;
a.download = filename;
} else {
a.href = `${ApiClient.serverAddress()}/Items/${id}/Download?ApiKey=${ApiClient.accessToken()}`;
a.download = '';
}
} else {
// Items without a downloadable original (e.g. Playnite Bridge's game
// videos: Items/{id}/Download answers 404) get the file from the
// stream endpoint instead.
let canDownload = true;
let item = null;
try {
item = await ApiClient.getItem(ApiClient.getCurrentUserId(), id);
canDownload = !!(item && item.CanDownload === true);
} catch (err) {
// Item lookup failed: keep the download endpoint.
}
a.href = canDownload
? `${ApiClient.serverAddress()}/Items/${id}/Download?ApiKey=${ApiClient.accessToken()}`
: `${ApiClient.serverAddress()}/Videos/${id}/stream?ApiKey=${ApiClient.accessToken()}&static=true`;
a.download = canDownload ? '' : streamFilename(item);
}
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const ensureBtn = () => {
if (!btn) {
btn = document.createElement('button');
btn.className = 'btnDownload autoSize paper-icon-button-light';
btn.title = 'Download';
// Inline SVG instead of the "Material Symbols Outlined" icon
// font: jellyfin-web ships no Material Symbols font, so without
// it (loaded by some other theme/plugin/CSS) the ligature text
// "download" would render as plain text instead of an icon. The
// path is the font's own "download" glyph (FILL 0, wght 400,
// GRAD 0, opsz 24), sized to the font's standard 24px, so it
// looks exactly like the font rendering.
const icon = document.createElement('span');
icon.className = 'xlargePaperIconButton jvosd-download-icon';
icon.style.cssText = 'display:inline-block;width:24px;height:24px;line-height:0;';
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" style="display:block"><path d="M480 -320 280 -520 336 -578 440 -474V-800H520V-474L624 -578L680 -520ZM240 -160Q207 -160 183.5 -183.5Q160 -207 160 -240V-360H240V-240Q240 -240 240.0 -240.0Q240 -240 240 -240H720Q720 -240 720.0 -240.0Q720 -240 720 -240V-360H800V-240Q800 -207 776.5 -183.5Q753 -160 720 -160Z"/></svg>';
btn.appendChild(icon);
btn.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
downloadCurrentVideo();
});
}
return btn;
};
const removeButton = () => {
if (btn) {
btn.remove();
btn = null;
}
};
// Greys the button out (one-line reason as tooltip) when the server has no
// file for the playing item at all (e.g. a Steam video streamed as HLS).
// Checked once per item: no downloadable original -> one 1-byte request
// to the stream endpoint.
const downloadable = {};
const updateAvailability = async id => {
if (!id || !window.ApiClient) return;
if (!(id in downloadable)) {
downloadable[id] = true;
try {
const item = await ApiClient.getItem(ApiClient.getCurrentUserId(), id);
if (!(item && item.CanDownload === true)) {
const r = await fetch(`${ApiClient.serverAddress()}/Videos/${id}/stream?ApiKey=${ApiClient.accessToken()}&static=true`,
{ headers: { Range: 'bytes=0-0' } });
downloadable[id] = r.ok;
if (r.body) r.body.cancel();
}
} catch (err) {
downloadable[id] = true;
}
}
if (btn && id === getCurrentVideoId()) {
btn.disabled = !downloadable[id];
btn.title = downloadable[id] ? 'Download' : 'No file to download for this video';
}
};
const checkVideoChange = () => {
const id = getCurrentVideoId();
if (id !== lastVideoId) {
lastVideoId = id;
updateAvailability(id);
}
};
const injectButton = () => {
if (!enabled) return false;
const favBtn = document.querySelector('#videoOsdPage:not(.hide) .buttons.focuscontainer-x > .btnUserRating');
if (!favBtn || !favBtn.parentNode) return false;
const container = favBtn.parentNode;
if (!container.querySelector('.btnDownload')) {
refreshResponsiveStyle();
const newBtn = ensureBtn();
container.insertBefore(newBtn, favBtn);
updateAvailability(getCurrentVideoId());
}
return true;
};
const enable = () => {
if (enabled) return;
enabled = true;
// This observer watches the whole document.body subtree for
// style/class changes, which fire constantly during playback
// (Jellyfin's progress bar updates style/class very often), and
// other addons run similar observers. Debounced to at most once
// every 100ms: still quick to catch newly inserted elements, but
// a burst of mutations becomes a single check instead of
// hundreds per second.
let debounceTimer = null;
observer = new MutationObserver(() => {
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
injectButton();
checkVideoChange();
}, 100);
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
});
pollInterval = setInterval(() => {
if (injectButton()) {
clearInterval(pollInterval);
pollInterval = null;
}
}, 300);
injectButton();
};
const disable = () => {
if (!enabled) return;
enabled = false;
if (observer) {
observer.disconnect();
observer = null;
}
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
removeButton();
lastVideoId = null;
};
const tryRegisterWithCustoms = () => {
if (registeredWithCustoms) return false;
const api = window[CUSTOMS_API_NAME];
if (!api || typeof api.registerAddon !== 'function') {
return false;
}
registeredWithCustoms = true;
if (localStorage.getItem(CUSTOMS_STORAGE_KEY) === null) {
localStorage.setItem(CUSTOMS_STORAGE_KEY, 'true');
}
api.registerAddon({
id: ADDON_ID,
name: ADDON_NAME,
enable() {
ignoreStoredCustomsState = false;
enable();
},
disable() {
ignoreStoredCustomsState = false;
disable();
}
});
if (!ignoreStoredCustomsState) {
if (isEnabledByCustomsState()) {
enable();
} else {
disable();
}
} else {
enable();
}
return true;
};
const startCustomsRegistrationWatcher = () => {
tryRegisterWithCustoms();
if (registeredWithCustoms) return;
let tries = 0;
customsRegisterTimer = setInterval(() => {
tries += 1;
tryRegisterWithCustoms();
if (registeredWithCustoms || tries >= CUSTOMS_WAIT_TRIES) {
clearInterval(customsRegisterTimer);
customsRegisterTimer = null;
}
}, CUSTOMS_WAIT_MS);
};
const start = () => {
if (isCustomsAvailable()) {
ignoreStoredCustomsState = false;
tryRegisterWithCustoms();
} else {
ignoreStoredCustomsState = true;
enable();
}
startCustomsRegistrationWatcher();
console.log('[VideoOSD Download Button] Script loaded.');
};
if (document.documentElement) {
start();
} else {
document.addEventListener('DOMContentLoaded', start, {
once: true
});
}
// ---- PLUGIN ADAPTER: apply fetched config once it arrives ----
fetchPluginConfig().then(function (pluginConfig) {
applyPluginConfig(pluginConfig);
refreshResponsiveStyle();
}).catch(function (err) {
console.error('[VideoOSD Download Button] config apply failed:', err);
});
// ---- END PLUGIN ADAPTER ----
}
// Phones, tablets and TV devices: this addon stays off there unless the plugin
// setting "DownloadOnTouchAndTvDevices" allows it (admin setting, default off). The setting is
// read from the plugin's own endpoint, which every signed-in user may read.
function dlAllowedOnThisDevice() {
// Signed out (login page) nothing is sent and no timer runs: the check
// waits for the first view change ("viewshow") or hash change with a token.
return new Promise(function (resolve) {
let started = false;
const attempt = function () {
const api = window.ApiClient;
if (started || !api || typeof api.accessToken !== 'function' || !api.accessToken()) return;
started = true;
document.removeEventListener('viewshow', attempt, true);
window.removeEventListener('hashchange', attempt);
api.getJSON(api.getUrl('VideoOSDTweaksCandy/ClientConfiguration')).then(function (config) {
resolve(!!config && config.DownloadOnTouchAndTvDevices === true);
}, function () {
resolve(false);
});
};
attempt();
if (!started) {
document.addEventListener('viewshow', attempt, true);
window.addEventListener('hashchange', attempt);
}
});
}
if (dlIsSupportedPlatform()) {
dlMain();
} else {
dlAllowedOnThisDevice().then(function (allowed) {
if (allowed) dlMain();
});
}