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