☰

Iwara Keepalive

Keeps iwara.tv video stream URLs fresh so idle-then-play and pause/resume work without a page reload.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==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.6
// @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';
// ───────────────────────────────────────────────────────────────────────────
// Playback, swap and refresh-coordination helpers. No top-level import/export
// (keeps the emit a plain script so it runs under @grant none). Stream-URL,
// variant and token helpers live in iwara/shared/src/stream-urls.ts, loaded
// after this file; src/main.ts runs last. TypeScript adds annotations only.
// ───────────────────────────────────────────────────────────────────────────
// Resolved on each call: in the page, stream-urls.ts registers its api after
// this file has run.
function streamUrls() {
    return typeof window === 'undefined'
        ? module.require('../../shared/src/stream-urls.ts')
        : globalThis[Symbol.for('iwara/stream-urls/v1')];
}
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');
    // Video.js 7 sets `player` on both the tech element and the `.video-js`
    // wrapper, then clears the tech element's copy once the first tech loads;
    // on iwara only the wrapper's reference survives.
    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 readCurrentSource(video) {
    try {
        const playerSource = findVideoJsPlayer(video)?.currentSrc?.();
        if (typeof playerSource === 'string' && playerSource)
            return playerSource;
    }
    catch { }
    return video.currentSrc || video.src || '';
}
function isManagedVideo(video) {
    return video.classList.contains('vjs-tech');
}
function managedVideos(doc) {
    return doc.querySelectorAll('video.vjs-tech');
}
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 requestPlay(video, player) {
    try {
        const p = player?.play ? player.play() : video.play();
        if (p && typeof p.catch === 'function')
            p.catch(() => { });
    }
    catch { }
}
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)
        requestPlay(video, player);
}
// Position and play state worth remembering from a healthy element, so a
// revive after a fatal error knows where to land. A load reset also drives
// currentTime to 0, but leaves readyState at 0; a genuine seek to the start
// (a replay after `ended`) happens on an element that still has metadata.
// Only the second is a position.
function rememberPlayback(video, last) {
    const t = video.currentTime;
    if (Number.isFinite(t) && (t > 0 || video.readyState > 0))
        last.t = t;
    if (!video.error)
        last.playing = !video.paused && !video.ended;
}
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;
    };
}
// How long a swapped-in source may take to deliver metadata before the swap
// counts as stalled: well above a CDN round trip, well below how long a person
// waits before giving up and reloading the page.
const SWAP_STALL_MS = 10_000;
function sameSource(a, b) {
    try {
        return new URL(a).href === new URL(b).href;
    }
    catch {
        return a === b;
    }
}
const pendingRestores = new WeakMap();
// Swapping a source resets the element: paused, no duration, position 0.
// When resuming, playback is requested right after the source change, not
// after metadata: Video.js queues the request until the new source is ready,
// and a pending play makes the browser fetch regardless of `preload`. The
// precise seek still waits for metadata. Waiting for `loadedmetadata` before
// doing anything left a replacement load that never delivered it as a paused
// element with no duration under a Video.js control bar still showing
// playing — the reported stuck player.
function swapSource(video, viewUrl, opts = {}) {
    const freshUrl = streamUrls().toAbsoluteUrl(viewUrl);
    const player = findVideoJsPlayer(video);
    // A swap started while an earlier one still waits to restore (a second
    // refresh, or the retry after a stall) takes over that swap's target: the
    // element now shows the earlier swap's load, not the person's position.
    // The earlier swap is cancelled so its metadata handler cannot seek too.
    const earlier = pendingRestores.get(video);
    const inherited = earlier && sameSource(readCurrentSource(video), earlier.src) ? earlier : null;
    earlier?.cancel();
    const reading = readPlaybackState(video, player);
    const at = inherited ? inherited.restoreAt : reading.at;
    const wasPlaying = inherited ? inherited.resume : reading.wasPlaying;
    const rate = inherited ? inherited.rate : reading.rate;
    // After a fatal media error the element may have dropped its position and
    // playing state; the caller passes what it last observed. A position read
    // from the element (or inherited above) wins; the memory only fills in what
    // the error erased.
    const restoreAt = Number.isFinite(at) && at > 0.25
        ? at
        : typeof opts.restoreAt === 'number' && Number.isFinite(opts.restoreAt) && opts.restoreAt > 0
            ? opts.restoreAt
            : at;
    const resume = wasPlaying || opts.resume === true;
    const unlockLayout = lockVideoJsLayout(video, player);
    let settled = false;
    let stallTimer = null;
    const settle = () => {
        if (settled)
            return;
        settled = true;
        if (stallTimer)
            clearTimeout(stallTimer);
        video.removeEventListener('error', onError);
        unlockLayout();
    };
    const pending = { src: freshUrl, restoreAt, resume, rate, cancel: settle };
    pendingRestores.set(video, pending);
    const restore = () => {
        if (settled)
            return;
        settle();
        if (pendingRestores.get(video) === pending)
            pendingRestores.delete(video);
        restorePlayback(video, player, restoreAt, rate, resume);
    };
    // A media error of this load means it failed; the error handler owns
    // recovery, and the target stays pending for the swap that follows. The
    // outgoing source can still fail its last fetch before Video.js applies the
    // new one; that error belongs to the old load and is ignored.
    const onError = () => {
        if (video.currentSrc && !sameSource(video.currentSrc, freshUrl))
            return;
        settle();
    };
    video.addEventListener('error', onError);
    if (opts.onStall && !opts.afterStall) {
        const onStall = opts.onStall;
        stallTimer = setTimeout(() => {
            if (settled)
                return;
            settle();
            onStall({ restoreAt, resume });
        }, SWAP_STALL_MS);
    }
    if (player?.src) {
        onceLoadedMetadata(video, player, restore);
        const source = { src: freshUrl };
        const type = mediaTypeForUrl(freshUrl);
        if (type)
            source.type = type;
        try {
            player.src(source);
            if (resume)
                requestPlay(video, player);
            return;
        }
        catch { }
    }
    video.addEventListener('loadedmetadata', restore, { once: true });
    video.src = freshUrl;
    video.load();
    if (resume)
        requestPlay(video, null);
}
// ── Terminal-failure handling ───────────────────────────────────────────────
// isProductiveRefresh and createRefreshGate live in the shared stream-URL
// helpers. main.ts passes this cap to the gate, so the log below names the
// number of attempts the gate allows.
const MAX_RECOVERY_ATTEMPTS = 3;
// ── Refresh coordination ────────────────────────────────────────────────────
// One manifest fetch at a time. A plain freshness check that arrives while one
// is in flight is redundant and is dropped. A forced request (player error)
// is not redundant: it carries the position and play state the error may have
// wiped, and it bypasses the buffered-check and gate suppression. It is kept:
// its memory is applied to the in-flight swap, and it runs on its own when
// that swap does not happen (same URL back, or the fetch failed). Dropping it
// was a field failure: "reviving dead player" in the console, followed by a
// plain swap that did not know where playback had been.
function createRefresher(deps) {
    let inFlight = false;
    let queuedRevive = null;
    const beginRecovery = (id) => {
        if (deps.gate.beginRecovery(id))
            return true;
        deps.log(`video still cannot play after ${MAX_RECOVERY_ATTEMPTS} recovery attempts; reload the page to retry`);
        return false;
    };
    const maybeRefresh = async (video, opts = {}) => {
        if (inFlight) {
            if (opts.force)
                queuedRevive = { restoreAt: opts.restoreAt, resume: opts.resume };
            return;
        }
        const videoId = deps.videoId();
        if (!videoId)
            return;
        if (!deps.gate.allow(videoId, { force: opts.force }))
            return;
        // A fully buffered video cannot die from URL expiry — playback and seeks
        // are served from the buffer, and a swap would only throw that away.
        if (!opts.force && deps.fullyBuffered(video))
            return;
        const current = deps.readSource(video);
        const nowSec = deps.now();
        if (!streamUrls().shouldRefresh(current, nowSec, opts))
            return;
        if (opts.force && !beginRecovery(videoId))
            return;
        inFlight = true;
        let swapped = false;
        try {
            const viewUrl = await deps.selectFresh(videoId, current, nowSec);
            const productive = streamUrls().isProductiveRefresh(viewUrl, current, deps.now());
            if (deps.gate.recordSuccess(videoId, { productive })) {
                deps.log('refresh keeps returning a source that still reads as stale; automatic refreshes paused');
            }
            if (viewUrl && viewUrl !== current) {
                const revive = queuedRevive;
                queuedRevive = null;
                // An error that joined an automatic refresh consumes one recovery
                // attempt; duplicate errors during the same request share it.
                if (revive && !opts.force && !beginRecovery(videoId))
                    return;
                const memory = revive
                    ? { restoreAt: revive.restoreAt ?? opts.restoreAt, resume: revive.resume ?? opts.resume }
                    : { restoreAt: opts.restoreAt, resume: opts.resume };
                deps.log('refreshing stale source', opts.force || revive ? '(forced)' : '');
                deps.swap(video, viewUrl, { ...memory, afterStall: opts.afterStall === true });
                swapped = true;
            }
        }
        catch (e) {
            deps.log('refresh failed:', e instanceof Error ? e.message : e);
            if (deps.gate.recordFailure(videoId))
                deps.onGaveUp();
        }
        finally {
            inFlight = false;
        }
        if (!swapped && queuedRevive) {
            const revive = queuedRevive;
            queuedRevive = null;
            await maybeRefresh(video, { force: true, ...revive });
        }
    };
    return maybeRefresh;
}
const NOTICE_ID = 'iwara-keepalive-notice';
const NOTICE_TIMEOUT_MS = 12_000;
function showRefreshNotice(doc, message) {
    const existing = doc.getElementById(NOTICE_ID);
    if (existing)
        return existing;
    if (!doc.body)
        return null;
    const el = doc.createElement('div');
    el.id = NOTICE_ID;
    el.textContent = message;
    el.style.cssText = [
        'position:fixed',
        'right:16px',
        'bottom:16px',
        'z-index:2147483647',
        'max-width:340px',
        'padding:10px 14px',
        'border-radius:8px',
        'background:rgba(20,20,24,0.92)',
        'color:#fff',
        'font:13px/1.45 system-ui,sans-serif',
        'box-shadow:0 4px 16px rgba(0,0,0,0.35)',
        'pointer-events:none',
    ].join(';');
    doc.body.appendChild(el);
    setTimeout(() => { try {
        el.remove();
    }
    catch { /* already gone */ } }, NOTICE_TIMEOUT_MS);
    return el;
}
const keepaliveApi = Object.freeze({
    swapSource,
    SWAP_STALL_MS,
    MAX_RECOVERY_ATTEMPTS,
    rememberPlayback,
    createRefresher,
    readCurrentSource,
    isManagedVideo,
    managedVideos,
    showRefreshNotice,
});
globalThis[Symbol.for('iwara-keepalive/core/v1')] = keepaliveApi;
if (typeof window === 'undefined' && typeof module !== 'undefined' && module.exports) {
    module.exports = keepaliveApi;
}

