A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

fix: resolve video upload 401 error by using correct PDS audience

jack 2e27c7b8 c56d4c83

+17 -9
+17 -9
src/index.ts
··· 262 262 } 263 263 264 264 async function uploadVideoToBluesky(agent: BskyAgent, buffer: Buffer, filename: string): Promise<BlobRef> { 265 - console.log(`[VIDEO] ๐ŸŸข Starting upload process for ${filename} (${(buffer.length / 1024 / 1024).toFixed(2)} MB)`); 265 + const sanitizedFilename = filename.split('?')[0] || 'video.mp4'; 266 + console.log(`[VIDEO] ๐ŸŸข Starting upload process for ${sanitizedFilename} (${(buffer.length / 1024 / 1024).toFixed(2)} MB)`); 266 267 267 268 try { 268 269 // 1. Get Service Auth 269 - console.log(`[VIDEO] ๐Ÿ”‘ Requesting service auth token for video.bsky.app...`); 270 + // The audience (aud) must be the user's PDS DID (did:web:HOST) 271 + const pdsUrl = new URL(agent.service as any); 272 + const pdsHost = pdsUrl.host; 273 + console.log(`[VIDEO] ๐ŸŒ PDS Host detected: ${pdsHost}`); 274 + console.log(`[VIDEO] ๐Ÿ”‘ Requesting service auth token for audience: did:web:${pdsHost}...`); 275 + 270 276 const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth({ 271 - aud: `did:web:video.bsky.app`, 277 + aud: `did:web:${pdsHost}`, 272 278 lxm: "com.atproto.repo.uploadBlob", 273 279 exp: Math.floor(Date.now() / 1000) + 60 * 30, 274 280 }); ··· 279 285 // 2. Upload to Video Service 280 286 const uploadUrl = new URL("https://video.bsky.app/xrpc/app.bsky.video.uploadVideo"); 281 287 uploadUrl.searchParams.append("did", agent.session!.did!); 282 - uploadUrl.searchParams.append("name", filename); 288 + uploadUrl.searchParams.append("name", sanitizedFilename); 283 289 284 290 console.log(`[VIDEO] ๐Ÿ“ค Uploading to ${uploadUrl.href}...`); 285 291 const uploadResponse = await fetch(uploadUrl, { ··· 293 299 294 300 if (!uploadResponse.ok) { 295 301 const errText = await uploadResponse.text(); 302 + console.error(`[VIDEO] โŒ Server responded with ${uploadResponse.status}: ${errText}`); 296 303 throw new Error(`Video upload failed: ${uploadResponse.status} ${errText}`); 297 304 } 298 305 299 306 const jobStatus = (await uploadResponse.json()) as any; 300 - console.log(`[VIDEO] ๐Ÿ“ฆ Upload accepted. Job ID: ${jobStatus.jobId}, Initial State: ${jobStatus.state}`); 307 + console.log(`[VIDEO] ๐Ÿ“ฆ Upload accepted. Job ID: ${jobStatus.jobId}, State: ${jobStatus.state}`); 301 308 302 309 let blob: BlobRef | undefined = jobStatus.blob; 303 310 304 311 // 3. Poll for processing status 305 312 if (!blob) { 306 - console.log(`[VIDEO] โณ Polling for processing completion...`); 313 + console.log(`[VIDEO] โณ Polling for processing completion (this can take a minute)...`); 307 314 let attempts = 0; 308 315 while (!blob) { 309 316 attempts++; 310 317 const statusUrl = new URL("https://video.bsky.app/xrpc/app.bsky.video.getJobStatus"); 311 318 statusUrl.searchParams.append("jobId", jobStatus.jobId); 312 319 320 + // Using a plain fetch for the status check as it doesn't always need auth 313 321 const statusResponse = await fetch(statusUrl); 314 322 if (!statusResponse.ok) { 315 323 console.warn(`[VIDEO] โš ๏ธ Job status fetch failed (${statusResponse.status}), retrying...`); 316 - await new Promise((resolve) => setTimeout(resolve, 3000)); 324 + await new Promise((resolve) => setTimeout(resolve, 5000)); 317 325 continue; 318 326 } 319 327 ··· 330 338 throw new Error(`Video processing failed: ${statusData.jobStatus.error || 'Unknown error'}`); 331 339 } else { 332 340 // Wait before next poll 333 - await new Promise((resolve) => setTimeout(resolve, 3000)); 341 + await new Promise((resolve) => setTimeout(resolve, 5000)); 334 342 } 335 343 336 - if (attempts > 100) { // ~5 minute timeout 344 + if (attempts > 60) { // ~5 minute timeout 337 345 throw new Error("Video processing timed out after 5 minutes."); 338 346 } 339 347 }