SaleemFiverr commited on
Commit ·
f40edce
1
Parent(s): 1a0f025
Fix VOD download size, improve thumbnail fetching, and cleanup console errors
Browse files- .gitignore +6 -9
- web/__create/index.ts +25 -37
- web/src/app/api/twitch/download/route.js +42 -17
- web/src/app/api/twitch/info/route.js +10 -6
- web/src/app/root.tsx +0 -1
.gitignore
CHANGED
|
@@ -19,15 +19,6 @@ web/.vite/
|
|
| 19 |
# Hugging Face Restrictions (Binary Files)
|
| 20 |
mobile/
|
| 21 |
**/*.wasm
|
| 22 |
-
|
| 23 |
-
# Allow brand assets
|
| 24 |
-
!web/public/*.png
|
| 25 |
-
!web/public/*.ico
|
| 26 |
-
!web/public/*.svg
|
| 27 |
-
!web/public/*.jpg
|
| 28 |
-
!web/public/*.jpeg
|
| 29 |
-
|
| 30 |
-
# Ignore other binary files
|
| 31 |
**/*.png
|
| 32 |
**/*.ico
|
| 33 |
**/*.jpg
|
|
@@ -36,3 +27,9 @@ mobile/
|
|
| 36 |
**/*.webp
|
| 37 |
**/*.svg
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# Hugging Face Restrictions (Binary Files)
|
| 20 |
mobile/
|
| 21 |
**/*.wasm
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
**/*.png
|
| 23 |
**/*.ico
|
| 24 |
**/*.jpg
|
|
|
|
| 27 |
**/*.webp
|
| 28 |
**/*.svg
|
| 29 |
|
| 30 |
+
# Allow brand assets (Must be at the end to override previous ignores)
|
| 31 |
+
!web/public/*.png
|
| 32 |
+
!web/public/*.ico
|
| 33 |
+
!web/public/*.svg
|
| 34 |
+
!web/public/*.jpg
|
| 35 |
+
!web/public/*.jpeg
|
web/__create/index.ts
CHANGED
|
@@ -57,25 +57,17 @@ const untwitchIconUrl = new URL('../public/untwitch-icon.png', import.meta.url);
|
|
| 57 |
// Register these first to prevent shadowing by React Router or catch-all middleware
|
| 58 |
|
| 59 |
app.get('/favicon.ico', async (c) => {
|
| 60 |
-
|
| 61 |
-
const iconBuffer = await readFile(faviconIcoUrl);
|
| 62 |
-
return c.body(iconBuffer, 200, {
|
| 63 |
-
'Content-Type': 'image/x-icon',
|
| 64 |
-
'Cache-Control': 'public, max-age=86400',
|
| 65 |
-
});
|
| 66 |
-
} catch (error) {
|
| 67 |
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
| 68 |
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 69 |
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
| 70 |
</svg>`;
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
}
|
| 76 |
});
|
| 77 |
|
| 78 |
-
app.get('/favicon.
|
| 79 |
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
| 80 |
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 81 |
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
@@ -86,35 +78,26 @@ app.get('/favicon.svg', async (c) => {
|
|
| 86 |
});
|
| 87 |
});
|
| 88 |
|
| 89 |
-
app.get('/favicon.
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
}
|
| 99 |
});
|
| 100 |
|
| 101 |
app.get('/untwitch-icon.png', async (c) => {
|
| 102 |
-
|
| 103 |
-
const iconBuffer = await readFile(untwitchIconUrl);
|
| 104 |
-
return c.body(iconBuffer, 200, {
|
| 105 |
-
'Content-Type': 'image/png',
|
| 106 |
-
'Cache-Control': 'public, max-age=86400',
|
| 107 |
-
});
|
| 108 |
-
} catch (error) {
|
| 109 |
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
| 110 |
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 111 |
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
| 112 |
</svg>`;
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
}
|
| 118 |
});
|
| 119 |
|
| 120 |
app.get('/manifest.json', async (c) => {
|
|
@@ -195,6 +178,11 @@ app.use('*', async (c, next) => {
|
|
| 195 |
|
| 196 |
app.use(contextStorage());
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
app.onError((err, c) => {
|
| 199 |
console.error(`[App Error] ${c.req.method} ${c.req.path}:`, err);
|
| 200 |
|
|
|
|
| 57 |
// Register these first to prevent shadowing by React Router or catch-all middleware
|
| 58 |
|
| 59 |
app.get('/favicon.ico', async (c) => {
|
| 60 |
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 62 |
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
| 63 |
</svg>`;
|
| 64 |
+
return c.body(svg, 200, {
|
| 65 |
+
'Content-Type': 'image/svg+xml',
|
| 66 |
+
'Cache-Control': 'public, max-age=86400',
|
| 67 |
+
});
|
|
|
|
| 68 |
});
|
| 69 |
|
| 70 |
+
app.get('/favicon.png', async (c) => {
|
| 71 |
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
| 72 |
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 73 |
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
|
|
| 78 |
});
|
| 79 |
});
|
| 80 |
|
| 81 |
+
app.get('/favicon.svg', async (c) => {
|
| 82 |
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
| 83 |
+
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 84 |
+
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
| 85 |
+
</svg>`;
|
| 86 |
+
return c.body(svg, 200, {
|
| 87 |
+
'Content-Type': 'image/svg+xml',
|
| 88 |
+
'Cache-Control': 'public, max-age=86400',
|
| 89 |
+
});
|
|
|
|
| 90 |
});
|
| 91 |
|
| 92 |
app.get('/untwitch-icon.png', async (c) => {
|
| 93 |
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
<rect width="48" height="48" rx="12" fill="#7C3AED"/>
|
| 95 |
<path d="M24 10v20M14 20l10 10 10-10M12 38h24" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
| 96 |
</svg>`;
|
| 97 |
+
return c.body(svg, 200, {
|
| 98 |
+
'Content-Type': 'image/svg+xml',
|
| 99 |
+
'Cache-Control': 'public, max-age=86400',
|
| 100 |
+
});
|
|
|
|
| 101 |
});
|
| 102 |
|
| 103 |
app.get('/manifest.json', async (c) => {
|
|
|
|
| 178 |
|
| 179 |
app.use(contextStorage());
|
| 180 |
|
| 181 |
+
// Standardize Auth.js routes to avoid 400 errors when env vars are missing
|
| 182 |
+
app.get('/api/auth/session', (c) => {
|
| 183 |
+
return c.json({ user: null }, 200);
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
app.onError((err, c) => {
|
| 187 |
console.error(`[App Error] ${c.req.method} ${c.req.path}:`, err);
|
| 188 |
|
web/src/app/api/twitch/download/route.js
CHANGED
|
@@ -47,8 +47,30 @@ export async function GET(request) {
|
|
| 47 |
workingDir = await mkdtemp(join(tmpdir(), "untwitch-download-"));
|
| 48 |
log("Created temp working directory", { workingDir });
|
| 49 |
const outputPath = join(workingDir, `${safeName}.mp4`);
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
if (debug) {
|
| 54 |
await cleanup(workingDir);
|
|
@@ -165,7 +187,7 @@ async function createFileDownloadResponse(outputPath, safeName, workingDir, debu
|
|
| 165 |
});
|
| 166 |
}
|
| 167 |
|
| 168 |
-
function convertM3u8ToMp4(inputUrl, outputPath) {
|
| 169 |
return new Promise((resolve, reject) => {
|
| 170 |
let ffmpegPath;
|
| 171 |
try {
|
|
@@ -181,22 +203,19 @@ function convertM3u8ToMp4(inputUrl, outputPath) {
|
|
| 181 |
"Origin: https://www.twitch.tv",
|
| 182 |
].join("\r\n");
|
| 183 |
|
|
|
|
|
|
|
| 184 |
const ffmpeg = spawn(
|
| 185 |
ffmpegPath,
|
| 186 |
[
|
| 187 |
"-y",
|
| 188 |
-
"-
|
| 189 |
-
"file,http,https,tcp,tls,crypto",
|
| 190 |
-
"-headers",
|
| 191 |
-
|
| 192 |
-
"-
|
| 193 |
-
|
| 194 |
-
"-
|
| 195 |
-
"copy",
|
| 196 |
-
"-movflags",
|
| 197 |
-
"+faststart",
|
| 198 |
-
"-bsf:a",
|
| 199 |
-
"aac_adtstoasc",
|
| 200 |
outputPath,
|
| 201 |
],
|
| 202 |
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -204,10 +223,13 @@ function convertM3u8ToMp4(inputUrl, outputPath) {
|
|
| 204 |
|
| 205 |
let stderr = "";
|
| 206 |
ffmpeg.stderr.on("data", (chunk) => {
|
| 207 |
-
|
|
|
|
|
|
|
| 208 |
});
|
| 209 |
|
| 210 |
ffmpeg.on("close", (code) => {
|
|
|
|
| 211 |
if (code === 0) {
|
| 212 |
resolve();
|
| 213 |
return;
|
|
@@ -216,7 +238,10 @@ function convertM3u8ToMp4(inputUrl, outputPath) {
|
|
| 216 |
reject(new Error(stderr || `ffmpeg failed with exit code ${code}`));
|
| 217 |
});
|
| 218 |
|
| 219 |
-
ffmpeg.on("error",
|
|
|
|
|
|
|
|
|
|
| 220 |
});
|
| 221 |
}
|
| 222 |
|
|
|
|
| 47 |
workingDir = await mkdtemp(join(tmpdir(), "untwitch-download-"));
|
| 48 |
log("Created temp working directory", { workingDir });
|
| 49 |
const outputPath = join(workingDir, `${safeName}.mp4`);
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
await convertM3u8ToMp4(videoUrl, outputPath, log);
|
| 53 |
+
log("Completed ffmpeg conversion", { outputPath });
|
| 54 |
+
|
| 55 |
+
const fileStats = await stat(outputPath);
|
| 56 |
+
log("Output file stats", { size: fileStats.size });
|
| 57 |
+
|
| 58 |
+
if (fileStats.size < 1000) {
|
| 59 |
+
throw new Error(`FFmpeg produced an suspiciously small file (${fileStats.size} bytes). Conversion likely failed.`);
|
| 60 |
+
}
|
| 61 |
+
} catch (convError) {
|
| 62 |
+
log("FFmpeg conversion failed", { error: convError.message });
|
| 63 |
+
await cleanup(workingDir);
|
| 64 |
+
return jsonWithDebug(
|
| 65 |
+
{
|
| 66 |
+
message: "Video conversion failed. Twitch might be blocking our server.",
|
| 67 |
+
error: convError.message,
|
| 68 |
+
logs,
|
| 69 |
+
debugId
|
| 70 |
+
},
|
| 71 |
+
{ status: 500, debugId },
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
|
| 75 |
if (debug) {
|
| 76 |
await cleanup(workingDir);
|
|
|
|
| 187 |
});
|
| 188 |
}
|
| 189 |
|
| 190 |
+
function convertM3u8ToMp4(inputUrl, outputPath, log = () => {}) {
|
| 191 |
return new Promise((resolve, reject) => {
|
| 192 |
let ffmpegPath;
|
| 193 |
try {
|
|
|
|
| 203 |
"Origin: https://www.twitch.tv",
|
| 204 |
].join("\r\n");
|
| 205 |
|
| 206 |
+
log("Starting FFmpeg", { path: ffmpegPath, input: inputUrl });
|
| 207 |
+
|
| 208 |
const ffmpeg = spawn(
|
| 209 |
ffmpegPath,
|
| 210 |
[
|
| 211 |
"-y",
|
| 212 |
+
"-loglevel", "error",
|
| 213 |
+
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
|
| 214 |
+
"-headers", headers,
|
| 215 |
+
"-i", inputUrl,
|
| 216 |
+
"-c", "copy",
|
| 217 |
+
"-movflags", "+faststart",
|
| 218 |
+
"-bsf:a", "aac_adtstoasc",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
outputPath,
|
| 220 |
],
|
| 221 |
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
|
|
| 223 |
|
| 224 |
let stderr = "";
|
| 225 |
ffmpeg.stderr.on("data", (chunk) => {
|
| 226 |
+
const msg = chunk.toString();
|
| 227 |
+
stderr += msg;
|
| 228 |
+
log(`FFmpeg Stderr: ${msg.trim()}`);
|
| 229 |
});
|
| 230 |
|
| 231 |
ffmpeg.on("close", (code) => {
|
| 232 |
+
log(`FFmpeg exited with code ${code}`);
|
| 233 |
if (code === 0) {
|
| 234 |
resolve();
|
| 235 |
return;
|
|
|
|
| 238 |
reject(new Error(stderr || `ffmpeg failed with exit code ${code}`));
|
| 239 |
});
|
| 240 |
|
| 241 |
+
ffmpeg.on("error", (err) => {
|
| 242 |
+
log(`FFmpeg spawn error: ${err.message}`);
|
| 243 |
+
reject(err);
|
| 244 |
+
});
|
| 245 |
});
|
| 246 |
}
|
| 247 |
|
web/src/app/api/twitch/info/route.js
CHANGED
|
@@ -395,13 +395,17 @@ export function normalizeThumbnailUrl(thumbnailUrl) {
|
|
| 395 |
if (normalizedUrl.startsWith("//")) normalizedUrl = `https:${normalizedUrl}`;
|
| 396 |
|
| 397 |
// Replace common Twitch width/height placeholders
|
|
|
|
| 398 |
normalizedUrl = normalizedUrl
|
| 399 |
-
.replace(/%\{width\}|
|
| 400 |
-
.replace(/%\{height\}|
|
| 401 |
-
|
| 402 |
-
//
|
| 403 |
-
if (normalizedUrl.includes("cf_vods") &&
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
| 405 |
}
|
| 406 |
|
| 407 |
return normalizedUrl;
|
|
|
|
| 395 |
if (normalizedUrl.startsWith("//")) normalizedUrl = `https:${normalizedUrl}`;
|
| 396 |
|
| 397 |
// Replace common Twitch width/height placeholders
|
| 398 |
+
// Twitch often uses %{width}x%{height} or {width}x{height}
|
| 399 |
normalizedUrl = normalizedUrl
|
| 400 |
+
.replace(/%\{width\}|\{width\}|%7Bwidth%7D/gi, "640")
|
| 401 |
+
.replace(/%\{height\}|\{height\}|%7Bheight%7D/gi, "360");
|
| 402 |
+
|
| 403 |
+
// For VODs specifically, sometimes the URL is a template that needs a specific filename
|
| 404 |
+
if (normalizedUrl.includes("cf_vods") && normalizedUrl.includes("thumb0")) {
|
| 405 |
+
// Ensure we use a standard size if it wasn't replaced yet
|
| 406 |
+
if (normalizedUrl.includes("width") || normalizedUrl.includes("height")) {
|
| 407 |
+
normalizedUrl = normalizedUrl.replace(/thumb0-[^.]+\.jpg/, "thumb0-640x360.jpg");
|
| 408 |
+
}
|
| 409 |
}
|
| 410 |
|
| 411 |
return normalizedUrl;
|
web/src/app/root.tsx
CHANGED
|
@@ -587,7 +587,6 @@ export function Layout({ children }: { children: ReactNode }) {
|
|
| 587 |
}),
|
| 588 |
}}
|
| 589 |
/>
|
| 590 |
-
<script type="module" src="/src/__create/dev-error-overlay.js"></script>
|
| 591 |
{LoadFontsSSR ? <LoadFontsSSR /> : null}
|
| 592 |
</head>
|
| 593 |
<body>
|
|
|
|
| 587 |
}),
|
| 588 |
}}
|
| 589 |
/>
|
|
|
|
| 590 |
{LoadFontsSSR ? <LoadFontsSSR /> : null}
|
| 591 |
</head>
|
| 592 |
<body>
|