SaleemFiverr commited on
Commit
f40edce
·
1 Parent(s): 1a0f025

Fix VOD download size, improve thumbnail fetching, and cleanup console errors

Browse files
.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
- try {
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
- return c.body(svg, 200, {
72
- 'Content-Type': 'image/svg+xml',
73
- 'Cache-Control': 'public, max-age=86400',
74
- });
75
- }
76
  });
77
 
78
- app.get('/favicon.svg', async (c) => {
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.png', async (c) => {
90
- try {
91
- const iconBuffer = await readFile(faviconUrl);
92
- return c.body(iconBuffer, 200, {
93
- 'Content-Type': 'image/png',
94
- 'Cache-Control': 'public, max-age=86400',
95
- });
96
- } catch (error) {
97
- return c.text('Not Found', 404);
98
- }
99
  });
100
 
101
  app.get('/untwitch-icon.png', async (c) => {
102
- try {
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
- return c.body(svg, 200, {
114
- 'Content-Type': 'image/svg+xml',
115
- 'Cache-Control': 'public, max-age=86400',
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
- await convertM3u8ToMp4(videoUrl, outputPath);
51
- log("Completed ffmpeg conversion", { outputPath });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "-protocol_whitelist",
189
- "file,http,https,tcp,tls,crypto",
190
- "-headers",
191
- headers,
192
- "-i",
193
- inputUrl,
194
- "-c",
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
- stderr += chunk.toString();
 
 
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", reject);
 
 
 
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\}|-\{width\}|\{width\}|%7Bwidth%7D/gi, "640")
400
- .replace(/%\{height\}|-\{height\}|\{height\}|%7Bheight%7D/gi, "360");
401
-
402
- // If it's a VOD thumbnail, ensure it points to a large version
403
- if (normalizedUrl.includes("cf_vods") && !normalizedUrl.includes("640x360")) {
404
- normalizedUrl = normalizedUrl.replace(/thumb\/[^\s]+/, "thumb/custom-640x360.jpg");
 
 
 
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>