diff --git a/desktop/src/api/proxy/proxy.controller.ts b/desktop/src/api/proxy/proxy.controller.ts index 8638b5c..80dc1aa 100644 --- a/desktop/src/api/proxy/proxy.controller.ts +++ b/desktop/src/api/proxy/proxy.controller.ts @@ -1,6 +1,16 @@ -import {FastifyReply} from 'fastify'; +import {FastifyReply, FastifyRequest} from 'fastify'; import {processM3U8Content, proxyRequest, streamToReadable} from './proxy.service'; -import {ProxyRequest} from '../types'; + +interface ProxyQuerystring { + url: string; + referer?: string; + origin?: string; + userAgent?: string; +} + +type ProxyRequest = FastifyRequest<{ + Querystring: ProxyQuerystring +}>; export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { const { url, referer, origin, userAgent } = req.query; @@ -10,32 +20,23 @@ export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { } try { + const rangeHeader = req.headers.range; + const { response, contentType, isM3U8, contentLength } = await proxyRequest(url, { referer, origin, userAgent - }); + }, rangeHeader); + reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Access-Control-Allow-Headers', 'Content-Type, Range'); reply.header('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges'); - if (contentType) { - reply.header('Content-Type', contentType); - } - - if (contentLength) { - reply.header('Content-Length', contentLength); - } - - if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) { - reply.header('Cache-Control', 'public, max-age=31536000, immutable'); - } - - reply.header('Accept-Ranges', 'bytes'); - if (isM3U8) { + reply.header('Content-Type', 'application/vnd.apple.mpegurl'); + const text = await response.text(); const baseUrl = new URL(response.url); @@ -48,13 +49,67 @@ export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { return reply.send(processedContent); } + const isSubtitle = url.includes('.vtt') || url.includes('.srt') || url.includes('.ass') || + contentType?.includes('text/vtt') || contentType?.includes('text/srt'); + + if (isSubtitle) { + const text = await response.text(); + + let mimeType = 'text/vtt'; + if (url.includes('.srt') || contentType?.includes('srt')) { + mimeType = 'text/plain'; + } else if (url.includes('.ass')) { + mimeType = 'text/plain'; + } + + reply.header('Content-Type', mimeType); + reply.header('Cache-Control', 'public, max-age=3600'); + return reply.send(text); + } + + if (contentType) { + reply.header('Content-Type', contentType); + } + + if (response.status === 206) { + const contentRange = response.headers.get('content-range'); + if (contentRange) { + reply.header('Content-Range', contentRange); + } + reply.code(206); + } + + if (contentLength) { + reply.header('Content-Length', contentLength); + } + + if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) { + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); + } + + reply.header('Accept-Ranges', 'bytes'); + return reply.send(streamToReadable(response.body!)); } catch (err) { req.server.log.error(err); + console.error('=== PROXY ERROR ==='); + console.error('URL:', url); + console.error('Error:', err); + console.error('==================='); if (!reply.sent) { - return reply.code(500).send({ error: "Internal Server Error" }); + return reply.code(500).send({ + error: "Internal Server Error", + details: err instanceof Error ? err.message : String(err) + }); } } +} + +export async function handleProxyOptions(req: FastifyRequest, reply: FastifyReply) { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Range'); + return reply.code(204).send(); } \ No newline at end of file diff --git a/desktop/src/api/proxy/proxy.routes.ts b/desktop/src/api/proxy/proxy.routes.ts index 183daee..c7adb42 100644 --- a/desktop/src/api/proxy/proxy.routes.ts +++ b/desktop/src/api/proxy/proxy.routes.ts @@ -1,8 +1,9 @@ import { FastifyInstance } from 'fastify'; -import { handleProxy } from './proxy.controller'; +import {handleProxy, handleProxyOptions} from './proxy.controller'; async function proxyRoutes(fastify: FastifyInstance) { fastify.get('/proxy', handleProxy); + fastify.options('/proxy', handleProxyOptions); } export default proxyRoutes; \ No newline at end of file diff --git a/desktop/src/api/proxy/proxy.service.ts b/desktop/src/api/proxy/proxy.service.ts index 9cfbb1c..f2d59ed 100644 --- a/desktop/src/api/proxy/proxy.service.ts +++ b/desktop/src/api/proxy/proxy.service.ts @@ -1,4 +1,4 @@ -import { Readable } from 'stream'; +import {Readable} from 'stream'; interface ProxyHeaders { referer?: string; @@ -13,25 +13,24 @@ interface ProxyResponse { contentLength: string | null; } -export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders): Promise { +export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders, rangeHeader?: string): Promise { const headers: Record = { 'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'identity', - 'Connection': 'keep-alive' }; if (referer) headers['Referer'] = referer; if (origin) headers['Origin'] = origin; + if (rangeHeader) headers['Range'] = rangeHeader; let lastError: Error | null = null; const maxRetries = 2; for (let attempt = 0; attempt < maxRetries; attempt++) { try { - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60000); @@ -43,8 +42,7 @@ export async function proxyRequest(url: string, { referer, origin, userAgent }: clearTimeout(timeoutId); - if (!response.ok) { - + if (!response.ok && response.status !== 206) { if (response.status === 404 || response.status === 403) { throw new Error(`Proxy Error: ${response.status} ${response.statusText}`); } @@ -57,9 +55,16 @@ export async function proxyRequest(url: string, { referer, origin, userAgent }: throw new Error(`Proxy Error: ${response.status} ${response.statusText}`); } - const contentType = response.headers.get('content-type'); const contentLength = response.headers.get('content-length'); - const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8'); + const contentType = response.headers.get('content-type'); + const ct = contentType?.toLowerCase(); + + const isM3U8 = + !!ct && ( + ct.includes('mpegurl') || + ct.includes('m3u8') + ) || + url.toLowerCase().includes('.m3u8'); return { response, @@ -83,24 +88,57 @@ export async function proxyRequest(url: string, { referer, origin, userAgent }: } export function processM3U8Content(text: string, baseUrl: URL, { referer, origin, userAgent }: ProxyHeaders): string { - return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => { - line = line.trim(); - let absoluteUrl: string; - + const buildProxy = (url: string) => { try { - absoluteUrl = new URL(line, baseUrl).href; - } catch (e) { - return line; + const params = new URLSearchParams(); + params.set('url', new URL(url, baseUrl).href); + if (referer) params.set('referer', referer); + if (origin) params.set('origin', origin); + if (userAgent) params.set('userAgent', userAgent); + return `/api/proxy?${params.toString()}`; + } catch (error) { + console.error('Error building proxy URL for:', url, error); + return url; + } + }; + + const isMasterPlaylist = text.includes('#EXT-X-STREAM-INF'); + + + let lines = text.split('\n'); + let result = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + result.push(line); + continue; } - const proxyParams = new URLSearchParams(); - proxyParams.set('url', absoluteUrl); - if (referer) proxyParams.set('referer', referer); - if (origin) proxyParams.set('origin', origin); - if (userAgent) proxyParams.set('userAgent', userAgent); + if (trimmed.startsWith('#')) { + if (line.includes('URI=')) { + const processedLine = line.replace(/URI="([^"]+)"/g, (match, uri) => { + return `URI="${buildProxy(uri)}"`; + }); + result.push(processedLine); + } else { + result.push(line); + } + continue; + } - return `/api/proxy?${proxyParams.toString()}`; - }); + try { + const proxiedUrl = buildProxy(trimmed); + result.push(proxiedUrl); + } catch (error) { + console.error('Error processing M3U8 URL line:', trimmed, error); + result.push(line); + } + } + + return result.join('\n'); } export function streamToReadable(webStream: ReadableStream): Readable { @@ -110,7 +148,6 @@ export function streamToReadable(webStream: ReadableStream): Readable { return new Readable({ async read() { try { - const timeoutPromise = new Promise((_, reject) => { readTimeout = setTimeout(() => reject(new Error('Stream read timeout')), 10000); }); diff --git a/docker/src/api/proxy/proxy.controller.ts b/docker/src/api/proxy/proxy.controller.ts index 8638b5c..80dc1aa 100644 --- a/docker/src/api/proxy/proxy.controller.ts +++ b/docker/src/api/proxy/proxy.controller.ts @@ -1,6 +1,16 @@ -import {FastifyReply} from 'fastify'; +import {FastifyReply, FastifyRequest} from 'fastify'; import {processM3U8Content, proxyRequest, streamToReadable} from './proxy.service'; -import {ProxyRequest} from '../types'; + +interface ProxyQuerystring { + url: string; + referer?: string; + origin?: string; + userAgent?: string; +} + +type ProxyRequest = FastifyRequest<{ + Querystring: ProxyQuerystring +}>; export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { const { url, referer, origin, userAgent } = req.query; @@ -10,32 +20,23 @@ export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { } try { + const rangeHeader = req.headers.range; + const { response, contentType, isM3U8, contentLength } = await proxyRequest(url, { referer, origin, userAgent - }); + }, rangeHeader); + reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Access-Control-Allow-Headers', 'Content-Type, Range'); reply.header('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges'); - if (contentType) { - reply.header('Content-Type', contentType); - } - - if (contentLength) { - reply.header('Content-Length', contentLength); - } - - if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) { - reply.header('Cache-Control', 'public, max-age=31536000, immutable'); - } - - reply.header('Accept-Ranges', 'bytes'); - if (isM3U8) { + reply.header('Content-Type', 'application/vnd.apple.mpegurl'); + const text = await response.text(); const baseUrl = new URL(response.url); @@ -48,13 +49,67 @@ export async function handleProxy(req: ProxyRequest, reply: FastifyReply) { return reply.send(processedContent); } + const isSubtitle = url.includes('.vtt') || url.includes('.srt') || url.includes('.ass') || + contentType?.includes('text/vtt') || contentType?.includes('text/srt'); + + if (isSubtitle) { + const text = await response.text(); + + let mimeType = 'text/vtt'; + if (url.includes('.srt') || contentType?.includes('srt')) { + mimeType = 'text/plain'; + } else if (url.includes('.ass')) { + mimeType = 'text/plain'; + } + + reply.header('Content-Type', mimeType); + reply.header('Cache-Control', 'public, max-age=3600'); + return reply.send(text); + } + + if (contentType) { + reply.header('Content-Type', contentType); + } + + if (response.status === 206) { + const contentRange = response.headers.get('content-range'); + if (contentRange) { + reply.header('Content-Range', contentRange); + } + reply.code(206); + } + + if (contentLength) { + reply.header('Content-Length', contentLength); + } + + if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) { + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); + } + + reply.header('Accept-Ranges', 'bytes'); + return reply.send(streamToReadable(response.body!)); } catch (err) { req.server.log.error(err); + console.error('=== PROXY ERROR ==='); + console.error('URL:', url); + console.error('Error:', err); + console.error('==================='); if (!reply.sent) { - return reply.code(500).send({ error: "Internal Server Error" }); + return reply.code(500).send({ + error: "Internal Server Error", + details: err instanceof Error ? err.message : String(err) + }); } } +} + +export async function handleProxyOptions(req: FastifyRequest, reply: FastifyReply) { + reply.header('Access-Control-Allow-Origin', '*'); + reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); + reply.header('Access-Control-Allow-Headers', 'Content-Type, Range'); + return reply.code(204).send(); } \ No newline at end of file diff --git a/docker/src/api/proxy/proxy.routes.ts b/docker/src/api/proxy/proxy.routes.ts index 183daee..c7adb42 100644 --- a/docker/src/api/proxy/proxy.routes.ts +++ b/docker/src/api/proxy/proxy.routes.ts @@ -1,8 +1,9 @@ import { FastifyInstance } from 'fastify'; -import { handleProxy } from './proxy.controller'; +import {handleProxy, handleProxyOptions} from './proxy.controller'; async function proxyRoutes(fastify: FastifyInstance) { fastify.get('/proxy', handleProxy); + fastify.options('/proxy', handleProxyOptions); } export default proxyRoutes; \ No newline at end of file diff --git a/docker/src/api/proxy/proxy.service.ts b/docker/src/api/proxy/proxy.service.ts index 9cfbb1c..f2d59ed 100644 --- a/docker/src/api/proxy/proxy.service.ts +++ b/docker/src/api/proxy/proxy.service.ts @@ -1,4 +1,4 @@ -import { Readable } from 'stream'; +import {Readable} from 'stream'; interface ProxyHeaders { referer?: string; @@ -13,25 +13,24 @@ interface ProxyResponse { contentLength: string | null; } -export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders): Promise { +export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders, rangeHeader?: string): Promise { const headers: Record = { 'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 'Accept': '*/*', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'identity', - 'Connection': 'keep-alive' }; if (referer) headers['Referer'] = referer; if (origin) headers['Origin'] = origin; + if (rangeHeader) headers['Range'] = rangeHeader; let lastError: Error | null = null; const maxRetries = 2; for (let attempt = 0; attempt < maxRetries; attempt++) { try { - const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60000); @@ -43,8 +42,7 @@ export async function proxyRequest(url: string, { referer, origin, userAgent }: clearTimeout(timeoutId); - if (!response.ok) { - + if (!response.ok && response.status !== 206) { if (response.status === 404 || response.status === 403) { throw new Error(`Proxy Error: ${response.status} ${response.statusText}`); } @@ -57,9 +55,16 @@ export async function proxyRequest(url: string, { referer, origin, userAgent }: throw new Error(`Proxy Error: ${response.status} ${response.statusText}`); } - const contentType = response.headers.get('content-type'); const contentLength = response.headers.get('content-length'); - const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8'); + const contentType = response.headers.get('content-type'); + const ct = contentType?.toLowerCase(); + + const isM3U8 = + !!ct && ( + ct.includes('mpegurl') || + ct.includes('m3u8') + ) || + url.toLowerCase().includes('.m3u8'); return { response, @@ -83,24 +88,57 @@ export async function proxyRequest(url: string, { referer, origin, userAgent }: } export function processM3U8Content(text: string, baseUrl: URL, { referer, origin, userAgent }: ProxyHeaders): string { - return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => { - line = line.trim(); - let absoluteUrl: string; - + const buildProxy = (url: string) => { try { - absoluteUrl = new URL(line, baseUrl).href; - } catch (e) { - return line; + const params = new URLSearchParams(); + params.set('url', new URL(url, baseUrl).href); + if (referer) params.set('referer', referer); + if (origin) params.set('origin', origin); + if (userAgent) params.set('userAgent', userAgent); + return `/api/proxy?${params.toString()}`; + } catch (error) { + console.error('Error building proxy URL for:', url, error); + return url; + } + }; + + const isMasterPlaylist = text.includes('#EXT-X-STREAM-INF'); + + + let lines = text.split('\n'); + let result = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + result.push(line); + continue; } - const proxyParams = new URLSearchParams(); - proxyParams.set('url', absoluteUrl); - if (referer) proxyParams.set('referer', referer); - if (origin) proxyParams.set('origin', origin); - if (userAgent) proxyParams.set('userAgent', userAgent); + if (trimmed.startsWith('#')) { + if (line.includes('URI=')) { + const processedLine = line.replace(/URI="([^"]+)"/g, (match, uri) => { + return `URI="${buildProxy(uri)}"`; + }); + result.push(processedLine); + } else { + result.push(line); + } + continue; + } - return `/api/proxy?${proxyParams.toString()}`; - }); + try { + const proxiedUrl = buildProxy(trimmed); + result.push(proxiedUrl); + } catch (error) { + console.error('Error processing M3U8 URL line:', trimmed, error); + result.push(line); + } + } + + return result.join('\n'); } export function streamToReadable(webStream: ReadableStream): Readable { @@ -110,7 +148,6 @@ export function streamToReadable(webStream: ReadableStream): Readable { return new Readable({ async read() { try { - const timeoutPromise = new Promise((_, reject) => { readTimeout = setTimeout(() => reject(new Error('Stream read timeout')), 10000); });