// AT Protocol integration module class ATProtoClient { constructor() { this.session = null; this.pds = "https://bsky.social"; } async login(identifier, password, pdsUrl) { try { // Update PDS URL if provided if (pdsUrl) { this.pds = pdsUrl; await chrome.storage.local.set({ pdsUrl }); } const response = await fetch( `${this.pds}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ identifier, password, }), } ); if (!response.ok) { throw new Error(`Login failed: ${response.statusText}`); } this.session = await response.json(); // Store session await chrome.storage.local.set({ atprotoSession: this.session }); return this.session; } catch (error) { console.error("AT Proto login error:", error); throw error; } } async loadSession() { const data = await chrome.storage.local.get(["atprotoSession", "pdsUrl"]); if (data.pdsUrl) { this.pds = data.pdsUrl; } if (data.atprotoSession) { this.session = data.atprotoSession; return true; } return false; } async logout() { this.session = null; await chrome.storage.local.remove("atprotoSession"); } async createScrobbleRecord(trackInfo) { if (!this.session) { throw new Error("Not authenticated"); } // Build the teal.fm play record const record = { $type: "fm.teal.alpha.feed.play", trackName: trackInfo.track, playedTime: new Date(trackInfo.timestamp).toISOString(), submissionClientAgent: "nts-teal-piper/1.0.0", musicServiceBaseDomain: "nts.live", }; // Add MusicBrainz metadata if available if (trackInfo.musicbrainz) { const mb = trackInfo.musicbrainz; // Prefer MusicBrainz track name if (mb.trackName) record.trackName = mb.trackName; // Recording metadata if (mb.recordingMbId) record.recordingMbId = mb.recordingMbId; if (mb.duration) record.duration = mb.duration; // Artist information - use artists array if available if (mb.artists && mb.artists.length > 0) { record.artists = mb.artists; } // Release information if (mb.releaseName) record.releaseName = mb.releaseName; if (mb.releaseMbId) record.releaseMbId = mb.releaseMbId; if (mb.isrc) record.isrc = mb.isrc; } // Fallback: if no MusicBrainz artists, use basic artist info if (!record.artists && trackInfo.artist) { record.artists = [ { artistName: trackInfo.artist, }, ]; } // Add optional fields if (trackInfo.originUrl) { record.originUrl = trackInfo.originUrl; } try { const response = await fetch( `${this.pds}/xrpc/com.atproto.repo.createRecord`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.session.accessJwt}`, }, body: JSON.stringify({ repo: this.session.did, collection: "fm.teal.alpha.feed.play", record, }), } ); if (!response.ok) { const errorText = await response.text(); throw new Error( `Failed to create record: ${response.statusText} - ${errorText}` ); } return await response.json(); } catch (error) { console.error("Error creating scrobble record:", error); throw error; } } async refreshSession() { if (!this.session?.refreshJwt) { throw new Error("No refresh token available"); } try { const response = await fetch( `${this.pds}/xrpc/com.atproto.server.refreshSession`, { method: "POST", headers: { Authorization: `Bearer ${this.session.refreshJwt}`, }, } ); if (!response.ok) { throw new Error("Session refresh failed"); } this.session = await response.json(); await chrome.storage.local.set({ atprotoSession: this.session }); return this.session; } catch (error) { console.error("Session refresh error:", error); throw error; } } isAuthenticated() { return !!this.session; } } // Make available to other scripts if (typeof module !== "undefined" && module.exports) { module.exports = ATProtoClient; }