mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
+8
-17
@@ -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<string, string[]> = {}
|
||||
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']
|
||||
}
|
||||
callback({ responseHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<VideoPlayerHandle, Props>(
|
||||
({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const { settings } = useSettings()
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
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))
|
||||
|
||||
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
|
||||
|
||||
// Attach media first, then load the source — more reliable in Electron/Chromium
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user