A tool to scrobble tracks from your Apple Music data export to Teal.fm.
at main 3.5 kB view raw
1import type { 2 AppleMusicContainer, 3 AppleMusicPlaybackEvent, 4 AppleMusicTrackHistory, 5} from "../types"; 6import * as Papaparse from "papaparse"; 7import { existsSync } from "fs"; 8 9const trackCache = new Map< 10 string, 11 { originId: string | null; artist: string } 12>(); 13 14type OverridesMap = Record<string, string>; 15let overrides: OverridesMap = {}; 16if (existsSync("artist_overrides.json")) { 17 const rawOverrides = await Bun.file("artist_overrides.json") 18 .json() as OverridesMap; 19 overrides = Object.fromEntries( 20 Object.entries(rawOverrides).map(( 21 [key, value], 22 ) => [key.toLowerCase(), value]), 23 ); 24} 25 26export async function parseCSV(dataExportPath: string, fileName: string) { 27 const cachedPath = `cache/${fileName}.json`; 28 if (existsSync(cachedPath)) { 29 return await Bun.file(cachedPath).json(); 30 } 31 const csv = await Bun.file(`${dataExportPath}/${fileName}.csv`).text(); 32 33 const parsed = Papaparse.parse(csv, { 34 header: true, 35 }); 36 37 const data = parsed.data; 38 39 await Bun.write(cachedPath, JSON.stringify(data)); 40 return data; 41} 42 43export function isEligible(event: AppleMusicPlaybackEvent): boolean { 44 // Must be a play end event 45 if (event["Event Type"] !== "PLAY_END") { 46 return false; 47 } 48 49 // Check end reason - should be a natural completion or user skip to next track 50 const endReason = event["End Reason Type"]; 51 const validEndReasons = [ 52 "NATURAL_END_OF_TRACK", 53 "TRACK_SKIPPED_FORWARDS", 54 "MANUALLY_SELECTED_PLAYBACK_OF_A_DIFF_ITEM", 55 ]; 56 57 if (!validEndReasons.includes(endReason)) { 58 return false; 59 } 60 61 // Get media duration in milliseconds 62 const mediaDuration = parseInt(event["Media Duration In Milliseconds"]); 63 64 // Track must be longer than 30 seconds 65 if (isNaN(mediaDuration) || mediaDuration <= 30000) { 66 return false; 67 } 68 69 // Calculate actual play duration 70 const playDuration = parseInt(event["Play Duration Milliseconds"]); 71 72 if (isNaN(playDuration)) { 73 return false; 74 } 75 76 // Last.fm scrobbling rules: 77 // Track must be played for at least 4 minutes (240000ms) OR 50% of duration 78 const minimumDuration = Math.min(240000, mediaDuration * 0.5); 79 80 if (playDuration < minimumDuration) { 81 return false; 82 } 83 84 return true; 85} 86 87export function buildTrackCache(trackHistory: AppleMusicTrackHistory[]): void { 88 trackCache.clear(); 89 90 for (const track of trackHistory) { 91 const [artist, name] = (track["Track Description"] || "").split(" - "); 92 if (name) { 93 const normalizedName = name.toLowerCase(); 94 // Only store first occurrence (like original code) 95 if (!trackCache.has(normalizedName)) { 96 trackCache.set(normalizedName, { 97 originId: track["Track Identifier"] === "0" 98 ? null 99 : track["Track Identifier"], 100 artist: artist!, 101 }); 102 } 103 } 104 } 105} 106 107export function getExtraSongData( 108 trackName: string, 109): { 110 originId: string | null; 111 artist: string; 112} | null { 113 const normalizedTrackName = trackName.toLowerCase(); 114 115 const cached = trackCache.get(normalizedTrackName); 116 if (cached) { 117 return cached; 118 } 119 120 if (overrides[normalizedTrackName]) { 121 return { 122 originId: null, 123 artist: overrides[normalizedTrackName], 124 }; 125 } 126 127 return null; 128}