Malachite is a tool to import your Last.fm and Spotify listening history to the AT Protocol network using the
fm.teal.alpha.feed.play lexicon.
malachite
scrobbles
importer
atproto
music
1/**
2 * Utility functions for the Last.fm importer
3 */
4import type { Config } from '../types.js';
5
6/**
7 * Get user's locale from environment or default to system locale
8 */
9function getUserLocale(): string {
10 // Try to get locale from environment variables
11 const envLang = process.env.LANG?.split('.')[0] ||
12 process.env.LC_ALL?.split('.')[0];
13
14 // Filter out invalid locales (like "C" or "POSIX")
15 if (envLang && envLang !== 'C' && envLang !== 'POSIX') {
16 // FIX: Replace underscore with hyphen to satisfy BCP 47 (e.g., en_GB -> en-GB) [cite: 413, 414]
17 return envLang.replace('_', '-');
18 }
19
20 // Try system locale
21 try {
22 const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale;
23 if (systemLocale && systemLocale !== 'C') {
24 return systemLocale;
25 }
26 } catch (e) {
27 // Ignore errors
28 }
29
30 // Default to UK format
31 return 'en-GB';
32}
33
34/**
35 * Format a date in a locale-aware way
36 * @param date - Date string or Date object
37 * @param includeTime - Whether to include time in the output
38 * @returns Formatted date string
39 */
40export function formatDate(date: string | Date, includeTime: boolean = false): string {
41 const dateObj = typeof date === 'string' ? new Date(date) : date;
42 const locale = getUserLocale();
43
44 if (includeTime) {
45 return dateObj.toLocaleString(locale, {
46 year: 'numeric',
47 month: 'short',
48 day: 'numeric',
49 hour: '2-digit',
50 minute: '2-digit',
51 timeZoneName: 'short'
52 });
53 } else {
54 return dateObj.toLocaleDateString(locale, {
55 year: 'numeric',
56 month: 'short',
57 day: 'numeric'
58 });
59 }
60}
61
62/**
63 * Format a date range in a locale-aware way
64 * @param startDate - Start date
65 * @param endDate - End date
66 * @returns Formatted date range string
67 */
68export function formatDateRange(startDate: string | Date, endDate: string | Date): string {
69 return `${formatDate(startDate)} to ${formatDate(endDate)}`;
70}
71
72/**
73 * Format duration in human-readable format
74 */
75export function formatDuration(milliseconds: number): string {
76 const seconds = Math.floor(milliseconds / 1000);
77 const minutes = Math.floor(seconds / 60);
78 const hours = Math.floor(minutes / 60);
79
80 if (hours > 0) {
81 const mins = minutes % 60;
82 return `${hours}h ${mins}m`;
83 } else if (minutes > 0) {
84 const secs = seconds % 60;
85 return `${minutes}m ${secs}s`;
86 } else {
87 return `${seconds}s`;
88 }
89}
90
91/**
92 * Calculate optimal batch size based on total records and rate limits
93 * Uses a logarithmic scaling approach to balance throughput with API safety
94 */
95export function calculateOptimalBatchSize(totalRecords: number, batchDelay: number, config: Config): number {
96 const {
97 MIN_RECORDS_FOR_SCALING,
98 BASE_BATCH_SIZE,
99 MAX_BATCH_SIZE,
100 SCALING_FACTOR,
101 DEFAULT_BATCH_DELAY
102 } = config;
103
104 const delay = batchDelay || DEFAULT_BATCH_DELAY;
105
106 // For very small datasets, use minimal batches
107 if (totalRecords <= 50) {
108 return 3;
109 }
110
111 // For small to medium datasets, use conservative batching
112 if (totalRecords <= MIN_RECORDS_FOR_SCALING) {
113 return BASE_BATCH_SIZE;
114 }
115
116 // Logarithmic scaling
117 const logScale = Math.log2(totalRecords / MIN_RECORDS_FOR_SCALING);
118 const calculatedSize = Math.floor(BASE_BATCH_SIZE + (logScale * SCALING_FACTOR));
119
120 // Apply maximum cap
121 let optimalSize = Math.min(calculatedSize, MAX_BATCH_SIZE);
122
123 // Adjust based on batch delay
124 if (delay < 1500 && optimalSize > 15) {
125 optimalSize = Math.floor(optimalSize * 0.75);
126 }
127
128 // Ensure batch size is at least 3
129 return Math.max(3, optimalSize);
130}
131
132/**
133 * Logs rate limiting and batching information to the console.
134 * Note: This function cannot import log from logger.ts to avoid circular dependencies,
135 * so it uses console.log directly. The CLI controls the log level, so this output
136 * is appropriately controlled.
137 */
138export function showRateLimitInfo(
139 totalRecords: number,
140 batchSize: number,
141 batchDelay: number,
142 estimatedDays: number,
143 dailyLimit: number
144): void {
145 console.log('\n📊 Rate Limiting Information:');
146 console.log(` Total records: ${totalRecords.toLocaleString()}`);
147 console.log(` Daily limit: ${dailyLimit.toLocaleString()} records/day`);
148 console.log(` Estimated duration: ${estimatedDays} day${estimatedDays > 1 ? 's' : ''}`);
149 console.log(` Batch size: ${batchSize} records`);
150 console.log(` Batch delay: ${(batchDelay / 1000).toFixed(1)}s`);
151
152 if (estimatedDays > 1) {
153 console.log('\n The import will automatically pause between days.');
154 console.log(' You can safely close and restart the importer - it will resume from where it left off.');
155 }
156 console.log('');
157}