DVR-chan – Force Enable YouTube DVR/Rewind (formerly YTBetter)

Force-enables DVR rewind on any YouTube live stream, even when the broadcaster disabled it. Seek window extended to 7 days.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         DVR-chan – Force Enable YouTube DVR/Rewind (formerly YTBetter)
// @name:ru      DVR-chan – Разблокировка DVR/перемотки на YouTube (бывший YTBetter)
// @name:de      DVR-chan – YouTube-DVR/Rewind erzwingen (ehemals YTBetter)
// @name:es      DVR-chan – Forzar YouTube DVR/Rewind (antes YTBetter)
// @name:fr      DVR-chan – Forcer le DVR/Rewind YouTube (anciennement YTBetter)
// @name:it      DVR-chan – Forza YouTube DVR/Rewind (ex YTBetter)
// @name:pl      DVR-chan – Wymuś YouTube DVR/Rewind (dawniej YTBetter)
// @name:pt-BR   DVR-chan – Forçar YouTube DVR/Rewind (anteriormente YTBetter)
// @name:tr      DVR-chan – YouTube DVR/Rewind'i Zorla Etkinleştir (eski adıyla YTBetter)
// @name:id      DVR-chan – Paksa Aktifkan YouTube DVR/Rewind (dulu YTBetter)
// @name:zh-CN   DVR-chan – 强制启用 YouTube DVR/Rewind(原 YTBetter)
// @name:zh-TW   DVR-chan – 強制啟用 YouTube DVR/Rewind(前身 YTBetter)
// @name:ja      DVR-chan – YouTube DVR/巻き戻しを強制有効化(旧 YTBetter)
// @name:ko      DVR-chan – YouTube DVR/되감기 강제 활성화 (구 YTBetter)
// @name:vi      DVR-chan – Buộc bật YouTube DVR/Rewind (tiền thân YTBetter)
// @name:th      DVR-chan – บังคับเปิดใช้ YouTube DVR/Rewind (เดิมชื่อ YTBetter)
// @namespace    YTBetter
// @version      4.1
// @description       Force-enables DVR rewind on any YouTube live stream, even when the broadcaster disabled it. Seek window extended to 7 days.
// @description:ru    Принудительно включает DVR и перемотку на любом YouTube-стриме, даже если автор отключил эту функцию. Окно перемотки расширено до 7 дней.
// @description:de    Erzwingt DVR-Rewind bei jedem YouTube-Livestream, auch wenn der Streamer es deaktiviert hat. Suchfenster auf 7 Tage erweitert.
// @description:es    Activa forzosamente el DVR en cualquier transmisión en vivo de YouTube, aunque el creador lo haya desactivado. Ventana de búsqueda ampliada a 7 días.
// @description:fr    Force l'activation du DVR sur n'importe quel live YouTube, même si le diffuseur l'a désactivé. Fenêtre de défilement étendue à 7 jours.
// @description:it    Forza l'abilitazione del DVR su qualsiasi live di YouTube, anche se il creator l'ha disattivato. Finestra di ricerca estesa a 7 giorni.
// @description:pl    Wymusza włączenie DVR na każdym streamie YouTube, nawet jeśli nadawca je wyłączył. Okno przewijania rozszerzone do 7 dni.
// @description:pt-BR Força a ativação do DVR em qualquer transmissão ao vivo do YouTube, mesmo que o canal tenha desativado. Janela de busca estendida para 7 dias.
// @description:tr    Yayıncı devre dışı bırakmış olsa bile her YouTube canlı yayınında DVR geri sarma işlevini zorla etkinleştirir. Arama penceresi 7 güne genişletildi.
// @description:id    Memaksa aktifkan DVR pada siaran langsung YouTube mana pun, meskipun dinonaktifkan oleh streamer. Jendela pencarian diperluas hingga 7 hari.
// @description:zh-CN 强制在任何 YouTube 直播中启用 DVR 回看,即使主播已将其关闭。回看窗口延长至 7 天。
// @description:zh-TW 強制在任何 YouTube 直播中啟用 DVR 回看,即使主播已將其關閉。回看視窗延長至 7 天。
// @description:ja    配信者がDVRを無効にしていても、あらゆるYouTubeライブ配信で巻き戻し機能を強制的に有効化します。シーク可能な時間を最大7日間に拡張。
// @description:ko    방송인이 비활성화했더라도 모든 YouTube 라이브 스트림에서 DVR 되감기를 강제로 활성화합니다. 탐색 가능 시간이 최대 7일로 확장됩니다.
// @description:vi    Bật buộc DVR trên bất kỳ livestream YouTube nào, kể cả khi người phát đã tắt. Khoảng thời gian tua lại được mở rộng lên 7 ngày.
// @description:th    บังคับเปิดใช้ DVR บน YouTube Live ทุกสตรีม แม้ว่าผู้สตรีมจะปิดใช้งานไว้ ขยายช่วงเวลาย้อนกลับสูงสุด 7 วัน
// @author       copyMister
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @run-at       document-start
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    /*
     * WHAT THIS DOES
     * YouTube hands its video player a "player response" object that describes the stream,
     * including whether live rewind (DVR) is allowed. We catch that object and flip a few
     * switches before the player reads it, so DVR-disabled live streams become seekable.
     *
     * WHERE WE CATCH IT (two sources, so every video is covered):
     *   1. First page load  -> the inline global `ytInitialPlayerResponse` (a plain object).
     *   2. Later navigations -> it arrives over the network and passes through `JSON.parse`.
     *
     * WHY WE DON'T TOUCH Object.prototype (this is the important part):
     * Old versions defined `Object.prototype.playerResponse`. That made
     * `"playerResponse" in <anything>` return true for EVERY object on the page. YouTube's
     * internal response router does exactly that check to decide what a response is, so it
     * mistook comment / feed responses for player data and threw their contents away
     * (comments stopped loading for everyone). Hooking the two real sources below avoids the
     * problem completely and also coexists cleanly with other player-response scripts.
     */

    // The default live DVR window is 12 hours (43200 s). We open it up to 7 days.
    const MAX_DVR_SECS = 43200 * 14; // 604800

    // ---- Core: adjust one live-stream player response, in place ------------------------
    function modifyPlayerResponse(pr) {
        const { streamingData, videoDetails, playerConfig, microformat } = pr;

        // Only live streams have DVR. Leave normal videos and finished VODs alone.
        if (!videoDetails || !videoDetails.isLive) return;

        // 1) Enable rewind even when the broadcaster turned DVR off.
        videoDetails.isLiveDvrEnabled = true;

        // 2) Turn off server-driven playback that blocks client-side seeking
        //    (server ABR, and "wait for the server before starting").
        //    https://greasyfork.org/en/scripts/485020-ytbetter-enable-rewind-dvr/discussions/295167
        const mc = playerConfig && playerConfig.mediaCommonConfig;
        if (mc) {
            mc.useServerDrivenAbr = false;
            // https://greasyfork.org/en/scripts/485020-ytbetter-enable-rewind-dvr/discussions/313024
            if (mc.serverPlaybackStartConfig) mc.serverPlaybackStartConfig.enable = false;
        }

        if (streamingData) {
            // 3) Drop the ad-carrying server-ABR stream URL when a clean manifest exists.
            if (streamingData.serverAbrStreamingUrl &&
                (streamingData.hlsManifestUrl || streamingData.dashManifestUrl)) {
                delete streamingData.serverAbrStreamingUrl;
            }

            // 4) For streams already running longer than 12 h, widen each format's DVR window.
            //    https://greasyfork.org/en/scripts/485020-ytbetter-enable-rewind-dvr/discussions/312558
            if (Array.isArray(streamingData.adaptiveFormats) && streamRunningOver12h(microformat)) {
                for (const format of streamingData.adaptiveFormats) {
                    format.maxDvrDurationSec = MAX_DVR_SECS;
                }
            }
        }
    }

    // How long has this live stream been running, based on its start timestamp?
    function streamRunningOver12h(microformat) {
        const live = microformat &&
            microformat.playerMicroformatRenderer &&
            microformat.playerMicroformatRenderer.liveBroadcastDetails;
        if (!live || !live.startTimestamp) return false;
        const seconds = (Date.now() - new Date(live.startTimestamp).getTime()) / 1000;
        return seconds > 43200; // 12 hours
    }

    // A player response can arrive on its own or wrapped under a `playerResponse` key.
    function patchResponse(data) {
        if (!data || typeof data !== "object") return false;
        if (data.videoDetails) modifyPlayerResponse(data);
        else if (data.playerResponse && data.playerResponse.videoDetails) modifyPlayerResponse(data.playerResponse);
        else return false;
        return true;
    }

    // ---- Source 1: the inline global set on first page load ----------------------------
    // Intercept only until the first real response, then release the global so uBO and other
    // scriptlets can keep editing `ytInitialPlayerResponse` normally.
    const previousInitialDescriptor = Object.getOwnPropertyDescriptor(window, "ytInitialPlayerResponse");
    const previousInitialSetter = previousInitialDescriptor && previousInitialDescriptor.set;
    let initialResponse = window.ytInitialPlayerResponse;
    if (!patchResponse(initialResponse)) {
        try {
            Object.defineProperty(window, "ytInitialPlayerResponse", {
                configurable: true,
                get() { return initialResponse; },
                set(value) {
                    if (previousInitialSetter) previousInitialSetter.call(this, value);
                    initialResponse = value;
                    if (patchResponse(value)) {
                        Object.defineProperty(window, "ytInitialPlayerResponse", {
                            configurable: true,
                            writable: true,
                            value,
                        });
                    }
                },
            });
        } catch (e) {
            patchResponse(window.ytInitialPlayerResponse);
        }
    }

    // ---- Source 2: player responses fetched on later in-site navigations ---------------
    const nativeParse = JSON.parse;
    JSON.parse = function (text, reviver) {
        const data = nativeParse.call(this, text, reviver);
        try { patchResponse(data); } catch (e) { /* never let our code break the page */ }
        return data;
    };

    /*
     * ---- Rewinding past ~13 hours -------------------------------------------------------
     * Seeking back further than ~13 h is capped by a player experiment flag
     * (html5_max_live_dvr_window_plus_margin_secs, default 46800 s). The player reads it from
     * a serialized "key=value&key=value..." string inside YouTube's `ytcfg` config. We rewrite
     * that one flag to 7 days as the config is set, before the player parses it. This is safe
     * for short streams: you can still only rewind as far as the stream has actually run.
     */
    const DVR_FLAG = "html5_max_live_dvr_window_plus_margin_secs";

    function liftDvrCap(ytcfg) {
        if (ytcfg.__dvrCapLifted) return;
        const store = typeof ytcfg.d === "function" && ytcfg.d();
        const players = store && store.WEB_PLAYER_CONTEXT_CONFIGS;
        if (!players) return; // config not loaded yet; try again on the next set()
        for (const id in players) {
            const cfg = players[id];
            if (cfg && typeof cfg.serializedExperimentFlags === "string") {
                cfg.serializedExperimentFlags = cfg.serializedExperimentFlags.replace(
                    new RegExp(DVR_FLAG + "=[\\d.]+"), DVR_FLAG + "=" + MAX_DVR_SECS);
            }
        }
        ytcfg.__dvrCapLifted = true;
    }

    function hookYtcfg(ytcfg) {
        if (!ytcfg || ytcfg.__dvrHooked) return ytcfg;
        ytcfg.__dvrHooked = true;
        const nativeSet = ytcfg.set;
        if (typeof nativeSet === "function") {
            // Re-check the cap after every config write (the player config may land in any one).
            ytcfg.set = function () {
                const result = nativeSet.apply(this, arguments);
                try { liftDvrCap(ytcfg); } catch (e) { /* ignore */ }
                return result;
            };
        }
        try { liftDvrCap(ytcfg); } catch (e) { /* config may already be present */ }
        return ytcfg;
    }

    // `ytcfg` is created by an inline page script that runs after us, so intercept its
    // creation to wrap `.set` early. If it already exists we wrap it here directly; the
    // try/catch guards the case where it was already defined as a non-configurable global.
    let cfgRef = hookYtcfg(window.ytcfg);
    try {
        Object.defineProperty(window, "ytcfg", {
            configurable: true,
            get() { return cfgRef; },
            set(value) { cfgRef = hookYtcfg(value); },
        });
    } catch (e) { /* already present and non-configurable — already hooked just above */ }
})();