"use strict";
// iwara stream URLs: signed-URL expiry, variant choice and token renewal,
// shared by Iwara Keepalive and Iwara NeoUI. Registered under
// Symbol.for('iwara/stream-urls/v1').
//
// Every api function is a plain function declaration that calls the others by
// bare name and uses only its parameters and browser globals: no module
// constants. NeoUI injects them into the page by joining their toString(), so
// a reference to anything else in this file would fail there.
(() => {
    'use strict';
    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;
    }
    // marginSec: how long before expiry a URL already counts as 'near'.
    function classifyFreshness(url, nowSec, marginSec = 90) {
        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 = 90) {
        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);
    }
    // ── Buffered awareness ────────────────────────────────────────────────────
    // An expired URL is only fatal for data the player still has to FETCH. When
    // the buffer already covers the whole video, no refresh is needed at all —
    // and swapping the source would throw that buffer away.
    function isFullyBuffered(video, slackSec = 0.5) {
        const dur = video.duration;
        if (typeof dur !== 'number' || !Number.isFinite(dur) || dur <= 0)
            return false;
        const buffered = video.buffered;
        if (!buffered || !buffered.length)
            return false;
        const ranges = [];
        try {
            for (let i = 0; i < buffered.length; i++)
                ranges.push([buffered.start(i), buffered.end(i)]);
        }
        catch {
            return false;
        }
        ranges.sort((a, b) => a[0] - b[0]);
        let covered = 0;
        for (const [start, end] of ranges) {
            if (start > covered + slackSec)
                return false; // a gap a seek could land in
            if (end > covered)
                covered = end;
        }
        return covered >= dur - slackSec;
    }
    // Which player errors warrant attempting a URL refresh: code 4 (src rejected
    // outright); code 2 (download died part-way) only when the URL is
    // stale/near — that is expiry killing an unbuffered fetch (mid-play or a seek
    // into an unbuffered region). Code 2 on a fresh URL is a real network problem
    // and code 3 is corrupt media; a swap fixes neither.
    function shouldForceRefreshOnError(code, currentUrl, nowSec) {
        if (code === 4)
            return true;
        if (code === 2)
            return isStaleOrNear(currentUrl, nowSec);
        return false;
    }
    // A refresh only helps if it hands back a DIFFERENT url that reads as fresh.
    // Anything else (the same url again, one already past its expiry, or one whose
    // expiry cannot be read at all) leaves the next freshness check saying "stale"
    // — and since restoring playback fires `play`, that check runs immediately.
    function isProductiveRefresh(freshUrl, currentUrl, nowSec) {
        if (!freshUrl || freshUrl === currentUrl)
            return false;
        return !isStaleOrNear(freshUrl, nowSec);
    }
    // Metadata failures and exhausted media recovery block all checks. Repeated
    // unchanged/stale URLs only suppress automatic checks. Fetching a URL does
    // not prove that its media plays, so it cannot reset the recovery allowance.
    // A refresh whose source keeps 404ing even after token renewal cannot be
    // kept alive; without the gate every play/seek would hit the API again.
    function createRefreshGate(maxConsecutiveFailures = 3, maxUnproductive = 2, maxRecoveryAttempts = 3) {
        let currentId = null;
        let failures = 0;
        let tripped = false;
        let unproductive = 0;
        let suppressed = false;
        let recoveries = 0;
        let recoveryStopped = false;
        const sync = (id) => {
            if (id !== currentId) {
                currentId = id;
                failures = 0;
                tripped = false;
                unproductive = 0;
                suppressed = false;
                recoveries = 0;
                recoveryStopped = false;
            }
        };
        return {
            allow(id, opts = {}) {
                sync(id);
                if (tripped || recoveryStopped)
                    return false;
                return !suppressed || opts.force === true;
            },
            beginRecovery(id) {
                sync(id);
                if (recoveries >= maxRecoveryAttempts) {
                    recoveryStopped = true;
                    return false;
                }
                recoveries += 1;
                return true;
            },
            recordPlayback(id) {
                sync(id);
                recoveries = 0;
                recoveryStopped = false;
            },
            recordFailure(id) {
                sync(id);
                if (tripped)
                    return false;
                failures += 1;
                if (failures < maxConsecutiveFailures)
                    return false;
                tripped = true;
                return true; // just tripped — caller shows its one-time notice on this
            },
            recordSuccess(id, opts = {}) {
                sync(id);
                failures = 0;
                tripped = false;
                if (opts.productive === false) {
                    if (suppressed)
                        return false;
                    unproductive += 1;
                    if (unproductive < maxUnproductive)
                        return false;
                    suppressed = true;
                    return true; // just suppressed — caller logs this once
                }
                unproductive = 0;
                suppressed = false;
                return false;
            },
        };
    }
    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 ['authorization', 'x-version', 'x-site']) {
            if (headers && headers[k])
                out[k] = headers[k];
        }
        return Object.keys(out).length ? out : null;
    }
    // ── Auth tokens ───────────────────────────────────────────────────────────
    // The API silently IGNORES invalid/expired Bearer tokens (no 401 — the
    // request is treated as anonymous) and answers 404 for videos that are
    // tag-gated to logged-in users. Signed stream URLs live ~50 min, about as
    // long as an access token, so by the time a refresh is needed the harvested
    // token is dead and a gated video 404s. These helpers classify tokens and
    // renew them the same way the site does: POST /user/token with the
    // long-lived token as Bearer.
    function stripBearer(value) {
        return String(value ?? '').replace(/^\s*Bearer\s+/i, '').trim();
    }
    function decodeJwtPayload(value) {
        const parts = stripBearer(value).split('.');
        if (parts.length !== 3)
            return null;
        try {
            const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
            const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
            const parsed = JSON.parse(atob(padded));
            return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
                ? parsed
                : null;
        }
        catch {
            return null;
        }
    }
    // 'opaque' (undecodable) stays usable as an access token — that is what the
    // script always did before tokens were classified, and the server ignores
    // bad tokens anyway. Expiry wins over type: an expired refresh token is
    // 'stale'. marginSec: how long before its expiry a token already counts as
    // stale.
    function classifyAuthValue(value, nowSec, marginSec = 30) {
        const payload = decodeJwtPayload(value);
        if (!payload)
            return 'opaque';
        const exp = typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp : null;
        if (exp != null && nowSec >= exp - marginSec)
            return 'stale';
        if (typeof payload.type === 'string' && /refresh/i.test(payload.type))
            return 'renewal';
        return 'access';
    }
    // The site keeps its long-lived token in localStorage; the stored shape is
    // not pinned by iwara, so accept a raw JWT, a JSON-quoted JWT, or a JSON
    // object with a token-ish field.
    function extractStoredToken(raw) {
        if (!raw)
            return null;
        const looksJwt = (s) => typeof s === 'string' && /^[\w-]+\.[\w-]+\.[\w-]+$/.test(s);
        const trimmed = String(raw).trim();
        if (looksJwt(trimmed))
            return trimmed;
        try {
            const parsed = JSON.parse(trimmed);
            if (looksJwt(parsed))
                return parsed;
            if (parsed && typeof parsed === 'object') {
                for (const key of ['token', 'accessToken', 'refreshToken']) {
                    const v = parsed[key];
                    if (looksJwt(v))
                        return v;
                }
            }
        }
        catch { /* not JSON */ }
        return null;
    }
    // Request headers with lower-case names, from a Request and from init.
    function requestHeaders(input, init) {
        const out = {};
        const collect = (headers) => {
            if (!headers)
                return;
            if (typeof Headers !== 'undefined' && headers instanceof Headers)
                headers.forEach((value, name) => { out[name.toLowerCase()] = value; });
            else if (Array.isArray(headers))
                for (const [name, value] of headers)
                    out[name.toLowerCase()] = value;
            else
                for (const name of Object.keys(headers))
                    out[name.toLowerCase()] = headers[name];
        };
        if (typeof Request !== 'undefined' && input instanceof Request)
            collect(input.headers);
        collect(init?.headers);
        return out;
    }
    // Authorization goes to a slot per token type: the site's own token
    // renewal request carries the long-lived token, which must not replace
    // the access token (or the reverse), or later refreshes fail.
    function harvestAuth(auth, url, headers, nowSec) {
        const picked = pickHarvestableHeaders(url, headers);
        if (!picked)
            return;
        const { authorization, ...rest } = picked;
        auth.headers = { ...auth.headers, ...rest };
        if (!authorization)
            return;
        const kind = classifyAuthValue(authorization, nowSec);
        if (kind === 'renewal')
            auth.renewalToken = stripBearer(authorization);
        else if (kind !== 'stale')
            auth.accessToken = stripBearer(authorization);
    }
    // The site keeps its long-lived token in localStorage under `token`; it is
    // the renewal fallback while no renewal token was harvested.
    function storedRenewalToken(win) {
        try {
            return extractStoredToken(win.localStorage.getItem('token'));
        }
        catch {
            return null; // storage access can throw in hardened contexts
        }
    }
    async function renewAccessToken(renewalToken, fetchImpl = fetch) {
        const res = await fetchImpl('https://api.iwara.tv/user/token', {
            method: 'POST',
            headers: { 'x-site': 'www.iwara.tv', authorization: `Bearer ${stripBearer(renewalToken)}` },
        });
        if (!res.ok)
            throw new Error(`renew ${res.status}`);
        const body = (await res.json());
        if (!body || typeof body.accessToken !== 'string' || !body.accessToken) {
            throw new Error('no accessToken in renewal response');
        }
        return body.accessToken;
    }
    // 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 harvested 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);
    }
    // One metadata attempt with the best token we hold (none, if it is provably
    // dead — the server would ignore it and the anonymous result is the same),
    // then on 404 renew once and retry once. A 404 with a working renewal token
    // is the tag-gated case; without one it is genuinely deleted/private.
    // Non-404 failures are not auth problems and propagate untouched.
    async function fetchFreshVariantsWithAuth(videoId, auth, nowSec, fetchImpl = fetch) {
        const base = { ...(auth.base || {}) };
        delete base.authorization; // the chain owns the Authorization header
        const attempt = (token) => {
            const headers = token ? { ...base, authorization: `Bearer ${stripBearer(token)}` } : base;
            return fetchFreshVariants(videoId, headers, fetchImpl);
        };
        const access = auth.accessToken || null;
        const cls = access ? classifyAuthValue(access, nowSec) : null;
        const usableAccess = access && (cls === 'access' || cls === 'opaque') ? access : null;
        try {
            return { variants: await attempt(usableAccess), renewedAccessToken: null };
        }
        catch (e) {
            const msg = e instanceof Error ? e.message : String(e);
            const renewal = auth.renewalToken || null;
            if (!/^metadata 404\b/.test(msg) || !renewal)
                throw e;
            let renewed;
            try {
                renewed = await renewAccessToken(renewal, fetchImpl);
            }
            catch (renewErr) {
                const rmsg = renewErr instanceof Error ? renewErr.message : String(renewErr);
                throw new Error(`${msg}; renew failed: ${rmsg}`, { cause: renewErr });
            }
            return { variants: await attempt(renewed), renewedAccessToken: renewed };
        }
    }
    // A manifest without a usable variant fails after the token chain, so it
    // never triggers a renewal.
    async function selectFreshViewUrlWithAuth(videoId, currentViewUrl, auth, nowSec, fetchImpl = fetch) {
        const { variants, renewedAccessToken } = await fetchFreshVariantsWithAuth(videoId, auth, nowSec, fetchImpl);
        const variant = pickVariant(variants, currentViewUrl);
        if (!variant || !variant.src || !variant.src.view)
            throw new Error('no usable variant');
        return { viewUrl: toAbsoluteUrl(variant.src.view), renewedAccessToken };
    }
    const api = Object.freeze({
        parseExpirySeconds,
        classifyFreshness,
        isStaleOrNear,
        extractVideoId,
        toAbsoluteUrl,
        parseVariantName,
        pickVariant,
        shouldRefresh,
        isFullyBuffered,
        shouldForceRefreshOnError,
        isProductiveRefresh,
        createRefreshGate,
        pickHarvestableHeaders,
        stripBearer,
        decodeJwtPayload,
        classifyAuthValue,
        extractStoredToken,
        requestHeaders,
        harvestAuth,
        storedRenewalToken,
        renewAccessToken,
        fetchFreshVariants,
        fetchFreshVariantsWithAuth,
        selectFreshViewUrl,
        selectFreshViewUrlWithAuth,
    });
    globalThis[Symbol.for('iwara/stream-urls/v1')] = api;
    if (typeof module !== 'undefined' && module.exports)
        module.exports = api;
})();

