A Chrome extension that scrobbles NTS Radio tracks to teal.fm
1// Background service worker
2
3importScripts("atproto.js");
4importScripts("musicbrainz.js");
5
6const atproto = new ATProtoClient();
7const musicbrainz = new MusicBrainzClient();
8let scrobbleQueue = [];
9let pendingScrobbleTimeout = null;
10let currentTrack = null;
11let recentTracks = [];
12
13// Initialize on install
14chrome.runtime.onInstalled.addListener(async () => {
15 console.log("NTS Radio Scrobbler installed");
16 await atproto.loadSession();
17 await loadRecentTracks();
18});
19
20// Load session on startup
21chrome.runtime.onStartup.addListener(async () => {
22 await atproto.loadSession();
23 await loadRecentTracks();
24});
25
26// Listen for messages from content script
27chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
28 if (message.type === "NEW_TRACK") {
29 handleNewTrack(message.data);
30 sendResponse({ received: true });
31 } else if (message.type === "LOGIN") {
32 handleLogin(message.data)
33 .then(sendResponse)
34 .catch((error) => sendResponse({ error: error.message }));
35 return true; // Keep channel open for async response
36 } else if (message.type === "LOGOUT") {
37 handleLogout()
38 .then(sendResponse)
39 .catch((error) => sendResponse({ error: error.message }));
40 return true;
41 } else if (message.type === "GET_AUTH_STATUS") {
42 sendResponse({ authenticated: atproto.isAuthenticated() });
43 } else if (message.type === "GET_CURRENT_TRACK") {
44 sendResponse({ track: currentTrack });
45 } else if (message.type === "GET_RECENT_TRACKS") {
46 sendResponse({ tracks: recentTracks });
47 }
48});
49
50async function handleNewTrack(trackInfo) {
51 // Store current track
52 currentTrack = trackInfo;
53
54 // Notify popup of track update
55 chrome.runtime.sendMessage({ type: 'TRACK_UPDATE' }).catch(() => {
56 // Ignore errors if popup is not open
57 });
58
59 if (!atproto.isAuthenticated()) {
60 console.log("Not authenticated - skipping scrobble");
61 return;
62 }
63
64 // Check if auto-scrobble is enabled
65 const settings = await chrome.storage.local.get("autoScrobble");
66 if (settings.autoScrobble === false) {
67 return;
68 }
69
70 // Cancel any pending scrobble
71 if (pendingScrobbleTimeout) {
72 clearTimeout(pendingScrobbleTimeout);
73 pendingScrobbleTimeout = null;
74 }
75
76 // Wait 30 seconds before scrobbling
77 pendingScrobbleTimeout = setTimeout(async () => {
78 try {
79 // Check for duplicates
80 if (isDuplicate(trackInfo)) {
81 console.log('Skipping duplicate:', `${trackInfo.artist} - ${trackInfo.track}`);
82 return;
83 }
84
85 // Fetch MusicBrainz metadata
86 const mbInfo = await musicbrainz.searchRecording(trackInfo.artist, trackInfo.track);
87 if (mbInfo) {
88 trackInfo.musicbrainz = mbInfo;
89 }
90
91 await atproto.createScrobbleRecord(trackInfo);
92 console.log('✓ Scrobbled:', `${trackInfo.artist} - ${trackInfo.track}`);
93
94 // Add to recent tracks
95 addToRecentTracks(trackInfo);
96
97 // Notify popup of scrobble success
98 chrome.runtime.sendMessage({ type: 'SCROBBLE_SUCCESS' }).catch(() => {
99 // Ignore errors if popup is not open
100 });
101
102 // Show notification
103 chrome.notifications.create({
104 type: "basic",
105 iconUrl: "icons/icon48.png",
106 title: "Track Scrobbled",
107 message: `${trackInfo.artist} - ${trackInfo.track}`,
108 });
109 } catch (error) {
110 console.error("Failed to scrobble:", error);
111
112 // If token expired, try to refresh and retry
113 if (error.message.includes("ExpiredToken") || error.message.includes("token")) {
114 try {
115 console.log("Token expired, refreshing...");
116 await atproto.refreshSession();
117 console.log("Token refreshed, retrying scrobble...");
118 await atproto.createScrobbleRecord(trackInfo);
119 console.log('✓ Scrobbled (after refresh):', `${trackInfo.artist} - ${trackInfo.track}`);
120
121 // Add to recent tracks
122 addToRecentTracks(trackInfo);
123
124 // Notify popup of scrobble success
125 chrome.runtime.sendMessage({ type: 'SCROBBLE_SUCCESS' }).catch(() => {
126 // Ignore errors if popup is not open
127 });
128
129 chrome.notifications.create({
130 type: "basic",
131 iconUrl: "icons/icon48.png",
132 title: "Track Scrobbled",
133 message: `${trackInfo.artist} - ${trackInfo.track}`,
134 });
135 } catch (refreshError) {
136 console.error("Failed after token refresh:", refreshError);
137 }
138 }
139 } finally {
140 pendingScrobbleTimeout = null;
141 }
142 }, 30000); // 30 seconds
143}
144
145async function handleLogin({ identifier, password, pdsUrl }) {
146 try {
147 const session = await atproto.login(identifier, password, pdsUrl);
148 return { success: true, session };
149 } catch (error) {
150 return { success: false, error: error.message };
151 }
152}
153
154async function handleLogout() {
155 await atproto.logout();
156 return { success: true };
157}
158
159async function loadRecentTracks() {
160 const data = await chrome.storage.local.get('recentTracks');
161 if (data.recentTracks) {
162 recentTracks = data.recentTracks;
163 }
164}
165
166async function saveRecentTracks() {
167 await chrome.storage.local.set({ recentTracks });
168}
169
170function isDuplicate(trackInfo) {
171 // Check if this track was recently scrobbled
172 return recentTracks.some(track =>
173 track.artist === trackInfo.artist &&
174 track.track === trackInfo.track
175 );
176}
177
178function addToRecentTracks(trackInfo) {
179 // Add to recent tracks
180 recentTracks.unshift({
181 artist: trackInfo.artist,
182 track: trackInfo.track,
183 timestamp: Date.now()
184 });
185
186 // Keep only last 5
187 recentTracks = recentTracks.slice(0, 5);
188
189 // Save to storage
190 saveRecentTracks();
191}