馃 distributed transcription service thistle.dunkirk.sh
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import "./vtt-viewer.ts"; 4 5interface TranscriptionJob { 6 id: string; 7 filename: string; 8 class_name?: string; 9 status: "uploading" | "processing" | "transcribing" | "completed" | "failed"; 10 progress: number; 11 transcript?: string; 12 created_at: number; 13 audioUrl?: string; 14 vttSegments?: VTTSegment[]; 15 vttContent?: string; 16} 17 18interface VTTSegment { 19 start: number; 20 end: number; 21 text: string; 22 index?: string; 23} 24 25class WordStreamer { 26 private queue: string[] = []; 27 private isProcessing = false; 28 private wordDelay: number; 29 private onWord: (word: string) => void; 30 31 constructor(wordDelay: number = 50, onWord: (word: string) => void) { 32 this.wordDelay = wordDelay; 33 this.onWord = onWord; 34 } 35 36 addChunk(text: string) { 37 // Split on whitespace and filter out empty strings 38 const words = text.split(/(\s+)/).filter((w) => w.length > 0); 39 this.queue.push(...words); 40 41 // Start processing if not already running 42 if (!this.isProcessing) { 43 this.processQueue(); 44 } 45 } 46 47 private async processQueue() { 48 this.isProcessing = true; 49 50 while (this.queue.length > 0) { 51 const word = this.queue.shift(); 52 if (!word) break; 53 this.onWord(word); 54 await new Promise((resolve) => setTimeout(resolve, this.wordDelay)); 55 } 56 57 this.isProcessing = false; 58 } 59 60 showAll() { 61 // Drain entire queue immediately 62 while (this.queue.length > 0) { 63 const word = this.queue.shift(); 64 if (!word) break; 65 this.onWord(word); 66 } 67 this.isProcessing = false; 68 } 69 70 clear() { 71 this.queue = []; 72 this.isProcessing = false; 73 } 74} 75 76@customElement("transcription-component") 77export class TranscriptionComponent extends LitElement { 78 @state() jobs: TranscriptionJob[] = []; 79 @state() isUploading = false; 80 @state() dragOver = false; 81 @state() serviceAvailable = true; 82 @state() existingClasses: string[] = []; 83 @state() showNewClassInput = false; 84 @state() hasSubscription = false; 85 @state() isAdmin = false; 86 // Word streamers for each job 87 private wordStreamers = new Map<string, WordStreamer>(); 88 // Displayed transcripts 89 private displayedTranscripts = new Map<string, string>(); 90 // Track last full transcript to compare 91 private lastTranscripts = new Map<string, string>(); 92 93 static override styles = css` 94 :host { 95 display: block; 96 } 97 98 .upload-area { 99 border: 2px dashed var(--secondary); 100 border-radius: 8px; 101 padding: 3rem 2rem; 102 text-align: center; 103 transition: all 0.2s; 104 cursor: pointer; 105 background: var(--background); 106 } 107 108 .upload-area:hover, 109 .upload-area.drag-over { 110 border-color: var(--primary); 111 background: color-mix(in srgb, var(--primary) 5%, transparent); 112 } 113 114 .upload-area.disabled { 115 border-color: var(--secondary); 116 opacity: 0.6; 117 cursor: not-allowed; 118 } 119 120 .upload-area.disabled:hover { 121 border-color: var(--secondary); 122 background: transparent; 123 } 124 125 .upload-icon { 126 font-size: 3rem; 127 color: var(--secondary); 128 margin-bottom: 1rem; 129 } 130 131 .upload-text { 132 color: var(--text); 133 font-size: 1.125rem; 134 font-weight: 500; 135 margin-bottom: 0.5rem; 136 } 137 138 .upload-hint { 139 color: var(--text); 140 opacity: 0.7; 141 font-size: 0.875rem; 142 } 143 144 .jobs-section { 145 margin-top: 2rem; 146 } 147 148 .jobs-title { 149 font-size: 1.25rem; 150 font-weight: 600; 151 color: var(--text); 152 margin-bottom: 1rem; 153 } 154 155 .job-card { 156 background: var(--background); 157 border: 1px solid var(--secondary); 158 border-radius: 8px; 159 padding: 1.5rem; 160 margin-bottom: 1rem; 161 } 162 163 .job-header { 164 display: flex; 165 align-items: center; 166 justify-content: space-between; 167 margin-bottom: 1rem; 168 } 169 170 .job-filename { 171 font-weight: 500; 172 color: var(--text); 173 } 174 175 .job-status { 176 padding: 0.25rem 0.75rem; 177 border-radius: 4px; 178 font-size: 0.75rem; 179 font-weight: 600; 180 text-transform: uppercase; 181 } 182 183 .status-uploading { 184 background: color-mix(in srgb, var(--primary) 10%, transparent); 185 color: var(--primary); 186 } 187 188 .status-processing { 189 background: color-mix(in srgb, var(--primary) 10%, transparent); 190 color: var(--primary); 191 } 192 193 .status-transcribing { 194 background: color-mix(in srgb, var(--accent) 10%, transparent); 195 color: var(--accent); 196 } 197 198 .status-completed { 199 background: color-mix(in srgb, var(--success) 10%, transparent); 200 color: var(--success); 201 } 202 203 .status-failed { 204 background: color-mix(in srgb, var(--text) 10%, transparent); 205 color: var(--text); 206 } 207 208 .progress-bar { 209 width: 100%; 210 height: 4px; 211 background: var(--secondary); 212 border-radius: 2px; 213 margin-bottom: 1rem; 214 overflow: hidden; 215 position: relative; 216 } 217 218 .progress-fill { 219 height: 100%; 220 background: var(--primary); 221 border-radius: 2px; 222 transition: width 0.3s; 223 } 224 225 .progress-fill.indeterminate { 226 width: 30%; 227 background: var(--primary); 228 animation: progress-slide 1.5s ease-in-out infinite; 229 } 230 231 @keyframes progress-slide { 232 0% { 233 transform: translateX(-100%); 234 } 235 100% { 236 transform: translateX(333%); 237 } 238 } 239 240 .job-transcript { 241 background: color-mix(in srgb, var(--primary) 5%, transparent); 242 border-radius: 6px; 243 padding: 1rem; 244 margin-top: 1rem; 245 font-family: monospace; 246 font-size: 0.875rem; 247 color: var(--text); 248 line-height: 1.6; 249 word-wrap: break-word; 250 } 251 252 .segment { 253 cursor: pointer; 254 transition: background 0.1s; 255 display: inline; 256 } 257 258 .segment:hover { 259 background: color-mix(in srgb, var(--primary) 15%, transparent); 260 border-radius: 2px; 261 } 262 263 .current-segment { 264 background: color-mix(in srgb, var(--accent) 30%, transparent); 265 border-radius: 2px; 266 } 267 268 .paragraph { 269 display: block; 270 margin: 0 0 1rem 0; 271 line-height: 1.6; 272 } 273 274 .audio-player { 275 margin-top: 1rem; 276 width: 100%; 277 } 278 279 .audio-player audio { 280 width: 100%; 281 height: 2.5rem; 282 } 283 284 .hidden { 285 display: none; 286 } 287 288 .file-input { 289 display: none; 290 } 291 292 .upload-form { 293 margin-top: 1rem; 294 display: flex; 295 flex-direction: column; 296 gap: 0.75rem; 297 } 298 299 .class-input { 300 padding: 0.5rem 0.75rem; 301 border: 1px solid var(--secondary); 302 border-radius: 4px; 303 font-size: 0.875rem; 304 color: var(--text); 305 background: var(--background); 306 } 307 308 .class-input:focus { 309 outline: none; 310 border-color: var(--primary); 311 } 312 313 .class-input::placeholder { 314 color: var(--paynes-gray); 315 opacity: 0.6; 316 } 317 318 .class-select { 319 width: 100%; 320 padding: 0.5rem 0.75rem; 321 border: 1px solid var(--secondary); 322 border-radius: 4px; 323 font-size: 0.875rem; 324 color: var(--text); 325 background: var(--background); 326 cursor: pointer; 327 } 328 329 .class-select:focus { 330 outline: none; 331 border-color: var(--primary); 332 } 333 334 .class-select option { 335 padding: 0.5rem; 336 } 337 338 .class-group { 339 margin-bottom: 2rem; 340 } 341 342 .class-header { 343 font-size: 1.25rem; 344 font-weight: 600; 345 color: var(--text); 346 margin-bottom: 1rem; 347 padding-bottom: 0.5rem; 348 border-bottom: 2px solid var(--accent); 349 } 350 351 .no-class-header { 352 border-bottom-color: var(--secondary); 353 } 354 `; 355 356 private eventSources: Map<string, EventSource> = new Map(); 357 private handleAuthChange = async () => { 358 await this.checkHealth(); 359 await this.loadJobs(); 360 await this.loadExistingClasses(); 361 this.connectToJobStreams(); 362 }; 363 364 private async loadExistingClasses() { 365 try { 366 const response = await fetch("/api/transcriptions"); 367 if (!response.ok) { 368 this.existingClasses = []; 369 return; 370 } 371 372 const data = await response.json(); 373 const jobs = data.jobs || []; 374 375 // Extract unique class names 376 const classSet = new Set<string>(); 377 for (const job of jobs) { 378 if (job.class_name) { 379 classSet.add(job.class_name); 380 } 381 } 382 383 this.existingClasses = Array.from(classSet).sort(); 384 } catch (error) { 385 console.error("Failed to load classes:", error); 386 this.existingClasses = []; 387 } 388 } 389 390 override async connectedCallback() { 391 super.connectedCallback(); 392 await this.checkAuth(); 393 await this.checkHealth(); 394 await this.loadJobs(); 395 await this.loadExistingClasses(); 396 this.connectToJobStreams(); 397 398 // Listen for auth changes to reload jobs 399 window.addEventListener("auth-changed", this.handleAuthChange); 400 } 401 402 override disconnectedCallback() { 403 super.disconnectedCallback(); 404 // Clean up all event sources and word streamers 405 for (const es of this.eventSources.values()) { 406 es.close(); 407 } 408 this.eventSources.clear(); 409 410 for (const streamer of this.wordStreamers.values()) { 411 streamer.clear(); 412 } 413 this.wordStreamers.clear(); 414 this.displayedTranscripts.clear(); 415 this.lastTranscripts.clear(); 416 417 window.removeEventListener("auth-changed", this.handleAuthChange); 418 } 419 420 private connectToJobStreams() { 421 // Connect to SSE streams for active jobs 422 for (const job of this.jobs) { 423 if ( 424 job.status === "processing" || 425 job.status === "transcribing" || 426 job.status === "uploading" 427 ) { 428 this.connectToJobStream(job.id); 429 } 430 } 431 } 432 433 private connectToJobStream(jobId: string, retryCount = 0) { 434 if (this.eventSources.has(jobId)) { 435 return; // Already connected 436 } 437 438 const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`); 439 440 // Handle named "update" events from SSE stream 441 eventSource.addEventListener("update", async (event) => { 442 const update = JSON.parse(event.data); 443 444 // Update the job in our list efficiently (mutate in place for Lit) 445 const job = this.jobs.find((j) => j.id === jobId); 446 if (job) { 447 // Update properties directly 448 if (update.status !== undefined) job.status = update.status; 449 if (update.progress !== undefined) job.progress = update.progress; 450 if (update.transcript !== undefined) { 451 job.transcript = update.transcript; 452 453 // Get or create word streamer for this job 454 if (!this.wordStreamers.has(jobId)) { 455 const streamer = new WordStreamer(50, (word) => { 456 const current = this.displayedTranscripts.get(jobId) || ""; 457 this.displayedTranscripts.set(jobId, current + word); 458 this.requestUpdate(); 459 }); 460 this.wordStreamers.set(jobId, streamer); 461 } 462 463 const streamer = this.wordStreamers.get(jobId); 464 if (!streamer) return; 465 const lastTranscript = this.lastTranscripts.get(jobId) || ""; 466 const newTranscript = update.transcript; 467 468 // Check if this is new content we haven't seen 469 if (newTranscript !== lastTranscript) { 470 // If new transcript starts with last transcript, it's cumulative - add diff 471 if (newTranscript.startsWith(lastTranscript)) { 472 const newPortion = newTranscript.slice(lastTranscript.length); 473 if (newPortion.trim()) { 474 streamer.addChunk(newPortion); 475 } 476 } else { 477 // Completely different segment, add space separator then new content 478 if (lastTranscript) { 479 streamer.addChunk(" "); 480 } 481 streamer.addChunk(newTranscript); 482 } 483 this.lastTranscripts.set(jobId, newTranscript); 484 } 485 486 // On completion, show everything immediately 487 if (update.status === "completed") { 488 streamer.showAll(); 489 this.wordStreamers.delete(jobId); 490 this.lastTranscripts.delete(jobId); 491 } 492 } 493 494 // Trigger Lit re-render by creating new array reference 495 this.jobs = [...this.jobs]; 496 497 // Close connection if job is complete or failed 498 if (update.status === "completed" || update.status === "failed") { 499 eventSource.close(); 500 this.eventSources.delete(jobId); 501 502 // Clean up streamer 503 const streamer = this.wordStreamers.get(jobId); 504 if (streamer) { 505 streamer.clear(); 506 this.wordStreamers.delete(jobId); 507 } 508 this.lastTranscripts.delete(jobId); 509 510 // Load VTT for completed jobs 511 if (update.status === "completed") { 512 await this.loadVTTForJob(jobId); 513 } 514 } 515 } 516 }); 517 518 eventSource.onerror = (error) => { 519 console.warn(`SSE connection error for job ${jobId}:`, error); 520 eventSource.close(); 521 this.eventSources.delete(jobId); 522 523 // Check if the job still exists before retrying 524 const job = this.jobs.find((j) => j.id === jobId); 525 if (!job) { 526 console.log(`Job ${jobId} no longer exists, skipping retry`); 527 return; 528 } 529 530 // Don't retry if job is already in a terminal state 531 if (job.status === "completed" || job.status === "failed") { 532 console.log(`Job ${jobId} is ${job.status}, skipping retry`); 533 return; 534 } 535 536 // Retry connection up to 3 times with exponential backoff 537 if (retryCount < 3) { 538 const backoff = 2 ** retryCount * 1000; // 1s, 2s, 4s 539 console.log( 540 `Retrying connection in ${backoff}ms (attempt ${retryCount + 1}/3)`, 541 ); 542 setTimeout(() => { 543 this.connectToJobStream(jobId, retryCount + 1); 544 }, backoff); 545 } else { 546 console.error(`Failed to connect to job ${jobId} after 3 attempts`); 547 } 548 }; 549 550 this.eventSources.set(jobId, eventSource); 551 } 552 553 async checkAuth() { 554 try { 555 const response = await fetch("/api/auth/me"); 556 if (response.ok) { 557 const data = await response.json(); 558 this.hasSubscription = data.has_subscription || false; 559 this.isAdmin = data.role === "admin"; 560 } 561 } catch (error) { 562 console.warn("Failed to check auth:", error); 563 } 564 } 565 566 async checkHealth() { 567 try { 568 const response = await fetch("/api/health"); 569 if (response.ok) { 570 const data = await response.json(); 571 this.serviceAvailable = data.status === "healthy"; 572 } else { 573 this.serviceAvailable = false; 574 } 575 } catch { 576 this.serviceAvailable = false; 577 } 578 } 579 580 async loadJobs() { 581 try { 582 const response = await fetch("/api/transcriptions"); 583 if (response.ok) { 584 const data = await response.json(); 585 this.jobs = data.jobs; 586 587 // Initialize displayedTranscripts for completed/failed jobs 588 for (const job of this.jobs) { 589 if ( 590 (job.status === "completed" || job.status === "failed") && 591 job.transcript 592 ) { 593 this.displayedTranscripts.set(job.id, job.transcript); 594 } 595 596 // Fetch VTT for completed jobs 597 if (job.status === "completed") { 598 await this.loadVTTForJob(job.id); 599 } 600 } 601 // Don't override serviceAvailable - it's set by checkHealth() 602 } else if (response.status === 403) { 603 // Subscription required - already handled by checkAuth 604 this.jobs = []; 605 } else if (response.status === 404) { 606 // Transcription service not available - show empty state 607 this.jobs = []; 608 } else { 609 console.error("Failed to load jobs:", response.status); 610 } 611 } catch (error) { 612 // Network error or service unavailable - don't break the page 613 console.warn("Transcription service unavailable:", error); 614 this.jobs = []; 615 } 616 } 617 618 private async loadVTTForJob(jobId: string) { 619 try { 620 const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`); 621 if (response.ok) { 622 const vttContent = await response.text(); 623 624 // Update job with VTT content 625 const job = this.jobs.find((j) => j.id === jobId); 626 if (job) { 627 job.vttContent = vttContent; 628 job.audioUrl = `/api/transcriptions/${jobId}/audio`; 629 this.jobs = [...this.jobs]; 630 } 631 } 632 } catch (error) { 633 console.warn(`Failed to load VTT for job ${jobId}:`, error); 634 } 635 } 636 637 private handleDragOver(e: DragEvent) { 638 e.preventDefault(); 639 this.dragOver = true; 640 } 641 642 private handleDragLeave(e: DragEvent) { 643 e.preventDefault(); 644 this.dragOver = false; 645 } 646 647 private async handleDrop(e: DragEvent) { 648 e.preventDefault(); 649 this.dragOver = false; 650 651 const files = e.dataTransfer?.files; 652 const file = files?.[0]; 653 if (file) { 654 await this.uploadFile(file); 655 } 656 } 657 658 private async handleFileSelect(e: Event) { 659 const input = e.target as HTMLInputElement; 660 const file = input.files?.[0]; 661 if (file) { 662 await this.uploadFile(file); 663 } 664 } 665 666 private handleClassSelectChange(e: Event) { 667 const select = e.target as HTMLSelectElement; 668 this.showNewClassInput = select.value === "__new__"; 669 } 670 671 private async uploadFile(file: File) { 672 const allowedTypes = [ 673 "audio/mpeg", // MP3 674 "audio/wav", // WAV 675 "audio/x-wav", // WAV (alternative) 676 "audio/m4a", // M4A 677 "audio/x-m4a", // M4A (alternative) 678 "audio/mp4", // MP4 audio 679 "audio/aac", // AAC 680 "audio/ogg", // OGG 681 "audio/webm", // WebM audio 682 "audio/flac", // FLAC 683 ]; 684 685 // Also check file extension for M4A files (sometimes MIME type isn't set correctly) 686 const isM4A = file.name.toLowerCase().endsWith(".m4a"); 687 const isAllowedType = 688 allowedTypes.includes(file.type) || (isM4A && file.type === ""); 689 690 if (!isAllowedType) { 691 alert( 692 "Please select a supported audio file (MP3, WAV, M4A, AAC, OGG, WebM, or FLAC)", 693 ); 694 return; 695 } 696 697 if (file.size > 100 * 1024 * 1024) { 698 // 100MB limit 699 alert("File size must be less than 100MB"); 700 return; 701 } 702 703 this.isUploading = true; 704 705 try { 706 // Get class name from dropdown or input 707 let className = ""; 708 709 if (this.showNewClassInput) { 710 const classInput = this.shadowRoot?.querySelector( 711 "#class-name-input", 712 ) as HTMLInputElement; 713 className = classInput?.value?.trim() || ""; 714 } else { 715 const classSelect = this.shadowRoot?.querySelector( 716 "#class-select", 717 ) as HTMLSelectElement; 718 const selectedValue = classSelect?.value; 719 if ( 720 selectedValue && 721 selectedValue !== "__new__" && 722 selectedValue !== "" 723 ) { 724 className = selectedValue; 725 } 726 } 727 728 const formData = new FormData(); 729 formData.append("audio", file); 730 if (className) { 731 formData.append("class_name", className); 732 } 733 734 const response = await fetch("/api/transcriptions", { 735 method: "POST", 736 body: formData, 737 }); 738 739 if (!response.ok) { 740 const data = await response.json(); 741 if (response.status === 403) { 742 // Subscription required 743 if ( 744 confirm( 745 "Active subscription required to upload transcriptions. Would you like to subscribe now?", 746 ) 747 ) { 748 window.location.href = "/settings?tab=billing"; 749 return; 750 } 751 } else { 752 alert( 753 data.error || 754 "Upload failed - transcription service may be unavailable", 755 ); 756 } 757 } else { 758 await response.json(); 759 // Redirect to class page after successful upload 760 let className = ""; 761 762 if (this.showNewClassInput) { 763 const classInput = this.shadowRoot?.querySelector( 764 "#class-name-input", 765 ) as HTMLInputElement; 766 className = classInput?.value?.trim() || ""; 767 } else { 768 const classSelect = this.shadowRoot?.querySelector( 769 "#class-select", 770 ) as HTMLSelectElement; 771 const selectedValue = classSelect?.value; 772 if ( 773 selectedValue && 774 selectedValue !== "__new__" && 775 selectedValue !== "" 776 ) { 777 className = selectedValue; 778 } 779 } 780 781 if (className) { 782 window.location.href = `/class/${encodeURIComponent(className)}`; 783 } else { 784 window.location.href = "/class/uncategorized"; 785 } 786 } 787 } catch { 788 alert("Upload failed - transcription service may be unavailable"); 789 } finally { 790 this.isUploading = false; 791 } 792 } 793 794 override render() { 795 const canUpload = 796 this.serviceAvailable && (this.hasSubscription || this.isAdmin); 797 798 return html` 799 ${ 800 !this.hasSubscription && !this.isAdmin 801 ? html` 802 <div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;"> 803 <h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3> 804 <p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p> 805 <a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a> 806 </div> 807 ` 808 : "" 809 } 810 811 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}" 812 @dragover=${canUpload ? this.handleDragOver : null} 813 @dragleave=${canUpload ? this.handleDragLeave : null} 814 @drop=${canUpload ? this.handleDrop : null} 815 @click=${canUpload ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}> 816 <div class="upload-icon">馃幍</div> 817 <div class="upload-text"> 818 ${ 819 !this.serviceAvailable 820 ? "Transcription service unavailable" 821 : !this.hasSubscription && !this.isAdmin 822 ? "Subscription required" 823 : this.isUploading 824 ? "Uploading..." 825 : "Drop audio file here or click to browse" 826 } 827 </div> 828 <div class="upload-hint"> 829 ${canUpload ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"} 830 </div> 831 <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} ${!canUpload ? "disabled" : ""} /> 832 </div> 833 834 ${ 835 canUpload 836 ? html` 837 <div class="upload-form"> 838 <select 839 id="class-select" 840 class="class-select" 841 ?disabled=${this.isUploading} 842 @change=${this.handleClassSelectChange} 843 > 844 <option value="">Select a class (optional)</option> 845 ${this.existingClasses.map( 846 (className) => html` 847 <option value=${className}>${className}</option> 848 `, 849 )} 850 <option value="__new__">+ Add new class</option> 851 </select> 852 853 ${ 854 this.showNewClassInput 855 ? html` 856 <input 857 type="text" 858 id="class-name-input" 859 class="class-input" 860 placeholder="Enter new class name" 861 ?disabled=${this.isUploading} 862 /> 863 ` 864 : "" 865 } 866 </div> 867 ` 868 : "" 869 } 870 `; 871 } 872}