"use strict";
// Iwara Keepalive entry point. Listed last in build.json so the helpers it
// uses are registered before it runs at load.
if (typeof window !== 'undefined')
    (function main() {
        'use strict';
        // Every source file before this one has registered its api.
        const lib = (key) => globalThis[Symbol.for(key)];
        const { createRefreshGate, extractVideoId, harvestAuth, isFullyBuffered, requestHeaders, selectFreshViewUrlWithAuth, shouldForceRefreshOnError, storedRenewalToken, } = lib('iwara/stream-urls/v1');
        const { MAX_RECOVERY_ATTEMPTS, createRefresher, isManagedVideo, managedVideos, readCurrentSource, rememberPlayback, showRefreshNotice, swapSource, } = lib('iwara-keepalive/core/v1');
        const LOG = '[iwara-keepalive]';
        const debug = (...a) => { try {
            console.debug(LOG, ...a);
        }
        catch { /* console may be gone */ } };
        // Headers and tokens from the site's own API requests, replayed by refreshes.
        const auth = { headers: {}, accessToken: null, renewalToken: null };
        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;
                    harvestAuth(auth, url, requestHeaders(input, init), Date.now() / 1000);
                }
                catch { /* harvesting is best-effort; never break the site's fetch */ }
                return nativeFetch(input, init);
            };
        }
        const refreshGate = createRefreshGate(3, 2, MAX_RECOVERY_ATTEMPTS);
        const maybeRefresh = createRefresher({
            videoId: () => extractVideoId(location.pathname),
            readSource: readCurrentSource,
            fullyBuffered: isFullyBuffered,
            selectFresh: async (videoId, current, nowSec) => {
                const { viewUrl, renewedAccessToken } = await selectFreshViewUrlWithAuth(videoId, current, {
                    base: auth.headers,
                    accessToken: auth.accessToken,
                    renewalToken: auth.renewalToken || storedRenewalToken(window),
                }, nowSec);
                if (renewedAccessToken)
                    auth.accessToken = renewedAccessToken;
                return viewUrl;
            },
            swap: (video, viewUrl, opts) => {
                swapSource(video, viewUrl, {
                    ...opts,
                    onStall: (memory) => {
                        debug('swapped source never loaded; refreshing once more');
                        maybeRefresh(video, { force: true, afterStall: true, ...memory });
                    },
                });
            },
            gate: refreshGate,
            now: () => Date.now() / 1000,
            log: debug,
            onGaveUp: () => {
                showRefreshNotice(document, 'Iwara Keepalive: could not refresh this video — it may be deleted, private, or hidden unless logged in. Reload the page to retry.');
            },
        });
        // How often the freshness check runs during playback. Driven by the
        // element's own `timeupdate` events (~4 Hz while playing, silent otherwise),
        // NOT a timer — well inside the 90 s near-expiry margin, so a playing video
        // is re-signed before its URL can go stale under it.
        const PLAYING_CHECK_INTERVAL_SEC = 20;
        const BOUND = new WeakSet();
        const bindVideo = (video) => {
            if (!video || !isManagedVideo(video) || BOUND.has(video))
                return;
            BOUND.add(video);
            // Last position/playing state observed while the element was healthy —
            // a fatal media error can wipe both, and recovery needs somewhere to land.
            // During `seeking`, currentTime is already the seek TARGET, so a seek that
            // dies on an expired URL is revived at the position the user clicked.
            const last = { t: 0, playing: false };
            let lastFreshnessCheckSec = 0;
            const remember = () => { rememberPlayback(video, last); };
            let sourceVideoId = extractVideoId(location.pathname);
            video.addEventListener('loadstart', () => { sourceVideoId = extractVideoId(location.pathname); });
            video.addEventListener('canplay', () => {
                const id = extractVideoId(location.pathname);
                if (id && id === sourceVideoId && video.isConnected && !video.error && video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) {
                    refreshGate.recordPlayback(id);
                }
            });
            video.addEventListener('play', () => { remember(); maybeRefresh(video); });
            video.addEventListener('seeking', () => { remember(); maybeRefresh(video); });
            video.addEventListener('pause', () => { remember(); });
            video.addEventListener('timeupdate', () => {
                remember();
                const nowSec = Date.now() / 1000;
                if (nowSec - lastFreshnessCheckSec < PLAYING_CHECK_INTERVAL_SEC)
                    return;
                lastFreshnessCheckSec = nowSec;
                maybeRefresh(video);
            });
            video.addEventListener('error', () => {
                const code = video.error && video.error.code;
                const current = readCurrentSource(video);
                if (!shouldForceRefreshOnError(code, current, Date.now() / 1000))
                    return;
                debug('reviving dead player, error code', code);
                maybeRefresh(video, { force: true, restoreAt: last.t, resume: last.playing });
            });
            debug('bound video element');
        };
        const scanAndBind = () => { managedVideos(document).forEach(bindVideo); };
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState !== 'visible')
                return;
            managedVideos(document).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();
        }
    })();