🪻 distributed transcription service thistle.dunkirk.sh

feat: auto detect file time

dunkirk.sh c0f6c375 4d7be0af

verified
+631 -26
+147 -25
src/components/upload-recording-modal.ts
··· 24 24 @state() private selectedSectionId: string | null = null; 25 25 @state() private uploading = false; 26 26 @state() private error: string | null = null; 27 + @state() private detectedMeetingTime: string | null = null; 28 + @state() private detectingMeetingTime = false; 27 29 28 30 static override styles = css` 29 31 :host { ··· 221 223 align-items: center; 222 224 gap: 0.5rem; 223 225 } 226 + 227 + .meeting-time-selector { 228 + display: flex; 229 + flex-direction: column; 230 + gap: 0.5rem; 231 + } 232 + 233 + .meeting-time-button { 234 + padding: 0.75rem 1rem; 235 + background: var(--background); 236 + border: 2px solid var(--secondary); 237 + border-radius: 6px; 238 + font-size: 0.875rem; 239 + font-weight: 500; 240 + cursor: pointer; 241 + transition: all 0.2s; 242 + font-family: inherit; 243 + color: var(--text); 244 + text-align: left; 245 + display: flex; 246 + align-items: center; 247 + gap: 0.5rem; 248 + } 249 + 250 + .meeting-time-button:hover { 251 + border-color: var(--primary); 252 + background: color-mix(in srgb, var(--primary) 5%, transparent); 253 + } 254 + 255 + .meeting-time-button.selected { 256 + background: var(--primary); 257 + border-color: var(--primary); 258 + color: white; 259 + } 260 + 261 + .meeting-time-button.detected { 262 + border-color: var(--accent); 263 + } 264 + 265 + .meeting-time-button.detected::after { 266 + content: "✨ Auto-detected"; 267 + margin-left: auto; 268 + font-size: 0.75rem; 269 + opacity: 0.8; 270 + } 271 + 272 + .detecting-text { 273 + font-size: 0.875rem; 274 + color: var(--paynes-gray); 275 + padding: 0.5rem; 276 + text-align: center; 277 + font-style: italic; 278 + } 224 279 `; 225 280 226 - private handleFileSelect(e: Event) { 281 + private async handleFileSelect(e: Event) { 227 282 const input = e.target as HTMLInputElement; 228 283 if (input.files && input.files.length > 0) { 229 284 this.selectedFile = input.files[0] ?? null; 230 285 this.error = null; 286 + this.detectedMeetingTime = null; 287 + this.selectedMeetingTimeId = null; 288 + 289 + // Auto-detect meeting time from file metadata 290 + if (this.selectedFile && this.classId) { 291 + await this.detectMeetingTime(); 292 + } 231 293 } 232 294 } 233 295 234 - private handleMeetingTimeChange(e: Event) { 235 - const select = e.target as HTMLSelectElement; 236 - this.selectedMeetingTimeId = select.value || null; 296 + private async detectMeetingTime() { 297 + if (!this.selectedFile || !this.classId) return; 298 + 299 + this.detectingMeetingTime = true; 300 + 301 + try { 302 + const formData = new FormData(); 303 + formData.append("audio", this.selectedFile); 304 + formData.append("class_id", this.classId); 305 + 306 + // Send the file's original lastModified timestamp (preserved by browser) 307 + // This is more accurate than server-side file timestamps 308 + if (this.selectedFile.lastModified) { 309 + formData.append( 310 + "file_timestamp", 311 + this.selectedFile.lastModified.toString(), 312 + ); 313 + } 314 + 315 + const response = await fetch("/api/transcriptions/detect-meeting-time", { 316 + method: "POST", 317 + body: formData, 318 + }); 319 + 320 + if (!response.ok) { 321 + console.warn("Failed to detect meeting time"); 322 + return; 323 + } 324 + 325 + const data = await response.json(); 326 + 327 + if (data.detected && data.meeting_time_id) { 328 + this.detectedMeetingTime = data.meeting_time_id; 329 + this.selectedMeetingTimeId = data.meeting_time_id; 330 + } 331 + } catch (error) { 332 + console.warn("Error detecting meeting time:", error); 333 + } finally { 334 + this.detectingMeetingTime = false; 335 + } 336 + } 337 + 338 + private handleMeetingTimeSelect(meetingTimeId: string) { 339 + this.selectedMeetingTimeId = meetingTimeId; 237 340 } 238 341 239 342 private handleSectionChange(e: Event) { ··· 248 351 this.selectedMeetingTimeId = null; 249 352 this.selectedSectionId = null; 250 353 this.error = null; 354 + this.detectedMeetingTime = null; 355 + this.detectingMeetingTime = false; 251 356 this.dispatchEvent(new CustomEvent("close")); 252 357 } 253 358 ··· 287 392 throw new Error(data.error || "Upload failed"); 288 393 } 289 394 290 - // Success - close modal and notify parent 395 + // Success 291 396 this.dispatchEvent(new CustomEvent("upload-success")); 292 397 this.handleClose(); 293 398 } catch (error) { ··· 342 447 <div class="help-text">Maximum file size: 100MB</div> 343 448 </div> 344 449 345 - <div class="form-group"> 346 - <label for="meeting-time">Meeting Time</label> 347 - <select 348 - id="meeting-time" 349 - @change=${this.handleMeetingTimeChange} 350 - ?disabled=${this.uploading} 351 - required 352 - > 353 - <option value="">Select a meeting time...</option> 354 - ${this.meetingTimes.map( 355 - (meeting) => html` 356 - <option value=${meeting.id}>${meeting.label}</option> 357 - `, 358 - )} 359 - </select> 360 - <div class="help-text"> 361 - Select which meeting this recording is for 362 - </div> 363 - </div> 450 + ${ 451 + this.selectedFile 452 + ? html` 453 + <div class="form-group"> 454 + <label>Meeting Time</label> 455 + ${ 456 + this.detectingMeetingTime 457 + ? html`<div class="detecting-text">Detecting meeting time from audio metadata...</div>` 458 + : html` 459 + <div class="meeting-time-selector"> 460 + ${this.meetingTimes.map( 461 + (meeting) => html` 462 + <button 463 + type="button" 464 + class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}" 465 + @click=${() => this.handleMeetingTimeSelect(meeting.id)} 466 + ?disabled=${this.uploading} 467 + > 468 + ${meeting.label} 469 + </button> 470 + `, 471 + )} 472 + </div> 473 + ` 474 + } 475 + <div class="help-text"> 476 + ${ 477 + this.detectedMeetingTime 478 + ? "Auto-detected based on recording date. You can change if needed." 479 + : "Select which meeting this recording is for" 480 + } 481 + </div> 482 + </div> 483 + ` 484 + : "" 485 + } 364 486 365 487 ${ 366 - this.sections.length > 1 488 + this.sections.length > 1 && this.selectedFile 367 489 ? html` 368 490 <div class="form-group"> 369 491 <label for="section">Section (optional)</label>
+157 -1
src/index.ts
··· 92 92 WhisperServiceManager, 93 93 } from "./lib/transcription"; 94 94 import { 95 + extractAudioCreationDate, 96 + findMatchingMeetingTime, 97 + getDayName, 98 + } from "./lib/audio-metadata"; 99 + import { 95 100 validateClassId, 96 101 validateCourseCode, 97 102 validateCourseName, ··· 2082 2087 } 2083 2088 }, 2084 2089 }, 2090 + "/api/transcriptions/detect-meeting-time": { 2091 + POST: async (req) => { 2092 + try { 2093 + const user = requireAuth(req); 2094 + 2095 + const formData = await req.formData(); 2096 + const file = formData.get("audio") as File; 2097 + const classId = formData.get("class_id") as string | null; 2098 + const fileTimestampStr = formData.get("file_timestamp") as 2099 + | string 2100 + | null; 2101 + 2102 + if (!file) throw ValidationErrors.missingField("audio"); 2103 + if (!classId) throw ValidationErrors.missingField("class_id"); 2104 + 2105 + // Verify user is enrolled in the class 2106 + const enrolled = isUserEnrolledInClass(user.id, classId); 2107 + if (!enrolled && user.role !== "admin") { 2108 + return Response.json( 2109 + { error: "Not enrolled in this class" }, 2110 + { status: 403 }, 2111 + ); 2112 + } 2113 + 2114 + let creationDate: Date | null = null; 2115 + 2116 + // Try client-provided timestamp first (most accurate - from original file) 2117 + if (fileTimestampStr) { 2118 + const timestamp = Number.parseInt(fileTimestampStr, 10); 2119 + if (!Number.isNaN(timestamp)) { 2120 + creationDate = new Date(timestamp); 2121 + console.log( 2122 + `[Upload] Using client-provided file timestamp: ${creationDate.toISOString()}`, 2123 + ); 2124 + } 2125 + } 2126 + 2127 + // Fallback: extract from audio file metadata 2128 + if (!creationDate) { 2129 + // Save file temporarily 2130 + const tempId = crypto.randomUUID(); 2131 + const fileExtension = file.name.split(".").pop()?.toLowerCase(); 2132 + const tempFilename = `temp-${tempId}.${fileExtension}`; 2133 + const tempPath = `./uploads/${tempFilename}`; 2134 + 2135 + await Bun.write(tempPath, file); 2136 + 2137 + try { 2138 + creationDate = await extractAudioCreationDate(tempPath); 2139 + } finally { 2140 + // Clean up temp file 2141 + try { 2142 + await Bun.$`rm ${tempPath}`.quiet(); 2143 + } catch { 2144 + // Ignore cleanup errors 2145 + } 2146 + } 2147 + } 2148 + 2149 + if (!creationDate) { 2150 + return Response.json({ 2151 + detected: false, 2152 + meeting_time_id: null, 2153 + message: "Could not extract creation date from audio file", 2154 + }); 2155 + } 2156 + 2157 + // Get meeting times for this class 2158 + const meetingTimes = getMeetingTimesForClass(classId); 2159 + 2160 + if (meetingTimes.length === 0) { 2161 + return Response.json({ 2162 + detected: false, 2163 + meeting_time_id: null, 2164 + message: "No meeting times configured for this class", 2165 + }); 2166 + } 2167 + 2168 + // Find matching meeting time based on day of week 2169 + const matchedId = findMatchingMeetingTime( 2170 + creationDate, 2171 + meetingTimes, 2172 + ); 2173 + 2174 + if (matchedId) { 2175 + const dayName = getDayName(creationDate); 2176 + return Response.json({ 2177 + detected: true, 2178 + meeting_time_id: matchedId, 2179 + day: dayName, 2180 + date: creationDate.toISOString(), 2181 + }); 2182 + } 2183 + 2184 + const dayName = getDayName(creationDate); 2185 + return Response.json({ 2186 + detected: false, 2187 + meeting_time_id: null, 2188 + day: dayName, 2189 + date: creationDate.toISOString(), 2190 + message: `No meeting time matches ${dayName}`, 2191 + }); 2192 + } catch (error) { 2193 + return handleError(error); 2194 + } 2195 + }, 2196 + }, 2085 2197 "/api/transcriptions": { 2086 2198 GET: async (req) => { 2087 2199 try { ··· 2294 2406 const uploadDir = "./uploads"; 2295 2407 await Bun.write(`${uploadDir}/${filename}`, file); 2296 2408 2409 + // Auto-detect meeting time from audio metadata if class provided and no meeting_time_id 2410 + let finalMeetingTimeId = meetingTimeId; 2411 + if (classId && !meetingTimeId) { 2412 + try { 2413 + // Extract creation date from audio file 2414 + const creationDate = await extractAudioCreationDate( 2415 + `${uploadDir}/${filename}`, 2416 + ); 2417 + 2418 + if (creationDate) { 2419 + // Get meeting times for this class 2420 + const meetingTimes = getMeetingTimesForClass(classId); 2421 + 2422 + if (meetingTimes.length > 0) { 2423 + // Find matching meeting time based on day of week 2424 + const matchedId = findMatchingMeetingTime( 2425 + creationDate, 2426 + meetingTimes, 2427 + ); 2428 + 2429 + if (matchedId) { 2430 + finalMeetingTimeId = matchedId; 2431 + const dayName = getDayName(creationDate); 2432 + console.log( 2433 + `[Upload] Auto-detected meeting time for ${dayName} (${creationDate.toISOString()}) -> ${matchedId}`, 2434 + ); 2435 + } else { 2436 + const dayName = getDayName(creationDate); 2437 + console.log( 2438 + `[Upload] No meeting time matches ${dayName}, leaving unassigned`, 2439 + ); 2440 + } 2441 + } 2442 + } 2443 + } catch (error) { 2444 + // Non-fatal: just log and continue without auto-detection 2445 + console.warn( 2446 + "[Upload] Failed to auto-detect meeting time:", 2447 + error instanceof Error ? error.message : "Unknown error", 2448 + ); 2449 + } 2450 + } 2451 + 2297 2452 // Create database record 2298 2453 db.run( 2299 2454 "INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ··· 2301 2456 transcriptionId, 2302 2457 user.id, 2303 2458 classId, 2304 - meetingTimeId, 2459 + finalMeetingTimeId, 2305 2460 sectionId, 2306 2461 filename, 2307 2462 file.name, ··· 2315 2470 return Response.json( 2316 2471 { 2317 2472 id: transcriptionId, 2473 + meeting_time_id: finalMeetingTimeId, 2318 2474 message: "Upload successful", 2319 2475 }, 2320 2476 { status: 201 },
+55
src/lib/audio-metadata.integration.test.ts
··· 1 + import { afterAll, describe, expect, test } from "bun:test"; 2 + import { extractAudioCreationDate } from "./audio-metadata"; 3 + 4 + describe("extractAudioCreationDate (integration)", () => { 5 + const testAudioPath = "./test-audio-sample.m4a"; 6 + 7 + // Clean up test file after tests 8 + afterAll(async () => { 9 + try { 10 + await Bun.file(testAudioPath).exists().then(async (exists) => { 11 + if (exists) { 12 + await Bun.$`rm ${testAudioPath}`; 13 + } 14 + }); 15 + } catch { 16 + // Ignore cleanup errors 17 + } 18 + }); 19 + 20 + test("extracts creation date from audio file with metadata", async () => { 21 + // Create a test audio file with metadata using ffmpeg 22 + // 1 second silent audio with creation_time metadata 23 + const creationTime = "2024-01-15T14:30:00.000000Z"; 24 + 25 + // Create the file with metadata 26 + await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -metadata creation_time=${creationTime} -y ${testAudioPath}`.quiet(); 27 + 28 + const date = await extractAudioCreationDate(testAudioPath); 29 + 30 + expect(date).not.toBeNull(); 31 + expect(date).toBeInstanceOf(Date); 32 + // JavaScript Date.toISOString() uses 3 decimal places, not 6 like the input 33 + expect(date?.toISOString()).toBe("2024-01-15T14:30:00.000Z"); 34 + }); 35 + 36 + test("returns null for audio file without creation_time metadata", async () => { 37 + // Create audio file without metadata 38 + await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -y ${testAudioPath}`.quiet(); 39 + 40 + const date = await extractAudioCreationDate(testAudioPath); 41 + 42 + // Should use file modification time as fallback 43 + expect(date).not.toBeNull(); 44 + expect(date).toBeInstanceOf(Date); 45 + // Should be very recent (within last minute) 46 + const now = new Date(); 47 + const diff = now.getTime() - (date?.getTime() ?? 0); 48 + expect(diff).toBeLessThan(60000); // Less than 1 minute 49 + }); 50 + 51 + test("returns null for non-existent file", async () => { 52 + const date = await extractAudioCreationDate("./non-existent-file.m4a"); 53 + expect(date).toBeNull(); 54 + }); 55 + });
+128
src/lib/audio-metadata.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + findMatchingMeetingTime, 4 + getDayName, 5 + getDayOfWeek, 6 + meetingTimeLabelMatchesDay, 7 + } from "./audio-metadata"; 8 + 9 + describe("getDayOfWeek", () => { 10 + test("returns correct day number", () => { 11 + // January 1, 2024 is a Monday (day 1) 12 + const monday = new Date("2024-01-01T12:00:00Z"); 13 + expect(getDayOfWeek(monday)).toBe(1); 14 + 15 + // January 7, 2024 is a Sunday (day 0) 16 + const sunday = new Date("2024-01-07T12:00:00Z"); 17 + expect(getDayOfWeek(sunday)).toBe(0); 18 + 19 + // January 6, 2024 is a Saturday (day 6) 20 + const saturday = new Date("2024-01-06T12:00:00Z"); 21 + expect(getDayOfWeek(saturday)).toBe(6); 22 + }); 23 + }); 24 + 25 + describe("getDayName", () => { 26 + test("returns correct day name", () => { 27 + expect(getDayName(new Date("2024-01-01T12:00:00Z"))).toBe("Monday"); 28 + expect(getDayName(new Date("2024-01-02T12:00:00Z"))).toBe("Tuesday"); 29 + expect(getDayName(new Date("2024-01-03T12:00:00Z"))).toBe("Wednesday"); 30 + expect(getDayName(new Date("2024-01-04T12:00:00Z"))).toBe("Thursday"); 31 + expect(getDayName(new Date("2024-01-05T12:00:00Z"))).toBe("Friday"); 32 + expect(getDayName(new Date("2024-01-06T12:00:00Z"))).toBe("Saturday"); 33 + expect(getDayName(new Date("2024-01-07T12:00:00Z"))).toBe("Sunday"); 34 + }); 35 + }); 36 + 37 + describe("meetingTimeLabelMatchesDay", () => { 38 + test("matches full day names", () => { 39 + expect(meetingTimeLabelMatchesDay("Monday Lecture", "Monday")).toBe(true); 40 + expect(meetingTimeLabelMatchesDay("Tuesday Lab", "Tuesday")).toBe(true); 41 + expect(meetingTimeLabelMatchesDay("Wednesday Discussion", "Wednesday")).toBe( 42 + true, 43 + ); 44 + }); 45 + 46 + test("matches 3-letter abbreviations", () => { 47 + expect(meetingTimeLabelMatchesDay("Mon Lecture", "Monday")).toBe(true); 48 + expect(meetingTimeLabelMatchesDay("Tue Lab", "Tuesday")).toBe(true); 49 + expect(meetingTimeLabelMatchesDay("Wed Discussion", "Wednesday")).toBe( 50 + true, 51 + ); 52 + expect(meetingTimeLabelMatchesDay("Thu Seminar", "Thursday")).toBe(true); 53 + expect(meetingTimeLabelMatchesDay("Fri Workshop", "Friday")).toBe(true); 54 + expect(meetingTimeLabelMatchesDay("Sat Review", "Saturday")).toBe(true); 55 + expect(meetingTimeLabelMatchesDay("Sun Study", "Sunday")).toBe(true); 56 + }); 57 + 58 + test("is case insensitive", () => { 59 + expect(meetingTimeLabelMatchesDay("MONDAY LECTURE", "Monday")).toBe(true); 60 + expect(meetingTimeLabelMatchesDay("monday lecture", "Monday")).toBe(true); 61 + expect(meetingTimeLabelMatchesDay("MoNdAy LeCTuRe", "Monday")).toBe(true); 62 + }); 63 + 64 + test("does not match wrong days", () => { 65 + expect(meetingTimeLabelMatchesDay("Monday Lecture", "Tuesday")).toBe(false); 66 + expect(meetingTimeLabelMatchesDay("Wednesday Lab", "Thursday")).toBe(false); 67 + expect(meetingTimeLabelMatchesDay("Lecture Hall A", "Monday")).toBe(false); 68 + }); 69 + 70 + test("handles labels without day names", () => { 71 + expect(meetingTimeLabelMatchesDay("Lecture", "Monday")).toBe(false); 72 + expect(meetingTimeLabelMatchesDay("Lab Session", "Tuesday")).toBe(false); 73 + expect(meetingTimeLabelMatchesDay("Section A", "Wednesday")).toBe(false); 74 + }); 75 + }); 76 + 77 + describe("findMatchingMeetingTime", () => { 78 + const meetingTimes = [ 79 + { id: "mt1", label: "Monday Lecture" }, 80 + { id: "mt2", label: "Wednesday Discussion" }, 81 + { id: "mt3", label: "Friday Lab" }, 82 + ]; 83 + 84 + test("finds correct meeting time for full day name", () => { 85 + const monday = new Date("2024-01-01T12:00:00Z"); // Monday 86 + expect(findMatchingMeetingTime(monday, meetingTimes)).toBe("mt1"); 87 + 88 + const wednesday = new Date("2024-01-03T12:00:00Z"); // Wednesday 89 + expect(findMatchingMeetingTime(wednesday, meetingTimes)).toBe("mt2"); 90 + 91 + const friday = new Date("2024-01-05T12:00:00Z"); // Friday 92 + expect(findMatchingMeetingTime(friday, meetingTimes)).toBe("mt3"); 93 + }); 94 + 95 + test("finds correct meeting time for abbreviated day name", () => { 96 + const abbrevMeetingTimes = [ 97 + { id: "mt1", label: "Mon Lecture" }, 98 + { id: "mt2", label: "Wed Discussion" }, 99 + { id: "mt3", label: "Fri Lab" }, 100 + ]; 101 + 102 + const monday = new Date("2024-01-01T12:00:00Z"); 103 + expect(findMatchingMeetingTime(monday, abbrevMeetingTimes)).toBe("mt1"); 104 + }); 105 + 106 + test("returns null when no match found", () => { 107 + const tuesday = new Date("2024-01-02T12:00:00Z"); // Tuesday 108 + expect(findMatchingMeetingTime(tuesday, meetingTimes)).toBe(null); 109 + 110 + const saturday = new Date("2024-01-06T12:00:00Z"); // Saturday 111 + expect(findMatchingMeetingTime(saturday, meetingTimes)).toBe(null); 112 + }); 113 + 114 + test("returns null for empty meeting times", () => { 115 + const monday = new Date("2024-01-01T12:00:00Z"); 116 + expect(findMatchingMeetingTime(monday, [])).toBe(null); 117 + }); 118 + 119 + test("returns first match when multiple matches exist", () => { 120 + const duplicateMeetingTimes = [ 121 + { id: "mt1", label: "Monday Lecture" }, 122 + { id: "mt2", label: "Monday Lab" }, 123 + ]; 124 + 125 + const monday = new Date("2024-01-01T12:00:00Z"); 126 + expect(findMatchingMeetingTime(monday, duplicateMeetingTimes)).toBe("mt1"); 127 + }); 128 + });
+144
src/lib/audio-metadata.ts
··· 1 + import { $ } from "bun"; 2 + 3 + /** 4 + * Extracts creation date from audio file metadata using ffprobe 5 + * Falls back to file birth time (original creation) if no metadata found 6 + * @param filePath Path to audio file 7 + * @returns Date object or null if not found 8 + */ 9 + export async function extractAudioCreationDate( 10 + filePath: string, 11 + ): Promise<Date | null> { 12 + try { 13 + // Use ffprobe to extract creation_time metadata 14 + // -v quiet: suppress verbose output 15 + // -print_format json: output as JSON 16 + // -show_entries format_tags: show all tags to search for date fields 17 + const result = 18 + await $`ffprobe -v quiet -print_format json -show_entries format_tags ${filePath}`.text(); 19 + 20 + const metadata = JSON.parse(result); 21 + const tags = metadata?.format?.tags || {}; 22 + 23 + // Try multiple metadata fields that might contain creation date 24 + const dateFields = [ 25 + tags.creation_time, // Standard creation_time 26 + tags.date, // Common date field 27 + tags.DATE, // Uppercase variant 28 + tags.year, // Year field 29 + tags.YEAR, // Uppercase variant 30 + tags["com.apple.quicktime.creationdate"], // Apple QuickTime 31 + tags.TDRC, // ID3v2 recording time 32 + tags.TDRL, // ID3v2 release time 33 + ]; 34 + 35 + for (const dateField of dateFields) { 36 + if (dateField) { 37 + const date = new Date(dateField); 38 + if (!Number.isNaN(date.getTime())) { 39 + console.log( 40 + `[AudioMetadata] Extracted creation date from metadata: ${date.toISOString()} from ${filePath}`, 41 + ); 42 + return date; 43 + } 44 + } 45 + } 46 + 47 + // Fallback: use file birth time (original creation time on filesystem) 48 + // This preserves the original file creation date better than mtime 49 + console.log( 50 + `[AudioMetadata] No creation_time metadata found, using file birth time`, 51 + ); 52 + const file = Bun.file(filePath); 53 + const stat = await file.stat(); 54 + const date = new Date(stat.birthtime || stat.mtime); 55 + console.log( 56 + `[AudioMetadata] Using file birth time: ${date.toISOString()} from ${filePath}`, 57 + ); 58 + return date; 59 + } catch (error) { 60 + console.error( 61 + `[AudioMetadata] Failed to extract metadata from ${filePath}:`, 62 + error instanceof Error ? error.message : "Unknown error", 63 + ); 64 + return null; 65 + } 66 + } 67 + 68 + /** 69 + * Gets day of week from a date (0 = Sunday, 6 = Saturday) 70 + */ 71 + export function getDayOfWeek(date: Date): number { 72 + return date.getDay(); 73 + } 74 + 75 + /** 76 + * Gets day name from a date 77 + */ 78 + export function getDayName(date: Date): string { 79 + const days = [ 80 + "Sunday", 81 + "Monday", 82 + "Tuesday", 83 + "Wednesday", 84 + "Thursday", 85 + "Friday", 86 + "Saturday", 87 + ]; 88 + return days[date.getDay()] || "Unknown"; 89 + } 90 + 91 + /** 92 + * Checks if a meeting time label matches a specific day 93 + * Labels like "Monday Lecture", "Tuesday Lab", "Wed Discussion" should match 94 + */ 95 + export function meetingTimeLabelMatchesDay( 96 + label: string, 97 + dayName: string, 98 + ): boolean { 99 + const lowerLabel = label.toLowerCase(); 100 + const lowerDay = dayName.toLowerCase(); 101 + 102 + // Check for full day name 103 + if (lowerLabel.includes(lowerDay)) { 104 + return true; 105 + } 106 + 107 + // Check for 3-letter abbreviations 108 + const abbrev = dayName.slice(0, 3).toLowerCase(); 109 + if (lowerLabel.includes(abbrev)) { 110 + return true; 111 + } 112 + 113 + return false; 114 + } 115 + 116 + /** 117 + * Finds the best matching meeting time for a given date 118 + * @param date Date from audio metadata 119 + * @param meetingTimes Available meeting times for the class 120 + * @returns Meeting time ID or null if no match 121 + */ 122 + export function findMatchingMeetingTime( 123 + date: Date, 124 + meetingTimes: Array<{ id: string; label: string }>, 125 + ): string | null { 126 + const dayName = getDayName(date); 127 + 128 + // Find meeting time that matches the day 129 + const match = meetingTimes.find((mt) => 130 + meetingTimeLabelMatchesDay(mt.label, dayName), 131 + ); 132 + 133 + if (match) { 134 + console.log( 135 + `[AudioMetadata] Matched ${dayName} to meeting time: ${match.label}`, 136 + ); 137 + return match.id; 138 + } 139 + 140 + console.log( 141 + `[AudioMetadata] No meeting time found matching ${dayName} in available options: ${meetingTimes.map((mt) => mt.label).join(", ")}`, 142 + ); 143 + return null; 144 + }