Iwara Keepalive

持续更新 iwara.tv 视频流 URL,让闲置后播放和暂停/继续无需重新加载页面。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         Iwara Keepalive
// @name:zh-CN Iwara Keepalive
// @name:zh-TW Iwara Keepalive
// @name:ja Iwara Keepalive
// @name:ko Iwara Keepalive
// @namespace    iwara-keepalive
// @version      0.1.0
// @description  Keeps iwara.tv video stream URLs fresh so idle-then-play and pause/resume work without a page reload.
// @description:zh-CN 持续更新 iwara.tv 视频流 URL,让闲置后播放和暂停/继续无需重新加载页面。
// @description:zh-TW 持續更新 iwara.tv 影片串流 URL,讓閒置後播放與暫停/繼續不必重新載入頁面。
// @description:ja iwara.tv の動画ストリーム URL を更新し、放置後の再生や一時停止/再開をページ再読み込みなしで動かします。
// @description:ko iwara.tv 동영상 스트림 URL을 최신 상태로 유지해 대기 후 재생과 일시정지/재개가 페이지 새로고침 없이 동작하게 합니다.
// @author       0xdev
// @license      LGPL-3.0-or-later
// @match        https://www.iwara.tv/*
// @match        https://iwara.tv/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: keepalive/iwara-keepalive.user.js
'use strict';
const NEAR_EXPIRY_MARGIN_SEC = 90;
function parseExpirySeconds(url) {
    let raw;
    try {
        raw = new URL(url, 'https://www.iwara.tv').searchParams.get('expires');
    }
    catch {
        return null;
    }
    if (raw == null || raw === '')
        return null;
    const n = Number(raw);
    if (!Number.isFinite(n) || n <= 0)
        return null;
    return n > 1e12 ? Math.floor(n / 1000) : n;
}
function classifyFreshness(url, nowSec, marginSec = NEAR_EXPIRY_MARGIN_SEC) {
    const exp = parseExpirySeconds(url);
    if (exp == null)
        return 'stale';
    if (nowSec >= exp)
        return 'stale';
    if (nowSec >= exp - marginSec)
        return 'near';
    return 'fresh';
}
function isStaleOrNear(url, nowSec, marginSec = NEAR_EXPIRY_MARGIN_SEC) {
    const f = classifyFreshness(url, nowSec, marginSec);
    return f === 'stale' || f === 'near';
}
function extractVideoId(pathname) {
    const m = /^\/video\/([^/]+)/.exec(pathname || '');
    return m ? m[1] : null;
}
function toAbsoluteUrl(url) {
    return url.startsWith('//') ? `https:${url}` : url;
}
function parseVariantName(viewUrl) {
    let filename;
    try {
        filename = new URL(viewUrl, 'https://www.iwara.tv').searchParams.get('filename');
    }
    catch {
        return null;
    }
    if (!filename)
        return null;
    const m = /_([^_.]+)\.[a-z0-9]+$/i.exec(filename);
    return m ? m[1] : null;
}
function pickVariant(variants, currentViewUrl) {
    if (!Array.isArray(variants) || variants.length === 0)
        return null;
    const byName = (name) => variants.find((v) => v && v.name === name);
    const desired = parseVariantName(currentViewUrl);
    return ((desired ? byName(desired) : undefined) ||
        byName('Source') ||
        variants.find((v) => v && v.name !== 'preview') ||
        variants[0]);
}
function shouldRefresh(currentUrl, nowSec, opts = {}) {
    if (opts.force)
        return true;
    return isStaleOrNear(currentUrl, nowSec, opts.marginSec);
}
const HARVEST_HEADER_KEYS = ['authorization', 'x-version', 'x-site'];
function pickHarvestableHeaders(url, headers) {
    let host;
    try {
        host = new URL(url, 'https://www.iwara.tv').host;
    }
    catch {
        return null;
    }
    if (!/(^|\.)iwara\.tv$/i.test(host))
        return null;
    const out = {};
    for (const k of HARVEST_HEADER_KEYS) {
        if (headers && headers[k])
            out[k] = headers[k];
    }
    return Object.keys(out).length ? out : null;
}
// Deliberately NO `credentials: 'include'`: the API only returns a readable
// CORS response in non-credentialed mode. With cookies it omits
// Access-Control-Allow-Origin and the browser blocks the fetch. iwara authorizes
// via the Bearer token we harvest into `headers`, not cookies.
async function fetchFreshVariants(videoId, headers, fetchImpl = fetch) {
    const base = { 'x-site': 'www.iwara.tv', ...(headers || {}) };
    const metaRes = await fetchImpl(`https://api.iwara.tv/video/${encodeURIComponent(videoId)}`, { headers: base });
    if (!metaRes.ok)
        throw new Error(`metadata ${metaRes.status}`);
    const meta = (await metaRes.json());
    if (!meta || !meta.fileUrl)
        throw new Error('no fileUrl in metadata');
    const manRes = await fetchImpl(meta.fileUrl, { headers: base });
    if (!manRes.ok)
        throw new Error(`manifest ${manRes.status}`);
    const variants = (await manRes.json());
    if (!Array.isArray(variants))
        throw new Error('manifest not an array');
    return variants;
}
async function selectFreshViewUrl(videoId, currentViewUrl, headers, fetchImpl = fetch) {
    const variants = await fetchFreshVariants(videoId, headers, fetchImpl);
    const variant = pickVariant(variants, currentViewUrl);
    if (!variant || !variant.src || !variant.src.view)
        throw new Error('no usable variant');
    return toAbsoluteUrl(variant.src.view);
}
function isVideoJsPlayer(value) {
    return !!value && typeof value === 'object' && typeof value.src === 'function';
}
function findVideoJsPlayer(video) {
    const win = video.ownerDocument.defaultView;
    const videojs = win?.videojs;
    const playerEl = video.closest('.video-js');
    const attachedPlayer = video.player || playerEl?.player;
    if (isVideoJsPlayer(attachedPlayer))
        return attachedPlayer;
    if (!videojs)
        return null;
    const candidates = [video];
    if (playerEl) {
        candidates.push(playerEl);
        if (playerEl.id)
            candidates.push(playerEl.id);
    }
    if (typeof videojs.getPlayer === 'function') {
        for (const candidate of candidates) {
            try {
                const player = videojs.getPlayer(candidate);
                if (isVideoJsPlayer(player))
                    return player;
            }
            catch { }
        }
    }
    if (playerEl?.id) {
        const player = videojs.players?.[playerEl.id];
        if (isVideoJsPlayer(player))
            return player;
    }
    return null;
}
function mediaTypeForUrl(viewUrl) {
    let sourceName;
    try {
        const url = new URL(viewUrl, 'https://www.iwara.tv');
        sourceName = url.searchParams.get('filename') || url.pathname;
    }
    catch {
        sourceName = viewUrl;
    }
    const lower = sourceName.toLowerCase();
    if (lower.endsWith('.mp4'))
        return 'video/mp4';
    if (lower.endsWith('.webm'))
        return 'video/webm';
    if (lower.endsWith('.m3u8'))
        return 'application/x-mpegURL';
    return undefined;
}
function readPlaybackState(video, player) {
    let at = video.currentTime;
    let wasPlaying = !video.paused;
    let rate = video.playbackRate;
    try {
        const playerTime = player?.currentTime?.();
        if (typeof playerTime === 'number' && Number.isFinite(playerTime))
            at = playerTime;
    }
    catch { }
    try {
        const playerPaused = player?.paused?.();
        if (typeof playerPaused === 'boolean')
            wasPlaying = !playerPaused;
    }
    catch { }
    try {
        const playerRate = player?.playbackRate?.();
        if (typeof playerRate === 'number' && Number.isFinite(playerRate))
            rate = playerRate;
    }
    catch { }
    return { at, wasPlaying, rate };
}
function restorePlayback(video, player, at, rate, wasPlaying) {
    try {
        if (player?.currentTime)
            player.currentTime(at);
        else
            video.currentTime = at;
    }
    catch { /* seek may be clamped; ignore */ }
    try {
        if (player?.playbackRate)
            player.playbackRate(rate);
        else
            video.playbackRate = rate;
    }
    catch { }
    if (wasPlaying) {
        try {
            const p = player?.play ? player.play() : video.play();
            if (p && typeof p.catch === 'function')
                p.catch(() => { });
        }
        catch { }
    }
}
function onceLoadedMetadata(video, player, handler) {
    if (player?.one) {
        try {
            player.one('loadedmetadata', handler);
            return;
        }
        catch { }
    }
    video.addEventListener('loadedmetadata', handler, { once: true });
}
function lockVideoJsLayout(video, player) {
    if (!player)
        return () => { };
    const playerEl = video.closest('.video-js');
    if (!playerEl)
        return () => { };
    const rect = playerEl.getBoundingClientRect();
    if (!Number.isFinite(rect.height) || rect.height <= 0)
        return () => { };
    const previousMinHeight = playerEl.style.minHeight;
    playerEl.style.minHeight = `${Math.round(rect.height)}px`;
    let released = false;
    return () => {
        if (released)
            return;
        released = true;
        playerEl.style.minHeight = previousMinHeight;
    };
}
function swapSource(video, viewUrl) {
    const freshUrl = toAbsoluteUrl(viewUrl);
    const player = findVideoJsPlayer(video);
    const { at, wasPlaying, rate } = readPlaybackState(video, player);
    const unlockLayout = lockVideoJsLayout(video, player);
    let restored = false;
    const restore = () => {
        if (restored)
            return;
        restored = true;
        restorePlayback(video, player, at, rate, wasPlaying);
        unlockLayout();
    };
    if (player?.src) {
        onceLoadedMetadata(video, player, restore);
        const source = { src: freshUrl };
        const type = mediaTypeForUrl(freshUrl);
        if (type)
            source.type = type;
        try {
            player.src(source);
            return;
        }
        catch { }
    }
    video.addEventListener('loadedmetadata', restore, { once: true });
    video.src = freshUrl;
    video.load();
}
if (typeof window !== 'undefined')
    (function main() {
        const LOG = '[iwara-keepalive]';
        const debug = (...a) => { try {
            console.debug(LOG, ...a);
        }
        catch { /* console may be gone */ } };
        let harvestedHeaders = {};
        const nativeFetch = window.fetch ? window.fetch.bind(window) : null;
        if (nativeFetch) {
            window.fetch = function keepaliveFetch(input, init) {
                try {
                    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
                    const raw = {};
                    const collect = (h) => {
                        if (!h)
                            return;
                        if (h instanceof Headers)
                            h.forEach((v, k) => { raw[k.toLowerCase()] = v; });
                        else if (Array.isArray(h))
                            for (const [k, v] of h)
                                raw[k.toLowerCase()] = v;
                        else
                            for (const k of Object.keys(h))
                                raw[k.toLowerCase()] = h[k];
                    };
                    if (input instanceof Request)
                        collect(input.headers);
                    if (init && init.headers)
                        collect(init.headers);
                    const picked = pickHarvestableHeaders(url, raw);
                    if (picked)
                        harvestedHeaders = { ...harvestedHeaders, ...picked };
                }
                catch { /* harvesting is best-effort; never break the site's fetch */ }
                return nativeFetch(input, init);
            };
        }
        let refreshInFlight = false;
        const maybeRefresh = async (video, opts = {}) => {
            if (refreshInFlight)
                return;
            const videoId = extractVideoId(location.pathname);
            if (!videoId)
                return;
            const current = video.currentSrc || video.src || '';
            const nowSec = Date.now() / 1000;
            if (!shouldRefresh(current, nowSec, opts))
                return;
            refreshInFlight = true;
            try {
                const fresh = await selectFreshViewUrl(videoId, current, harvestedHeaders);
                if (fresh && fresh !== current) {
                    debug('refreshing stale source', opts.force ? '(forced)' : '');
                    swapSource(video, fresh);
                }
            }
            catch (e) {
                debug('refresh failed:', e instanceof Error ? e.message : e);
            }
            finally {
                refreshInFlight = false;
            }
        };
        const BOUND = new WeakSet();
        const bindVideo = (video) => {
            if (!video || BOUND.has(video))
                return;
            BOUND.add(video);
            video.addEventListener('play', () => { maybeRefresh(video); });
            video.addEventListener('seeking', () => { maybeRefresh(video); });
            video.addEventListener('error', () => {
                if (video.error && video.error.code === 4)
                    maybeRefresh(video, { force: true });
            });
            debug('bound video element');
        };
        const scanAndBind = () => { document.querySelectorAll('video').forEach(bindVideo); };
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState !== 'visible')
                return;
            document.querySelectorAll('video').forEach((v) => maybeRefresh(v));
        });
        let scanTimer = null;
        const observer = new MutationObserver(() => {
            if (scanTimer)
                return;
            scanTimer = setTimeout(() => { scanTimer = null; scanAndBind(); }, 200);
        });
        const start = () => {
            scanAndBind();
            observer.observe(document.documentElement, { childList: true, subtree: true });
            debug('active');
        };
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', start, { once: true });
        }
        else {
            start();
        }
    })();
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = {
        NEAR_EXPIRY_MARGIN_SEC,
        parseExpirySeconds,
        classifyFreshness,
        isStaleOrNear,
        extractVideoId,
        toAbsoluteUrl,
        parseVariantName,
        pickVariant,
        shouldRefresh,
        HARVEST_HEADER_KEYS,
        pickHarvestableHeaders,
        fetchFreshVariants,
        selectFreshViewUrl,
        swapSource,
    };
}