diff --git a/plugins/multi-scrobbler/src/services/ListenBrainzService.ts b/plugins/multi-scrobbler/src/services/ListenBrainzService.ts index 42da2f5..b6340ed 100644 --- a/plugins/multi-scrobbler/src/services/ListenBrainzService.ts +++ b/plugins/multi-scrobbler/src/services/ListenBrainzService.ts @@ -37,171 +37,198 @@ interface ListenBrainzPlayingNowResponse { } export class ListenBrainzService extends BaseService { - getServiceName(): string { - return "ListenBrainz"; - } + getServiceName(): string { + return "ListenBrainz"; + } - protected logVerbose(...args: any[]): void { - if (currentSettings.verboseLogging) { - console.log(`[${this.getServiceName()}] Verbose:`, ...args); + protected logVerbose(...args: any[]): void { + if (currentSettings.verboseLogging) { + console.log(`[${this.getServiceName()}] Verbose:`, ...args); + } + } + + async validateCredentials(): Promise { + try { + const username = currentSettings.listenbrainzUsername; + const token = currentSettings.listenbrainzToken; + + if (!username) { + throw new Error("Username not set for ListenBrainz"); + } + + // For ListenBrainz, we don't need a token to read public data + // But we can test the connection by fetching user listens + const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`; + + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Token ${token}`; + } + + await this.makeRequest(url, { headers }); + + this.log("Credentials validation successful"); + return true; + } catch (error) { + this.logError("Credentials validation failed:", error); + return false; + } + } + + async fetchLatestScrobble(): Promise { + try { + const username = currentSettings.listenbrainzUsername; + const token = currentSettings.listenbrainzToken; + + if (!username) { + throw new Error("Username not set for ListenBrainz"); + } + + this.logVerbose("Fetching latest scrobble for user:", username); + + // First check for currently playing track + let currentlyPlaying: ListenBrainzListen | null = null; + + // Helper to normalize ListenBrainz responses which may return either + // { listens: [...] } or { payload: { listens: [...] } } + const extractListens = (resp: any): ListenBrainzListen[] | undefined => { + if (!resp) return undefined; + if (Array.isArray(resp.listens)) return resp.listens; + if (resp.payload && Array.isArray(resp.payload.listens)) + return resp.payload.listens; + if (resp.data && Array.isArray(resp.data.listens)) + return resp.data.listens; + return undefined; + }; + + try { + const playingNowUrl = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/playing-now`; + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Token ${token}`; } - } - async validateCredentials(): Promise { - try { - const username = currentSettings.listenbrainzUsername; - const token = currentSettings.listenbrainzToken; - - if (!username) { - throw new Error("Username not set for ListenBrainz"); - } - - // For ListenBrainz, we don't need a token to read public data - // But we can test the connection by fetching user listens - const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`; - - const headers: Record = {}; - if (token) { - headers["Authorization"] = `Token ${token}`; - } - - await this.makeRequest(url, { headers }); - - this.log("Credentials validation successful"); - return true; - } catch (error) { - this.logError("Credentials validation failed:", error); - return false; + // Use a permissive any type for response, then normalize with extractListens + const playingNowRaw: any = await this.makeRequest(playingNowUrl, { + headers, + }); + const playingNowListens = extractListens(playingNowRaw); + if (playingNowListens && playingNowListens.length > 0) { + currentlyPlaying = playingNowListens[0]; + currentlyPlaying.playing_now = true; } - } + } catch (error) { + this.logVerbose( + "No currently playing track or failed to fetch:", + error, + ); + } - async fetchLatestScrobble(): Promise { - try { - const username = currentSettings.listenbrainzUsername; - const token = currentSettings.listenbrainzToken; + let latestListen: ListenBrainzListen; - if (!username) { - throw new Error("Username not set for ListenBrainz"); - } + if (currentlyPlaying) { + latestListen = currentlyPlaying; + this.logVerbose("Using currently playing track"); + } else { + // Fetch recent listens + const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`; + const headers: Record = {}; + if (token) { + headers["Authorization"] = `Token ${token}`; + } - this.logVerbose("Fetching latest scrobble for user:", username); + // Fetch recent listens and normalize different response shapes + const dataRaw: any = await this.makeRequest(url, { + headers, + }); - // First check for currently playing track - let currentlyPlaying: ListenBrainzListen | null = null; - try { - const playingNowUrl = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/playing-now`; - const headers: Record = {}; - if (token) { - headers["Authorization"] = `Token ${token}`; - } + const recentListens = ((): ListenBrainzListen[] | undefined => { + if (!dataRaw) return undefined; + if (Array.isArray(dataRaw.listens)) return dataRaw.listens; + if (dataRaw.payload && Array.isArray(dataRaw.payload.listens)) + return dataRaw.payload.listens; + if (dataRaw.data && Array.isArray(dataRaw.data.listens)) + return dataRaw.data.listens; + return undefined; + })(); - const playingNowData: ListenBrainzPlayingNowResponse = - await this.makeRequest(playingNowUrl, { headers }); - if (playingNowData.listens && playingNowData.listens.length > 0) { - currentlyPlaying = playingNowData.listens[0]; - currentlyPlaying.playing_now = true; - } - } catch (error) { - this.logVerbose( - "No currently playing track or failed to fetch:", - error, - ); - } + if (!recentListens || recentListens.length === 0) { + throw new Error("No listens found"); + } - let latestListen: ListenBrainzListen; + latestListen = recentListens[0]; + this.logVerbose("Using latest completed listen"); + } - if (currentlyPlaying) { - latestListen = currentlyPlaying; - this.logVerbose("Using currently playing track"); - } else { - // Fetch recent listens - const url = `${Constants.SERVICES.listenbrainz.baseUrl}/user/${encodeURIComponent(username)}/listens?count=1`; - const headers: Record = {}; - if (token) { - headers["Authorization"] = `Token ${token}`; - } + this.logVerbose("Raw listen data:", latestListen); - const data: ListenBrainzResponse = await this.makeRequest(url, { - headers, - }); - - if (!data.listens || data.listens.length === 0) { - throw new Error("No listens found"); - } - - latestListen = data.listens[0]; - this.logVerbose("Using latest completed listen"); - } - - this.logVerbose("Raw listen data:", latestListen); - - const isNowPlaying = Boolean(latestListen.playing_now); - const trackTimestamp = + const isNowPlaying = Boolean(latestListen.playing_now); + const trackTimestamp = latestListen.listened_at || Math.floor(Date.now() / 1000); - let duration: number | null = null; - let endTime: number | null = null; + let duration: number | null = null; + let endTime: number | null = null; - // Extract duration from additional_info if available - if (latestListen.track_metadata.additional_info?.duration_ms) { - duration = Math.floor( - latestListen.track_metadata.additional_info.duration_ms / 1000, - ); - if (isNowPlaying && duration > 0) { - endTime = trackTimestamp + duration; - } - } + // Extract duration from additional_info if available + if (latestListen.track_metadata.additional_info?.duration_ms) { + duration = Math.floor( + latestListen.track_metadata.additional_info.duration_ms / 1000, + ); + if (isNowPlaying && duration > 0) { + endTime = trackTimestamp + duration; + } + } - // Try to get album art from MusicBrainz or other sources - const albumArt: string | null = null; - if (latestListen.track_metadata.additional_info?.release_mbid) { - try { - // We could fetch cover art from Cover Art Archive, but for now we'll leave it null - // albumArt = `https://coverartarchive.org/release/${latestListen.track_metadata.additional_info.release_mbid}/front`; - } catch (error) { - this.logVerbose("Failed to fetch album art:", error); - } - } + // Try to get album art from MusicBrainz or other sources + const albumArt: string | null = null; + if (latestListen.track_metadata.additional_info?.release_mbid) { + try { + // We could fetch cover art from Cover Art Archive, but for now we'll leave it null + // albumArt = `https://coverartarchive.org/release/${latestListen.track_metadata.additional_info.release_mbid}/front`; + } catch (error) { + this.logVerbose("Failed to fetch album art:", error); + } + } - const track: Track = { - name: latestListen.track_metadata.track_name, - artist: latestListen.track_metadata.artist_name, - album: latestListen.track_metadata.release_name || "", - albumArt, - url: + const track: Track = { + name: latestListen.track_metadata.track_name, + artist: latestListen.track_metadata.artist_name, + album: latestListen.track_metadata.release_name || "", + albumArt, + url: latestListen.track_metadata.additional_info?.origin_url || `https://listenbrainz.org/user/${username}`, - date: isNowPlaying - ? "now" - : new Date(trackTimestamp * 1000).toISOString(), - nowPlaying: isNowPlaying, - loved: false, // ListenBrainz doesn't have a direct "loved" concept - from: trackTimestamp, - to: endTime, - duration, - }; + date: isNowPlaying + ? "now" + : new Date(trackTimestamp * 1000).toISOString(), + nowPlaying: isNowPlaying, + loved: false, // ListenBrainz doesn't have a direct "loved" concept + from: trackTimestamp, + to: endTime, + duration, + }; - this.logVerbose("Processed track:", track); - this.log( - `${isNowPlaying ? "Now playing" : "Last played"}:`, - `${track.artist} - ${track.name}`, - ); + this.logVerbose("Processed track:", track); + this.log( + `${isNowPlaying ? "Now playing" : "Last played"}:`, + `${track.artist} - ${track.name}`, + ); - return track; - } catch (error) { - this.logError("Failed to fetch latest listen:", error); - throw error; - } + return track; + } catch (error) { + this.logError("Failed to fetch latest listen:", error); + throw error; } + } - protected getErrorMessage(error: any): string { + protected getErrorMessage(error: any): string { // ListenBrainz uses different error format - if (error.error) { - return error.error; - } - if (error.message) { - return error.message; - } - return error.toString() || "Unknown error"; + if (error.error) { + return error.error; } + if (error.message) { + return error.message; + } + return error.toString() || "Unknown error"; + } }