import type { IRecordingMatch, IRelease } from "musicbrainz-api"; import { env } from "../env"; import type { ResolveHandleRes, TealfmPlayRecord } from "../types"; import AtpAgent, { Agent, CredentialSession } from "@atproto/api"; import { getAppleMusicURL } from "./musicbrainz"; const agent = new AtpAgent({ service: env.PDS, }); await agent.login({ identifier: env.HANDLE, password: env.APP_PASSWORD, }); async function _getDID(handle: string) { const resolution: ResolveHandleRes = (await (await fetch( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, )).json()) as ResolveHandleRes; if (resolution.error) throw new Error("Failed to resolve handle to DID"); return resolution.did!; } async function _getPDSUrl(did: string) { let services; if (did.startsWith("did:plc:")) { const resolution = (await (await fetch( `https://plc.directory/${did}`, )).json()) as any; if (resolution.message) { throw new Error("Failed to resolve resolve PDS URL"); } services = resolution.service; } else if (did.startsWith("did:web:")) { const domain = did.replace("did:web:", ""); const resolution = (await (await fetch( `${domain}/.well-known/did.json`, )).json()) as any; services = resolution.service; } return services.find((service: any) => service.id == "#atproto_pds") .serviceEndpoint as string; } export async function getLastTimestamp() { const did = await _getDID(env.HANDLE); const pdsURL = await _getPDSUrl(did); const listRecords = (await (await fetch( `${pdsURL}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=fm.teal.alpha.feed.play&limit=1&reverse=false`, )).json()) as { records?: { uri: string; cid: string; value: TealfmPlayRecord; }[]; }; if (!listRecords.records || !listRecords.records[0]) { throw new Error("Unable to find latest teal.fm play record"); } const latest = listRecords.records[0].value; if (!latest.playedTime) { throw new Error("Missing timestamp from latest teal.fm play record"); } return latest.playedTime; } export async function constructRecord( timestamp: string, trackName: string, recording: IRecordingMatch, release: IRelease, originId: string | null, ) { let originUrl: string | undefined = `https://music.apple.com/us/song/${originId}`; if (!originId) { const remoteOriginUrl = await getAppleMusicURL(recording); if (!remoteOriginUrl) { originUrl = remoteOriginUrl; } else { originUrl = undefined; } } const record: TealfmPlayRecord = { isrc: (recording.isrcs || [])[0], $type: "fm.teal.alpha.feed.play", artists: recording["artist-credit"]?.map((credit) => ({ artistMbId: credit.artist.id, artistName: credit.name, })) ?? [], duration: Math.floor(recording.length / 1000) || undefined, originUrl, trackName, playedTime: timestamp, releaseMbId: release.id, releaseName: release.title, recordingMbId: recording.id, submissionClientAgent: "manual/unknown", musicServiceBaseDomain: "music.apple.com", }; return record; } export async function createRecord(record: TealfmPlayRecord) { const maxRetries = 5; let lastError: Error | undefined; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const creation = await agent.com.atproto.repo.createRecord({ repo: agent.assertDid, collection: "fm.teal.alpha.feed.play", record, }); return creation; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); console.error( `Attempt ${attempt}/${maxRetries} failed:`, lastError.message, ); if (attempt < maxRetries) { // Exponential backoff: 1s, 2s, 4s, 8s const delayMs = Math.pow(2, attempt - 1) * 1000; console.log(`Retrying in ${delayMs}ms...`); await new Promise((resolve) => setTimeout(resolve, delayMs)); } } } console.error(`Failed to create record after ${maxRetries} attempts`); process.exit(1); }