A Chrome extension that scrobbles NTS Radio tracks to teal.fm
at main 4.6 kB view raw
1// AT Protocol integration module 2 3class ATProtoClient { 4 constructor() { 5 this.session = null; 6 this.pds = "https://bsky.social"; 7 } 8 9 async login(identifier, password, pdsUrl) { 10 try { 11 // Update PDS URL if provided 12 if (pdsUrl) { 13 this.pds = pdsUrl; 14 await chrome.storage.local.set({ pdsUrl }); 15 } 16 17 const response = await fetch( 18 `${this.pds}/xrpc/com.atproto.server.createSession`, 19 { 20 method: "POST", 21 headers: { 22 "Content-Type": "application/json", 23 }, 24 body: JSON.stringify({ 25 identifier, 26 password, 27 }), 28 } 29 ); 30 31 if (!response.ok) { 32 throw new Error(`Login failed: ${response.statusText}`); 33 } 34 35 this.session = await response.json(); 36 37 // Store session 38 await chrome.storage.local.set({ atprotoSession: this.session }); 39 40 return this.session; 41 } catch (error) { 42 console.error("AT Proto login error:", error); 43 throw error; 44 } 45 } 46 47 async loadSession() { 48 const data = await chrome.storage.local.get(["atprotoSession", "pdsUrl"]); 49 if (data.pdsUrl) { 50 this.pds = data.pdsUrl; 51 } 52 if (data.atprotoSession) { 53 this.session = data.atprotoSession; 54 return true; 55 } 56 return false; 57 } 58 59 async logout() { 60 this.session = null; 61 await chrome.storage.local.remove("atprotoSession"); 62 } 63 64 async createScrobbleRecord(trackInfo) { 65 if (!this.session) { 66 throw new Error("Not authenticated"); 67 } 68 69 // Build the teal.fm play record 70 const record = { 71 $type: "fm.teal.alpha.feed.play", 72 trackName: trackInfo.track, 73 playedTime: new Date(trackInfo.timestamp).toISOString(), 74 submissionClientAgent: "nts-teal-piper/1.0.0", 75 musicServiceBaseDomain: "nts.live", 76 }; 77 78 // Add MusicBrainz metadata if available 79 if (trackInfo.musicbrainz) { 80 const mb = trackInfo.musicbrainz; 81 82 // Prefer MusicBrainz track name 83 if (mb.trackName) record.trackName = mb.trackName; 84 85 // Recording metadata 86 if (mb.recordingMbId) record.recordingMbId = mb.recordingMbId; 87 if (mb.duration) record.duration = mb.duration; 88 89 // Artist information - use artists array if available 90 if (mb.artists && mb.artists.length > 0) { 91 record.artists = mb.artists; 92 } 93 94 // Release information 95 if (mb.releaseName) record.releaseName = mb.releaseName; 96 if (mb.releaseMbId) record.releaseMbId = mb.releaseMbId; 97 if (mb.isrc) record.isrc = mb.isrc; 98 } 99 100 // Fallback: if no MusicBrainz artists, use basic artist info 101 if (!record.artists && trackInfo.artist) { 102 record.artists = [ 103 { 104 artistName: trackInfo.artist, 105 }, 106 ]; 107 } 108 109 // Add optional fields 110 if (trackInfo.originUrl) { 111 record.originUrl = trackInfo.originUrl; 112 } 113 114 try { 115 const response = await fetch( 116 `${this.pds}/xrpc/com.atproto.repo.createRecord`, 117 { 118 method: "POST", 119 headers: { 120 "Content-Type": "application/json", 121 Authorization: `Bearer ${this.session.accessJwt}`, 122 }, 123 body: JSON.stringify({ 124 repo: this.session.did, 125 collection: "fm.teal.alpha.feed.play", 126 record, 127 }), 128 } 129 ); 130 131 if (!response.ok) { 132 const errorText = await response.text(); 133 throw new Error( 134 `Failed to create record: ${response.statusText} - ${errorText}` 135 ); 136 } 137 138 return await response.json(); 139 } catch (error) { 140 console.error("Error creating scrobble record:", error); 141 throw error; 142 } 143 } 144 145 async refreshSession() { 146 if (!this.session?.refreshJwt) { 147 throw new Error("No refresh token available"); 148 } 149 150 try { 151 const response = await fetch( 152 `${this.pds}/xrpc/com.atproto.server.refreshSession`, 153 { 154 method: "POST", 155 headers: { 156 Authorization: `Bearer ${this.session.refreshJwt}`, 157 }, 158 } 159 ); 160 161 if (!response.ok) { 162 throw new Error("Session refresh failed"); 163 } 164 165 this.session = await response.json(); 166 await chrome.storage.local.set({ atprotoSession: this.session }); 167 168 return this.session; 169 } catch (error) { 170 console.error("Session refresh error:", error); 171 throw error; 172 } 173 } 174 175 isAuthenticated() { 176 return !!this.session; 177 } 178} 179 180// Make available to other scripts 181if (typeof module !== "undefined" && module.exports) { 182 module.exports = ATProtoClient; 183}