A Chrome extension that scrobbles NTS Radio tracks to teal.fm
at main 5.7 kB view raw
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}