From 123809fb9ad4093d5717581c585c56aaa51634bf Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Fri, 15 May 2026 11:31:32 +0200 Subject: [PATCH] fix: suppress HLS disk cache via Fetch API loader instead of XHR headers Chromium evaluates the Fetch API's cache mode before the disk cache storage decision; XHR request headers and onHeadersReceived patches do not reliably prevent storage. The old approach silently allowed caching regardless of the setting. Moving cache suppression to a custom hls.js NoCacheLoader (Fetch with cache: 'no-store') also eliminates the CORS preflight that Cache-Control as a request header was triggering, which lets the main-process CORS handler drop the applyNoStore branch entirely. --- src/main/index.ts | 31 +++----- src/renderer/src/components/VideoPlayer.tsx | 13 +++- src/renderer/src/lib/hlsNoCacheLoader.ts | 86 +++++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/lib/hlsNoCacheLoader.ts diff --git a/src/main/index.ts b/src/main/index.ts index c8ccf2b..661fe8f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { HelixClient } from './twitch/helixClient' import { PlaybackService } from './playback/playbackService' import { registerIpcHandlers } from './ipc/handlers' import { startGamepadReader } from './input/gamepadReader' -import { getHlsCacheEnabled } from './prefs/hlsCachePref' + import log from 'electron-log/main' log.initialize() @@ -63,23 +63,22 @@ function createWindow(): BrowserWindow { return win } -function isHlsStreamContent(url: string): boolean { - const pathEnd = url.indexOf('?') - const path = pathEnd === -1 ? url : url.slice(0, pathEnd) - return /\.(m3u8|ts|m4s|aac)$/i.test(path) -} - /** * CORS-Bypass für Twitch CDN und HLS-Streams. * hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet * Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests * und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert. - * Zusätzlich: cache-control: no-store für HLS-Inhalte wenn hlsCacheEnabled=false. + * + * HLS-Cache-Unterdrückung: xhrSetup in hls.js setzt Cache-Control: no-store auf jedem + * XHR. Das löst einen CORS-Preflight aus (Cache-Control ist kein safelisted header). + * Damit der Preflight durchkommt, muss onHeadersReceived auch ttvnw.net-Domains abdecken + * (Segment-CDN: *.j.cloudfront.hls.ttvnw.net — enthält nicht "cloudfront.net"). */ function setupTwitchCors(): void { const isTwitchCdn = (url: string): boolean => url.includes('twitchsvc.net') || url.includes('cloudfront.net') || + url.includes('ttvnw.net') || url.includes('twitch.tv') || url.includes('twitch.amazon.com') @@ -92,23 +91,15 @@ function setupTwitchCors(): void { }) session.defaultSession.webRequest.onHeadersReceived((details, callback) => { - const isCdn = isTwitchCdn(details.url) - const applyNoStore = isHlsStreamContent(details.url) && !getHlsCacheEnabled() - - if (!isCdn && !applyNoStore) { callback({}); return } + if (!isTwitchCdn(details.url)) { callback({}); return } const headers: Record = {} for (const [k, v] of Object.entries(details.responseHeaders ?? {})) { headers[k.toLowerCase()] = Array.isArray(v) ? v : [v] } - if (isCdn) { - headers['access-control-allow-origin'] = ['*'] - headers['access-control-allow-headers'] = ['*'] - headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS'] - } - if (applyNoStore) { - headers['cache-control'] = ['no-store'] - } + headers['access-control-allow-origin'] = ['*'] + headers['access-control-allow-headers'] = ['*'] + headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS'] callback({ responseHeaders: headers }) }) } diff --git a/src/renderer/src/components/VideoPlayer.tsx b/src/renderer/src/components/VideoPlayer.tsx index f9333bc..a48a3cb 100644 --- a/src/renderer/src/components/VideoPlayer.tsx +++ b/src/renderer/src/components/VideoPlayer.tsx @@ -1,6 +1,8 @@ import Hls from 'hls.js' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback' +import { useSettings } from '../context/SettingsContext' +import { NoCacheLoader } from '../lib/hlsNoCacheLoader' import log from 'electron-log/renderer' export interface VideoPlayerHandle { @@ -30,6 +32,7 @@ export const VideoPlayer = forwardRef( ({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => { const videoRef = useRef(null) const hlsRef = useRef(null) + const { settings } = useSettings() useImperativeHandle(ref, () => ({ seek(delta: number) { @@ -85,7 +88,15 @@ export const VideoPlayer = forwardRef( log.info('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80)) if (Hls.isSupported()) { - const hls = new Hls({ enableWorker: false }) + const hlsConfig: Partial = { enableWorker: false } + if (!settings.hlsCacheEnabled) { + // XHR-based approaches (xhrSetup, webRequest hooks) cannot prevent Chromium's HTTP + // disk cache from storing responses — the cache layer ignores no-store in XHR request + // headers. The Fetch API's cache mode is the only reliable mechanism: it is evaluated + // before the cache storage decision is made. + hlsConfig.loader = NoCacheLoader + } + const hls = new Hls(hlsConfig) hlsRef.current = hls // Attach media first, then load the source — more reliable in Electron/Chromium diff --git a/src/renderer/src/lib/hlsNoCacheLoader.ts b/src/renderer/src/lib/hlsNoCacheLoader.ts new file mode 100644 index 0000000..387530c --- /dev/null +++ b/src/renderer/src/lib/hlsNoCacheLoader.ts @@ -0,0 +1,86 @@ +import type { HlsConfig, LoaderCallbacks, LoaderConfiguration, LoaderContext, LoaderStats } from 'hls.js' + +function makeStats(): LoaderStats { + return { + aborted: false, + loaded: 0, + retry: 0, + total: 0, + chunkCount: 0, + bwEstimate: 0, + loading: { start: 0, first: 0, end: 0 }, + parsing: { start: 0, end: 0 }, + buffering: { start: 0, first: 0, end: 0 }, + } +} + +// Custom hls.js loader using fetch() with cache: 'no-store'. +// XHR-based approaches (xhrSetup, onBeforeSendHeaders, onHeadersReceived) cannot reliably +// prevent Chromium's HTTP disk cache from storing responses — only the Fetch API's +// cache mode is evaluated before the cache storage decision is made. +export class NoCacheLoader { + context: LoaderContext | null = null + stats: LoaderStats = makeStats() + + private controller: AbortController | null = null + + // hls.js instantiates loaders via `new config.loader(config)` — we ignore the config arg + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(_config: HlsConfig) {} + + load(context: LoaderContext, _config: LoaderConfiguration, callbacks: LoaderCallbacks): void { + this.context = context + this.controller = new AbortController() + this.stats = makeStats() + this.stats.loading.start = performance.now() + + const headers: Record = { ...(context.headers ?? {}) } + if (context.rangeEnd) { + headers['Range'] = `bytes=${context.rangeStart ?? 0}-${context.rangeEnd - 1}` + } + + fetch(context.url, { cache: 'no-store', signal: this.controller.signal, headers }) + .then((response) => { + if (!response.ok) { + callbacks.onError( + { code: response.status, text: response.statusText }, + context, + response, + this.stats, + ) + return + } + this.stats.loading.first = performance.now() + this.stats.total = parseInt(response.headers.get('content-length') ?? '0', 10) || 0 + + const dataPromise: Promise = + context.responseType === 'arraybuffer' ? response.arrayBuffer() : response.text() + + return dataPromise.then((data) => { + this.stats.loaded = + typeof data === 'string' ? data.length : (data as ArrayBuffer).byteLength + this.stats.loading.end = performance.now() + callbacks.onSuccess({ url: response.url, data }, this.stats, context, response) + }) + }) + .catch((err: unknown) => { + if (err instanceof Error && err.name === 'AbortError') return + callbacks.onError( + { code: 0, text: err instanceof Error ? err.message : String(err) }, + context, + null, + this.stats, + ) + }) + } + + abort(): void { + this.stats.aborted = true + this.controller?.abort() + } + + destroy(): void { + this.abort() + this.context = null + } +}