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.
This commit is contained in:
Andre
2026-05-15 11:31:32 +02:00
parent c7bc1a23c0
commit 123809fb9a
3 changed files with 109 additions and 21 deletions
+11 -20
View File
@@ -5,7 +5,7 @@ import { HelixClient } from './twitch/helixClient'
import { PlaybackService } from './playback/playbackService' import { PlaybackService } from './playback/playbackService'
import { registerIpcHandlers } from './ipc/handlers' import { registerIpcHandlers } from './ipc/handlers'
import { startGamepadReader } from './input/gamepadReader' import { startGamepadReader } from './input/gamepadReader'
import { getHlsCacheEnabled } from './prefs/hlsCachePref'
import log from 'electron-log/main' import log from 'electron-log/main'
log.initialize() log.initialize()
@@ -63,23 +63,22 @@ function createWindow(): BrowserWindow {
return win 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. * CORS-Bypass für Twitch CDN und HLS-Streams.
* hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet * hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet
* Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests * Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests
* und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert. * 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 { function setupTwitchCors(): void {
const isTwitchCdn = (url: string): boolean => const isTwitchCdn = (url: string): boolean =>
url.includes('twitchsvc.net') || url.includes('twitchsvc.net') ||
url.includes('cloudfront.net') || url.includes('cloudfront.net') ||
url.includes('ttvnw.net') ||
url.includes('twitch.tv') || url.includes('twitch.tv') ||
url.includes('twitch.amazon.com') url.includes('twitch.amazon.com')
@@ -92,23 +91,15 @@ function setupTwitchCors(): void {
}) })
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const isCdn = isTwitchCdn(details.url) if (!isTwitchCdn(details.url)) { callback({}); return }
const applyNoStore = isHlsStreamContent(details.url) && !getHlsCacheEnabled()
if (!isCdn && !applyNoStore) { callback({}); return }
const headers: Record<string, string[]> = {} const headers: Record<string, string[]> = {}
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) { for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v] headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
} }
if (isCdn) { headers['access-control-allow-origin'] = ['*']
headers['access-control-allow-origin'] = ['*'] headers['access-control-allow-headers'] = ['*']
headers['access-control-allow-headers'] = ['*'] headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
}
if (applyNoStore) {
headers['cache-control'] = ['no-store']
}
callback({ responseHeaders: headers }) callback({ responseHeaders: headers })
}) })
} }
+12 -1
View File
@@ -1,6 +1,8 @@
import Hls from 'hls.js' import Hls from 'hls.js'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback' import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback'
import { useSettings } from '../context/SettingsContext'
import { NoCacheLoader } from '../lib/hlsNoCacheLoader'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
export interface VideoPlayerHandle { export interface VideoPlayerHandle {
@@ -30,6 +32,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => { ({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null) const hlsRef = useRef<Hls | null>(null)
const { settings } = useSettings()
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
seek(delta: number) { seek(delta: number) {
@@ -85,7 +88,15 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
log.info('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80)) log.info('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80))
if (Hls.isSupported()) { if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: false }) const hlsConfig: Partial<Hls['config']> = { 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 hlsRef.current = hls
// Attach media first, then load the source — more reliable in Electron/Chromium // Attach media first, then load the source — more reliable in Electron/Chromium
+86
View File
@@ -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<LoaderContext>): void {
this.context = context
this.controller = new AbortController()
this.stats = makeStats()
this.stats.loading.start = performance.now()
const headers: Record<string, string> = { ...(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<string | ArrayBuffer> =
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
}
}