diff --git a/apps/electron-backend/src/app/workers/epg-parser.worker.ts b/apps/electron-backend/src/app/workers/epg-parser.worker.ts index 31f647506..1eb71fc1a 100644 --- a/apps/electron-backend/src/app/workers/epg-parser.worker.ts +++ b/apps/electron-backend/src/app/workers/epg-parser.worker.ts @@ -6,10 +6,13 @@ import { } from '@iptvnator/shared/interfaces'; import { Readable } from 'stream'; import { parentPort, workerData } from 'worker_threads'; -import { createGunzip } from 'zlib'; import { EpgDatabase, EpgDatabaseClearOperation } from './epg-database'; +import { createDecodedEpgStream } from './epg-stream-decoder'; import { StreamingEpgParser } from './epg-streaming-parser'; -import { shouldGunzipEpgResponse } from './epg-response-utils'; +import { + getEpgResponseContentEncoding, + shouldGunzipEpgResponse, +} from './epg-response-utils'; import { isPrivateNetworkUrlAccessAllowed, UnsafeUrlError, @@ -130,6 +133,9 @@ async function fetchAndParseEpgStreaming( headers: response.headers, url: responseUrl, }); + const contentEncoding = getEpgResponseContentEncoding( + response.headers + ); if (responseUrl && responseUrl !== url) { console.log( @@ -142,6 +148,12 @@ async function fetchAndParseEpgStreaming( loggerLabel, `EPG response detected as gzipped: ${isGzipped}` ); + if (contentEncoding) { + console.log( + loggerLabel, + `EPG response content-encoding: ${contentEncoding}` + ); + } if (response.status < 200 || response.status >= 300) { throw new Error(`HTTP error! status: ${response.status}`); @@ -175,18 +187,11 @@ async function fetchAndParseEpgStreaming( ); return new Promise((resolve, reject) => { - let dataStream: Readable = response.data; - - if (isGzipped) { - const gunzip = createGunzip(); - dataStream = response.data.pipe(gunzip); - - gunzip.on('error', (err) => { - console.error(loggerLabel, 'Gunzip error:', err); - epgDb.close(); - reject(err); - }); - } + const dataStream = createDecodedEpgStream( + response.data, + response.headers, + isGzipped + ); dataStream.on('data', (chunk: Buffer) => { try { diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts index 314c10192..b8b3b879a 100644 --- a/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts +++ b/apps/electron-backend/src/app/workers/epg-response-utils.spec.ts @@ -1,4 +1,7 @@ -import { shouldGunzipEpgResponse } from './epg-response-utils'; +import { + getEpgResponseContentEncoding, + shouldGunzipEpgResponse, +} from './epg-response-utils'; describe('shouldGunzipEpgResponse', () => { it('returns true for original .gz URLs', () => { @@ -71,3 +74,37 @@ describe('shouldGunzipEpgResponse', () => { ).toBe(false); }); }); + +describe('getEpgResponseContentEncoding', () => { + it('detects Brotli transfer encoding from response headers', () => { + expect( + getEpgResponseContentEncoding({ + 'content-encoding': 'br', + }) + ).toBe('br'); + }); + + it('detects gzip transfer encoding from response headers', () => { + expect( + getEpgResponseContentEncoding( + new Headers([['content-encoding', 'gzip']]) + ) + ).toBe('gzip'); + }); + + it('detects deflate transfer encoding from response headers', () => { + expect( + getEpgResponseContentEncoding({ + 'Content-Encoding': 'deflate', + }) + ).toBe('deflate'); + }); + + it('ignores unsupported transfer encodings', () => { + expect( + getEpgResponseContentEncoding({ + 'content-encoding': 'zstd', + }) + ).toBe(null); + }); +}); diff --git a/apps/electron-backend/src/app/workers/epg-response-utils.ts b/apps/electron-backend/src/app/workers/epg-response-utils.ts index 363dfd00a..fb12a833b 100644 --- a/apps/electron-backend/src/app/workers/epg-response-utils.ts +++ b/apps/electron-backend/src/app/workers/epg-response-utils.ts @@ -1,9 +1,11 @@ -type HeaderReader = +export type HeaderReader = | Record | { get(name: string): unknown; }; +export type EpgResponseContentEncoding = 'br' | 'gzip' | 'deflate'; + function getHeaderValue(headers: HeaderReader, name: string): string | null { const value = 'get' in headers && typeof headers.get === 'function' @@ -27,6 +29,20 @@ function hasGzipPath(url: string | null | undefined): boolean { } } +export function getEpgResponseContentEncoding( + headers: HeaderReader +): EpgResponseContentEncoding | null { + const contentEncoding = getHeaderValue(headers, 'content-encoding') + ?.toLowerCase() + .split(',') + .map((encoding) => encoding.trim()) + .find((encoding): encoding is EpgResponseContentEncoding => + ['br', 'gzip', 'deflate'].includes(encoding) + ); + + return contentEncoding ?? null; +} + /** * Detect whether an EPG response should be gunzipped. * Some providers redirect plain-looking URLs to a `.gz` payload. diff --git a/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts b/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts new file mode 100644 index 000000000..b9c2743d8 --- /dev/null +++ b/apps/electron-backend/src/app/workers/epg-stream-decoder.spec.ts @@ -0,0 +1,85 @@ +import { Readable } from 'stream'; +import { brotliCompressSync, gzipSync } from 'zlib'; +import { createDecodedEpgStream } from './epg-stream-decoder'; +import { StreamingEpgParser } from './epg-streaming-parser'; + +const xmltvFixture = + '' + + 'Test Channel'; + +async function collectDecodedText(stream: Readable): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks).toString('utf-8'); +} + +function parseChannelCount(xml: string): number { + const parser = new StreamingEpgParser( + () => undefined, + () => undefined, + () => undefined + ); + + parser.write(xml); + return parser.finish().totalChannels; +} + +describe('createDecodedEpgStream', () => { + it('decodes Brotli transfer-encoded XML before SAX parsing', async () => { + const decodedText = await collectDecodedText( + createDecodedEpgStream( + Readable.from([brotliCompressSync(xmltvFixture)]), + { 'content-encoding': 'br' }, + false + ) + ); + + expect(decodedText.startsWith(' { + const decodedText = await collectDecodedText( + createDecodedEpgStream( + Readable.from([gzipSync(xmltvFixture)]), + { 'content-encoding': 'gzip' }, + false + ) + ); + + expect(decodedText.startsWith(' { + const decodedText = await collectDecodedText( + createDecodedEpgStream( + Readable.from([Buffer.from(xmltvFixture)]), + {}, + false + ) + ); + + expect(decodedText.startsWith(' { + const transferEncodedPayload = gzipSync(gzipSync(xmltvFixture)); + + const decodedText = await collectDecodedText( + createDecodedEpgStream( + Readable.from([transferEncodedPayload]), + { 'content-encoding': 'gzip' }, + true + ) + ); + + expect(decodedText.startsWith(' +): Transform | null { + switch (contentEncoding) { + case 'br': + return createBrotliDecompress(); + case 'gzip': + return createGunzip(); + case 'deflate': + return createInflate(); + default: + return null; + } +} + +export function createDecodedEpgStream( + source: Readable, + headers: HeaderReader, + shouldGunzipPayload: boolean +): Readable { + const transforms: Transform[] = []; + const contentEncoding = getEpgResponseContentEncoding(headers); + const contentEncodingDecoder = + createContentEncodingDecoder(contentEncoding); + + if (contentEncodingDecoder) { + transforms.push(contentEncodingDecoder); + } + + if (shouldGunzipPayload) { + transforms.push(createGunzip()); + } + + if (transforms.length === 0) { + return source; + } + + const decodedStream = transforms.reduce( + (stream, transform) => stream.pipe(transform), + source + ); + + for (const stream of [source, ...transforms]) { + if (stream !== decodedStream) { + stream.on('error', (error) => decodedStream.destroy(error)); + } + } + + return decodedStream; +}