馃 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}