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