馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3 4interface TranscriptionJob { 5 id: string; 6 filename: string; 7 status: "uploading" | "processing" | "completed" | "failed"; 8 progress: number; 9 transcript?: string; 10 created_at: number; 11} 12 13@customElement("transcription-component") 14export class TranscriptionComponent extends LitElement { 15 @state() jobs: TranscriptionJob[] = []; 16 @state() isUploading = false; 17 @state() dragOver = false; 18 @state() serviceAvailable = true; 19 20 static override styles = css` 21 :host { 22 display: block; 23 } 24 25 .upload-area { 26 border: 2px dashed var(--secondary); 27 border-radius: 8px; 28 padding: 3rem 2rem; 29 text-align: center; 30 transition: all 0.2s; 31 cursor: pointer; 32 background: var(--background); 33 } 34 35 .upload-area:hover, 36 .upload-area.drag-over { 37 border-color: var(--primary); 38 background: color-mix(in srgb, var(--primary) 5%, transparent); 39 } 40 41 .upload-area.disabled { 42 border-color: var(--secondary); 43 opacity: 0.6; 44 cursor: not-allowed; 45 } 46 47 .upload-area.disabled:hover { 48 border-color: var(--secondary); 49 background: transparent; 50 } 51 52 .upload-icon { 53 font-size: 3rem; 54 color: var(--secondary); 55 margin-bottom: 1rem; 56 } 57 58 .upload-text { 59 color: var(--text); 60 font-size: 1.125rem; 61 font-weight: 500; 62 margin-bottom: 0.5rem; 63 } 64 65 .upload-hint { 66 color: var(--text); 67 opacity: 0.7; 68 font-size: 0.875rem; 69 } 70 71 .jobs-section { 72 margin-top: 2rem; 73 } 74 75 .jobs-title { 76 font-size: 1.25rem; 77 font-weight: 600; 78 color: var(--text); 79 margin-bottom: 1rem; 80 } 81 82 .job-card { 83 background: var(--background); 84 border: 1px solid var(--secondary); 85 border-radius: 8px; 86 padding: 1.5rem; 87 margin-bottom: 1rem; 88 } 89 90 .job-header { 91 display: flex; 92 align-items: center; 93 justify-content: space-between; 94 margin-bottom: 1rem; 95 } 96 97 .job-filename { 98 font-weight: 500; 99 color: var(--text); 100 } 101 102 .job-status { 103 padding: 0.25rem 0.75rem; 104 border-radius: 4px; 105 font-size: 0.75rem; 106 font-weight: 600; 107 text-transform: uppercase; 108 } 109 110 .status-uploading { 111 background: color-mix(in srgb, var(--primary) 10%, transparent); 112 color: var(--primary); 113 } 114 115 .status-processing { 116 background: color-mix(in srgb, var(--accent) 10%, transparent); 117 color: var(--accent); 118 } 119 120 .status-completed { 121 background: color-mix(in srgb, var(--success) 10%, transparent); 122 color: var(--success); 123 } 124 125 .status-failed { 126 background: color-mix(in srgb, var(--text) 10%, transparent); 127 color: var(--text); 128 } 129 130 .progress-bar { 131 width: 100%; 132 height: 4px; 133 background: var(--secondary); 134 border-radius: 2px; 135 margin-bottom: 1rem; 136 } 137 138 .progress-fill { 139 height: 100%; 140 background: var(--primary); 141 border-radius: 2px; 142 transition: width 0.3s; 143 } 144 145 .job-transcript { 146 background: color-mix(in srgb, var(--primary) 5%, transparent); 147 border-radius: 6px; 148 padding: 1rem; 149 margin-top: 1rem; 150 white-space: pre-wrap; 151 font-family: monospace; 152 font-size: 0.875rem; 153 color: var(--text); 154 } 155 156 .hidden { 157 display: none; 158 } 159 160 .file-input { 161 display: none; 162 } 163 `; 164 165 private eventSources: Map<string, EventSource> = new Map(); 166 private handleAuthChange = async () => { 167 await this.loadJobs(); 168 this.connectToJobStreams(); 169 }; 170 171 override async connectedCallback() { 172 super.connectedCallback(); 173 await this.loadJobs(); 174 this.connectToJobStreams(); 175 176 // Listen for auth changes to reload jobs 177 window.addEventListener("auth-changed", this.handleAuthChange); 178 } 179 180 override disconnectedCallback() { 181 super.disconnectedCallback(); 182 // Clean up all event sources 183 for (const es of this.eventSources.values()) { 184 es.close(); 185 } 186 this.eventSources.clear(); 187 window.removeEventListener("auth-changed", this.handleAuthChange); 188 } 189 190 private connectToJobStreams() { 191 // Connect to SSE streams for active jobs 192 for (const job of this.jobs) { 193 if (job.status === "processing" || job.status === "uploading") { 194 this.connectToJobStream(job.id); 195 } 196 } 197 } 198 199 private connectToJobStream(jobId: string, retryCount = 0) { 200 if (this.eventSources.has(jobId)) { 201 return; // Already connected 202 } 203 204 const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`); 205 206 eventSource.onmessage = (event) => { 207 const update = JSON.parse(event.data); 208 console.log(`[Client received] Job ${jobId}:`, update); 209 210 // Update the job in our list efficiently (mutate in place for Lit) 211 const job = this.jobs.find((j) => j.id === jobId); 212 if (job) { 213 // Update properties directly 214 if (update.status !== undefined) job.status = update.status; 215 if (update.progress !== undefined) job.progress = update.progress; 216 if (update.transcript !== undefined) job.transcript = update.transcript; 217 218 // Trigger Lit re-render by creating new array reference 219 this.jobs = [...this.jobs]; 220 221 // Close connection if job is complete or failed 222 if (update.status === "completed" || update.status === "failed") { 223 eventSource.close(); 224 this.eventSources.delete(jobId); 225 } 226 } 227 }; 228 229 eventSource.onerror = (error) => { 230 console.warn(`SSE connection error for job ${jobId}:`, error); 231 eventSource.close(); 232 this.eventSources.delete(jobId); 233 234 // Retry connection up to 3 times with exponential backoff 235 if (retryCount < 3) { 236 const backoff = 2 ** retryCount * 1000; // 1s, 2s, 4s 237 console.log( 238 `Retrying connection in ${backoff}ms (attempt ${retryCount + 1}/3)`, 239 ); 240 setTimeout(() => { 241 this.connectToJobStream(jobId, retryCount + 1); 242 }, backoff); 243 } else { 244 console.error(`Failed to connect to job ${jobId} after 3 attempts`); 245 } 246 }; 247 248 this.eventSources.set(jobId, eventSource); 249 } 250 251 async loadJobs() { 252 try { 253 const response = await fetch("/api/transcriptions"); 254 if (response.ok) { 255 const data = await response.json(); 256 this.jobs = data.jobs; 257 this.serviceAvailable = true; 258 } else if (response.status === 404) { 259 // Transcription service not available - show empty state 260 this.jobs = []; 261 this.serviceAvailable = false; 262 } else { 263 console.error("Failed to load jobs:", response.status); 264 this.serviceAvailable = false; 265 } 266 } catch (error) { 267 // Network error or service unavailable - don't break the page 268 console.warn("Transcription service unavailable:", error); 269 this.jobs = []; 270 this.serviceAvailable = false; 271 } 272 } 273 274 private handleDragOver(e: DragEvent) { 275 e.preventDefault(); 276 this.dragOver = true; 277 } 278 279 private handleDragLeave(e: DragEvent) { 280 e.preventDefault(); 281 this.dragOver = false; 282 } 283 284 private async handleDrop(e: DragEvent) { 285 e.preventDefault(); 286 this.dragOver = false; 287 288 const files = e.dataTransfer?.files; 289 const file = files?.[0]; 290 if (file) { 291 await this.uploadFile(file); 292 } 293 } 294 295 private async handleFileSelect(e: Event) { 296 const input = e.target as HTMLInputElement; 297 const file = input.files?.[0]; 298 if (file) { 299 await this.uploadFile(file); 300 } 301 } 302 303 private async uploadFile(file: File) { 304 const allowedTypes = [ 305 "audio/mpeg", // MP3 306 "audio/wav", // WAV 307 "audio/x-wav", // WAV (alternative) 308 "audio/m4a", // M4A 309 "audio/mp4", // MP4 audio 310 "audio/aac", // AAC 311 "audio/ogg", // OGG 312 "audio/webm", // WebM audio 313 "audio/flac", // FLAC 314 ]; 315 316 // Also check file extension for M4A files (sometimes MIME type isn't set correctly) 317 const isM4A = file.name.toLowerCase().endsWith(".m4a"); 318 const isAllowedType = 319 allowedTypes.includes(file.type) || (isM4A && file.type === ""); 320 321 if (!isAllowedType) { 322 alert( 323 "Please select a supported audio file (MP3, WAV, M4A, AAC, OGG, WebM, or FLAC)", 324 ); 325 return; 326 } 327 328 if (file.size > 25 * 1024 * 1024) { 329 // 25MB limit 330 alert("File size must be less than 25MB"); 331 return; 332 } 333 334 this.isUploading = true; 335 336 try { 337 const formData = new FormData(); 338 formData.append("audio", file); 339 340 const response = await fetch("/api/transcriptions", { 341 method: "POST", 342 body: formData, 343 }); 344 345 if (!response.ok) { 346 const data = await response.json(); 347 alert( 348 data.error || 349 "Upload failed - transcription service may be unavailable", 350 ); 351 } else { 352 const result = await response.json(); 353 await this.loadJobs(); 354 // Connect to SSE stream for this new job 355 this.connectToJobStream(result.id); 356 } 357 } catch { 358 alert("Upload failed - transcription service may be unavailable"); 359 } finally { 360 this.isUploading = false; 361 } 362 } 363 364 private getStatusClass(status: string) { 365 return `status-${status}`; 366 } 367 368 override render() { 369 return html` 370 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}" 371 @dragover=${this.serviceAvailable ? this.handleDragOver : null} 372 @dragleave=${this.serviceAvailable ? this.handleDragLeave : null} 373 @drop=${this.serviceAvailable ? this.handleDrop : null} 374 @click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}> 375 <div class="upload-icon">馃幍</div> 376 <div class="upload-text"> 377 ${ 378 !this.serviceAvailable 379 ? "Transcription service unavailable" 380 : this.isUploading 381 ? "Uploading..." 382 : "Drop audio file here or click to browse" 383 } 384 </div> 385 <div class="upload-hint"> 386 ${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 25MB - Requires faster-whisper server" : "Transcription service unavailable"} 387 </div> 388 <input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!this.serviceAvailable ? "disabled" : ""} /> 389 </div> 390 391 <div class="jobs-section ${this.jobs.length === 0 ? "hidden" : ""}"> 392 <h3 class="jobs-title">Your Transcriptions</h3> 393 ${this.jobs.map( 394 (job) => html` 395 <div class="job-card"> 396 <div class="job-header"> 397 <span class="job-filename">${job.filename}</span> 398 <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span> 399 </div> 400 401 ${ 402 job.status === "uploading" || job.status === "processing" 403 ? html` 404 <div class="progress-bar"> 405 <div class="progress-fill" style="width: ${job.progress}%"></div> 406 </div> 407 ` 408 : "" 409 } 410 411 ${ 412 job.transcript 413 ? html` 414 <div class="job-transcript">${job.transcript}</div> 415 ` 416 : "" 417 } 418 </div> 419 `, 420 )} 421 </div> 422 `; 423 } 424}