A Chrome extension that scrobbles NTS Radio tracks to teal.fm
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}