Ctrl K
ComunidadGuías…

Jellyfin-VideoOSD-Download-Button-v1.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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();
    });
}