馃 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 209 // Update the job in our list efficiently (mutate in place for Lit) 210 const job = this.jobs.find((j) => j.id === jobId); 211 if (job) { 212 // Update properties directly 213 if (update.status !== undefined) job.status = update.status; 214 if (update.progress !== undefined) job.progress = update.progress; 215 if (update.transcript !== undefined) job.transcript = update.transcript; 216 217 // Trigger Lit re-render by creating new array reference 218 this.jobs = [...this.jobs]; 219 220 // Close connection if job is complete or failed 221 if (update.status === "completed" || update.status === "failed") { 222 eventSource.close(); 223 this.eventSources.delete(jobId); 224 } 225 } 226 }; 227 228 eventSource.onerror = (error) => { 229 console.warn(`SSE connection error for job ${jobId}:`, error); 230 eventSource.close(); 231 this.eventSources.delete(jobId); 232 233 // Retry connection up to 3 times with exponential backoff 234 if (retryCount < 3) { 235 const backoff = 2 ** retryCount * 1000; // 1s, 2s, 4s 236 console.log( 237 `Retrying connection in ${backoff}ms (attempt ${retryCount + 1}/3)`, 238 ); 239 setTimeout(() => { 240 this.connectToJobStream(jobId, retryCount + 1); 241 }, backoff); 242 } else { 243 console.error(`Failed to connect to job ${jobId} after 3 attempts`); 244 } 245 }; 246 247 this.eventSources.set(jobId, eventSource); 248 } 249 250 async loadJobs() { 251 try { 252 const response = await fetch("/api/transcriptions"); 253 if (response.ok) { 254 const data = await response.json(); 255 this.jobs = data.jobs; 256 this.serviceAvailable = true; 257 } else if (response.status === 404) { 258 // Transcription service not available - show empty state 259 this.jobs = []; 260 this.serviceAvailable = false; 261 } else { 262 console.error("Failed to load jobs:", response.status); 263 this.serviceAvailable = false; 264 } 265 } catch (error) { 266 // Network error or service unavailable - don't break the page 267 console.warn("Transcription service unavailable:", error); 268 this.jobs = []; 269 this.serviceAvailable = false; 270 } 271 } 272 273 private handleDragOver(e: DragEvent) { 274 e.preventDefault(); 275 this.dragOver = true; 276 } 277 278 private handleDragLeave(e: DragEvent) { 279 e.preventDefault(); 280 this.dragOver = false; 281 } 282 283 private async handleDrop(e: DragEvent) { 284 e.preventDefault(); 285 this.dragOver = false; 286 287 const files = e.dataTransfer?.files; 288 const file = files?.[0]; 289 if (file) { 290 await this.uploadFile(file); 291 } 292 } 293 294 private async handleFileSelect(e: Event) { 295 const input = e.target as HTMLInputElement; 296 const file = input.files?.[0]; 297 if (file) { 298 await this.uploadFile(file); 299 } 300 } 301 302 private async uploadFile(file: File) { 303 const allowedTypes = [ 304 "audio/mpeg", // MP3 305 "audio/wav", // WAV 306 "audio/x-wav", // WAV (alternative) 307 "audio/m4a", // M4A 308 "audio/mp4", // MP4 audio 309 "audio/aac", // AAC 310 "audio/ogg", // OGG 311 "audio/webm", // WebM audio 312 "audio/flac", // FLAC 313 ]; 314 315 // Also check file extension for M4A files (sometimes MIME type isn't set correctly) 316 const isM4A = file.name.toLowerCase().endsWith(".m4a"); 317 const isAllowedType = 318 allowedTypes.includes(file.type) || (isM4A && file.type === ""); 319 320 if (!isAllowedType) { 321 alert( 322 "Please select a supported audio file (MP3, WAV, M4A, AAC, OGG, WebM, or FLAC)", 323 ); 324 return; 325 } 326 327 if (file.size > 25 * 1024 * 1024) { 328 // 25MB limit 329 alert("File size must be less than 25MB"); 330 return; 331 } 332 333 this.isUploading = true; 334 335 try { 336 const formData = new FormData(); 337 formData.append("audio", file); 338 339 const response = await fetch("/api/transcriptions", { 340 method: "POST", 341 body: formData, 342 }); 343 344 if (!response.ok) { 345 const data = await response.json(); 346 alert( 347 data.error || 348 "Upload failed - transcription service may be unavailable", 349 ); 350 } else { 351 const result = await response.json(); 352 await this.loadJobs(); 353 // Connect to SSE stream for this new job 354 this.connectToJobStream(result.id); 355 } 356 } catch { 357 alert("Upload failed - transcription service may be unavailable"); 358 } finally { 359 this.isUploading = false; 360 } 361 } 362 363 private getStatusClass(status: string) { 364 return `status-${status}`; 365 } 366 367 override render() { 368 return html` 369 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}" 370 @dragover=${this.serviceAvailable ? this.handleDragOver : null} 371 @dragleave=${this.serviceAvailable ? this.handleDragLeave : null} 372 @drop=${this.serviceAvailable ? this.handleDrop : null} 373 @click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}> 374 <div class="upload-icon">馃幍</div> 375 <div class="upload-text"> 376 ${ 377 !this.serviceAvailable 378 ? "Transcription service unavailable" 379 : this.isUploading 380 ? "Uploading..." 381 : "Drop audio file here or click to browse" 382 } 383 </div> 384 <div class="upload-hint"> 385 ${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 25MB - Requires faster-whisper server" : "Transcription service unavailable"} 386 </div> 387 <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" : ""} /> 388 </div> 389 390 <div class="jobs-section ${this.jobs.length === 0 ? "hidden" : ""}"> 391 <h3 class="jobs-title">Your Transcriptions</h3> 392 ${this.jobs.map( 393 (job) => html` 394 <div class="job-card"> 395 <div class="job-header"> 396 <span class="job-filename">${job.filename}</span> 397 <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span> 398 </div> 399 400 ${ 401 job.status === "uploading" || job.status === "processing" 402 ? html` 403 <div class="progress-bar"> 404 <div class="progress-fill" style="width: ${job.progress}%"></div> 405 </div> 406 ` 407 : "" 408 } 409 410 ${ 411 job.transcript 412 ? html` 413 <div class="job-transcript">${job.transcript}</div> 414 ` 415 : "" 416 } 417 </div> 418 `, 419 )} 420 </div> 421 `; 422 } 423}