A tool to scrobble tracks from your Apple Music data export to Teal.fm.
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}