持續更新 iwara.tv 影片串流 URL,讓閒置後播放與暫停/繼續不必重新載入頁面。
// ==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,
};
}