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:
+11
-20
@@ -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 })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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