A tool to scrobble tracks from your Apple Music data export to Teal.fm.
1import { getCache, updateCache } from "./utils/cache";
2import {
3 constructRecord,
4 createRecord,
5 getLastTimestamp,
6} from "./utils/atproto";
7import {
8 buildTrackCache,
9 getExtraSongData,
10 isEligible,
11 parseCSV,
12} from "./utils/data";
13import consola from "consola";
14import { lookupTrack } from "./utils/musicbrainz";
15import type { AppleMusicPlaybackEvent } from "./types";
16
17const logger = consola.withTag("AM -> TEAL.FM");
18
19const dataExportPath = "/Users/bryceoliver/Desktop/Apple Music Activity/";
20const playActivity = await parseCSV(
21 dataExportPath,
22 "Apple Music Play Activity",
23) as AppleMusicPlaybackEvent[];
24
25const dailyTrackHistory = await parseCSV(
26 dataExportPath,
27 "Apple Music - Play History Daily Tracks",
28);
29
30buildTrackCache(dailyTrackHistory);
31
32const lastTimestamp = new Date(await getLastTimestamp());
33logger.info("Last Teal.fm record timestamp: ", lastTimestamp.toDateString());
34
35const progress = await getCache();
36
37const eligible = playActivity.filter((event) =>
38 isEligible(event) && new Date(event["Event End Timestamp"]) > lastTimestamp
39);
40
41const notYetImported = eligible.filter((event) =>
42 !progress.some((p) =>
43 p.songName === event["Song Name"] &&
44 p.timestamp === event["Event End Timestamp"]
45 )
46);
47
48logger.info(`Importing ${notYetImported.length.toLocaleString()} tracks`);
49
50const events = notYetImported
51 .sort(
52 (a, b) =>
53 new Date(a["Event End Timestamp"]).getTime() -
54 new Date(b["Event End Timestamp"]).getTime(),
55 );
56
57const missingTrackData = [
58 ...new Set(
59 events.filter((x) => getExtraSongData(x["Song Name"]) == null).map((
60 x,
61 ) => x["Song Name"]),
62 ),
63];
64
65if (missingTrackData.length > 0) {
66 logger.warn(
67 "Missing track data! Please create an artist_overrides.json file to specify the artist for the following tracks",
68 );
69 console.log(missingTrackData.join(", "));
70 process.exit(1);
71}
72
73for (const event of events) {
74 const details = getExtraSongData(event["Song Name"])!;
75 logger.log(`Resolving track ${event["Song Name"]} by ${details.artist}..`);
76
77 const track = await lookupTrack(
78 details.artist,
79 event["Album Name"],
80 event["Song Name"],
81 )!;
82
83 if (!track) {
84 logger.error("Failed to resolve track.");
85 process.exit(1);
86 }
87
88 logger.success(
89 `Resolved track to recording "${track.recording.title}" and release "${track.release.title}"!`,
90 );
91
92 const record = await constructRecord(
93 event["Event End Timestamp"],
94 event["Song Name"],
95 track.recording,
96 track.release,
97 details.originId,
98 );
99
100 console.log(record);
101
102 await updateCache(event);
103 await createRecord(record);
104}