馃 distributed transcription service
thistle.dunkirk.sh
1import { $ } 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 */
9export 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 */
71export function getDayOfWeek(date: Date): number {
72 return date.getDay();
73}
74
75/**
76 * Gets day name from a date
77 */
78export 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 */
95export 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 */
122export 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}