A tool to scrobble tracks from your Apple Music data export to Teal.fm.
at main 4.6 kB view raw
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}