A tool to scrobble tracks from your Apple Music data export to Teal.fm.
1import type { IRecordingMatch, IRelease } from "musicbrainz-api";
2import { env } from "../env";
3import type { ResolveHandleRes, TealfmPlayRecord } from "../types";
4import AtpAgent, { Agent, CredentialSession } from "@atproto/api";
5import { getAppleMusicURL } from "./musicbrainz";
6
7const agent = new AtpAgent({
8 service: env.PDS,
9});
10
11await agent.login({
12 identifier: env.HANDLE,
13 password: env.APP_PASSWORD,
14});
15
16async function _getDID(handle: string) {
17 const resolution: ResolveHandleRes = (await (await fetch(
18 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`,
19 )).json()) as ResolveHandleRes;
20 if (resolution.error) throw new Error("Failed to resolve handle to DID");
21
22 return resolution.did!;
23}
24
25async function _getPDSUrl(did: string) {
26 let services;
27
28 if (did.startsWith("did:plc:")) {
29 const resolution = (await (await fetch(
30 `https://plc.directory/${did}`,
31 )).json()) as any;
32 if (resolution.message) {
33 throw new Error("Failed to resolve resolve PDS URL");
34 }
35
36 services = resolution.service;
37 } else if (did.startsWith("did:web:")) {
38 const domain = did.replace("did:web:", "");
39 const resolution = (await (await fetch(
40 `${domain}/.well-known/did.json`,
41 )).json()) as any;
42
43 services = resolution.service;
44 }
45
46 return services.find((service: any) => service.id == "#atproto_pds")
47 .serviceEndpoint as string;
48}
49
50export async function getLastTimestamp() {
51 const did = await _getDID(env.HANDLE);
52 const pdsURL = await _getPDSUrl(did);
53
54 const listRecords = (await (await fetch(
55 `${pdsURL}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=fm.teal.alpha.feed.play&limit=1&reverse=false`,
56 )).json()) as {
57 records?: {
58 uri: string;
59 cid: string;
60 value: TealfmPlayRecord;
61 }[];
62 };
63
64 if (!listRecords.records || !listRecords.records[0]) {
65 throw new Error("Unable to find latest teal.fm play record");
66 }
67
68 const latest = listRecords.records[0].value;
69 if (!latest.playedTime) {
70 throw new Error("Missing timestamp from latest teal.fm play record");
71 }
72
73 return latest.playedTime;
74}
75
76export async function constructRecord(
77 timestamp: string,
78 trackName: string,
79 recording: IRecordingMatch,
80 release: IRelease,
81 originId: string | null,
82) {
83 let originUrl: string | undefined =
84 `https://music.apple.com/us/song/${originId}`;
85 if (!originId) {
86 const remoteOriginUrl = await getAppleMusicURL(recording);
87 if (!remoteOriginUrl) {
88 originUrl = remoteOriginUrl;
89 } else {
90 originUrl = undefined;
91 }
92 }
93
94 const record: TealfmPlayRecord = {
95 isrc: (recording.isrcs || [])[0],
96 $type: "fm.teal.alpha.feed.play",
97 artists: recording["artist-credit"]?.map((credit) => ({
98 artistMbId: credit.artist.id,
99 artistName: credit.name,
100 })) ?? [],
101 duration: Math.floor(recording.length / 1000) || undefined,
102 originUrl,
103 trackName,
104 playedTime: timestamp,
105 releaseMbId: release.id,
106 releaseName: release.title,
107 recordingMbId: recording.id,
108 submissionClientAgent: "manual/unknown",
109 musicServiceBaseDomain: "music.apple.com",
110 };
111
112 return record;
113}
114
115export async function createRecord(record: TealfmPlayRecord) {
116 const maxRetries = 5;
117 let lastError: Error | undefined;
118
119 for (let attempt = 1; attempt <= maxRetries; attempt++) {
120 try {
121 const creation = await agent.com.atproto.repo.createRecord({
122 repo: agent.assertDid,
123 collection: "fm.teal.alpha.feed.play",
124 record,
125 });
126 return creation;
127 } catch (error) {
128 lastError = error instanceof Error
129 ? error
130 : new Error(String(error));
131 console.error(
132 `Attempt ${attempt}/${maxRetries} failed:`,
133 lastError.message,
134 );
135
136 if (attempt < maxRetries) {
137 // Exponential backoff: 1s, 2s, 4s, 8s
138 const delayMs = Math.pow(2, attempt - 1) * 1000;
139 console.log(`Retrying in ${delayMs}ms...`);
140 await new Promise((resolve) => setTimeout(resolve, delayMs));
141 }
142 }
143 }
144
145 console.error(`Failed to create record after ${maxRetries} attempts`);
146 process.exit(1);
147}