import crypto from 'crypto'; import { albumCache, artistCache, songCache, imageCache, PersistentCache } from './cache'; export interface NavidromeConfig { serverUrl: string; username: string; password: string; } export interface SubsonicResponse> { 'subsonic-response': { status: string; version: string; type: string; serverVersion?: string; openSubsonic?: boolean; error?: { code: number; message: string; }; } & T; } export interface Album { id: string; name: string; artist: string; artistId: string; coverArt?: string; songCount: number; duration: number; playCount?: number; created: string; starred?: string; year?: number; genre?: string; } export interface Artist { id: string; name: string; albumCount: number; starred?: string; coverArt?: string; } export interface Song { id: string; parent: string; isDir: boolean; title: string; album: string; artist: string; track?: number; year?: number; genre?: string; coverArt?: string; size: number; contentType: string; suffix: string; duration: number; bitRate?: number; path: string; playCount?: number; discNumber?: number; created: string; albumId: string; artistId: string; type: string; starred?: string; replayGain?: number; } export interface Playlist { id: string; name: string; comment?: string; owner: string; public: boolean; songCount: number; duration: number; created: string; changed: string; coverArt?: string; } export interface RadioStation { id: string; streamUrl: string; name: string; homePageUrl?: string; } export interface AlbumInfo { notes?: string; musicBrainzId?: string; lastFmUrl?: string; smallImageUrl?: string; mediumImageUrl?: string; largeImageUrl?: string; biography?: string; } export interface ArtistInfo { biography?: string; musicBrainzId?: string; lastFmUrl?: string; smallImageUrl?: string; mediumImageUrl?: string; largeImageUrl?: string; similarArtist?: Artist[]; } export interface User { username: string; email?: string; scrobblingEnabled: boolean; maxBitRate?: number; adminRole: boolean; settingsRole: boolean; downloadRole: boolean; uploadRole: boolean; playlistRole: boolean; coverArtRole: boolean; commentRole: boolean; podcastRole: boolean; streamRole: boolean; jukeboxRole: boolean; shareRole: boolean; videoConversionRole: boolean; avatarLastChanged?: string; } class NavidromeAPI { private config: NavidromeConfig; private clientName = 'miceclient'; private version = '1.16.0'; constructor(config: NavidromeConfig) { this.config = config; } private generateSalt(): string { return crypto.randomBytes(8).toString('hex'); } private generateToken(password: string, salt: string): string { return crypto.createHash('md5').update(password + salt).digest('hex'); } async makeRequest(endpoint: string, params: Record = {}): Promise> { const salt = this.generateSalt(); const token = this.generateToken(this.config.password, salt); const queryParams = new URLSearchParams({ u: this.config.username, t: token, s: salt, v: this.version, c: this.clientName, f: 'json', ...params }); const url = `${this.config.serverUrl}/rest/${endpoint}?${queryParams.toString()}`; try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data: SubsonicResponse = await response.json(); if (data['subsonic-response'].status === 'failed') { throw new Error(data['subsonic-response'].error?.message || 'Unknown error'); } return data['subsonic-response']; } catch (error) { console.error('Navidrome API request failed:', error); throw error; } } async ping(): Promise { try { await this.makeRequest('ping'); return true; } catch { return false; } } async getUserInfo(): Promise { const response = await this.makeRequest('getUser', { username: this.config.username }); const userData = response.user as User; return userData; } async getArtists(): Promise { const response = await this.makeRequest('getArtists'); const artists: Artist[] = []; const artistsData = response.artists as { index?: Array<{ artist?: Artist[] }> }; if (artistsData?.index) { for (const index of artistsData.index) { if (index.artist) { artists.push(...index.artist); } } } return artists; } async getArtist(artistId: string): Promise<{ artist: Artist; albums: Album[] }> { const response = await this.makeRequest('getArtist', { id: artistId }); const artistData = response.artist as Artist & { album?: Album[] }; return { artist: artistData, albums: artistData.album || [] }; } async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise { const response = await this.makeRequest('getAlbumList2', { type: type || 'newest', size, offset }); const albumListData = response.albumList2 as { album?: Album[] }; return albumListData?.album || []; } async getAlbum(albumId: string): Promise<{ album: Album; songs: Song[] }> { const response = await this.makeRequest('getAlbum', { id: albumId }); const albumData = response.album as Album & { song?: Song[] }; return { album: albumData, songs: albumData.song || [] }; } async search(query: string, artistCount = 20, albumCount = 20, songCount = 20): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[]; }> { const response = await this.makeRequest('search3', { query, artistCount, albumCount, songCount }); const searchData = response.searchResult3 as { artist?: Artist[]; album?: Album[]; song?: Song[]; }; return { artists: searchData?.artist || [], albums: searchData?.album || [], songs: searchData?.song || [] }; } async getPlaylists(): Promise { const response = await this.makeRequest('getPlaylists'); const playlistsData = response.playlists as { playlist?: Playlist[] }; return playlistsData?.playlist || []; } async getPlaylist(playlistId: string): Promise<{ playlist: Playlist; songs: Song[] }> { const response = await this.makeRequest('getPlaylist', { id: playlistId }); const playlistData = response.playlist as Playlist & { entry?: Song[] }; return { playlist: playlistData, songs: playlistData.entry || [] }; } async createPlaylist(name: string, songIds?: string[]): Promise { const params: Record = { name }; if (songIds && songIds.length > 0) { songIds.forEach((id, index) => { params[`songId[${index}]`] = id; }); } const response = await this.makeRequest('createPlaylist', params); return response.playlist as Playlist; } async updatePlaylist(playlistId: string, name?: string, comment?: string, songIds?: string[]): Promise { const params: Record = { playlistId }; if (name) params.name = name; if (comment) params.comment = comment; if (songIds) { songIds.forEach((id, index) => { params[`songId[${index}]`] = id; }); } await this.makeRequest('updatePlaylist', params); } async deletePlaylist(playlistId: string): Promise { await this.makeRequest('deletePlaylist', { id: playlistId }); } getStreamUrl(songId: string, maxBitRate?: number): string { const salt = this.generateSalt(); const token = this.generateToken(this.config.password, salt); const params = new URLSearchParams({ u: this.config.username, t: token, s: salt, v: this.version, c: this.clientName, id: songId }); if (maxBitRate) { params.append('maxBitRate', maxBitRate.toString()); } return `${this.config.serverUrl}/rest/stream?${params.toString()}`; } // Direct download URL (original file). Useful for offline caching where the browser can handle transcoding. getDownloadUrl(songId: string): string { const salt = this.generateSalt(); const token = this.generateToken(this.config.password, salt); const params = new URLSearchParams({ u: this.config.username, t: token, s: salt, v: this.version, c: this.clientName, id: songId }); return `${this.config.serverUrl}/rest/download?${params.toString()}`; } getCoverArtUrl(coverArtId: string, size?: number): string { const salt = this.generateSalt(); const token = this.generateToken(this.config.password, salt); const params = new URLSearchParams({ u: this.config.username, t: token, s: salt, v: this.version, c: this.clientName, id: coverArtId }); if (size) { params.append('size', size.toString()); } return `${this.config.serverUrl}/rest/getCoverArt?${params.toString()}`; } async star(id: string, type: 'song' | 'album' | 'artist'): Promise { const paramName = type === 'song' ? 'id' : type === 'album' ? 'albumId' : 'artistId'; await this.makeRequest('star', { [paramName]: id }); } async unstar(id: string, type: 'song' | 'album' | 'artist'): Promise { const paramName = type === 'song' ? 'id' : type === 'album' ? 'albumId' : 'artistId'; await this.makeRequest('unstar', { [paramName]: id }); } async scrobble(songId: string, submission: boolean = true): Promise { await this.makeRequest('scrobble', { id: songId, submission: submission.toString(), time: Date.now() }); } // Enhanced scrobbling functionality for Last.fm integration async updateNowPlaying(songId: string): Promise { try { await this.makeRequest('scrobble', { id: songId, submission: 'false', time: Date.now() }); } catch (error) { console.error('Failed to update now playing:', error); // Don't throw - this is not critical } } async scrobbleTrack(songId: string, timestamp?: number): Promise { try { await this.makeRequest('scrobble', { id: songId, submission: 'true', time: timestamp || Date.now() }); } catch (error) { console.error('Failed to scrobble track:', error); // Don't throw - this is not critical } } // Helper method to determine if a track should be scrobbled // According to Last.fm guidelines: track should be scrobbled if played for at least // 30 seconds OR half the track duration, whichever comes first shouldScrobble(playedDuration: number, totalDuration: number): boolean { const minimumTime = 30; // 30 seconds minimum const halfTrackTime = totalDuration / 2; return playedDuration >= Math.min(minimumTime, halfTrackTime); } async getAllSongs(size = 500, offset = 0): Promise { const response = await this.makeRequest('search3', { query: '', songCount: size, songOffset: offset, artistCount: 0, albumCount: 0 }); const searchData = response.searchResult3 as { song?: Song[] }; return searchData?.song || []; } async getRadioStations(): Promise { const response = await this.makeRequest('getRadioStations'); const radioStationsData = response.radioStations as { radioStation?: RadioStation[] }; return radioStationsData?.radioStation || []; } async getRadioStation(stationId: string): Promise { const response = await this.makeRequest('getRadioStation', { id: stationId }); return response.radioStation as RadioStation; } async getInternetRadioStations(): Promise { try { const response = await this.makeRequest('getInternetRadioStations'); const radioData = response.internetRadioStations as { internetRadioStation?: RadioStation[] }; return radioData?.internetRadioStation || []; } catch (error) { console.error('Failed to get internet radio stations:', error); return []; } } async createInternetRadioStation(name: string, streamUrl: string, homePageUrl?: string): Promise { const params: Record = { name, streamUrl }; if (homePageUrl) params.homePageUrl = homePageUrl; await this.makeRequest('createInternetRadioStation', params); } async deleteInternetRadioStation(id: string): Promise { await this.makeRequest('deleteInternetRadioStation', { id }); } async getArtistInfo(artistId: string): Promise<{ artist: Artist; info: ArtistInfo }> { const response = await this.makeRequest('getArtistInfo2', { id: artistId }); const artistData = response.artist as Artist; const artistInfo = response.info as ArtistInfo; return { artist: artistData, info: artistInfo }; } async getAlbumInfo(albumId: string): Promise<{ album: Album; info: AlbumInfo }> { const response = await this.makeRequest('getAlbumInfo2', { id: albumId }); const albumData = response.album as Album; const albumInfo = response.info as AlbumInfo; return { album: albumData, info: albumInfo }; } async search2(query: string, artistCount = 20, albumCount = 20, songCount = 20): Promise<{ artists: Artist[]; albums: Album[]; songs: Song[]; }> { const response = await this.makeRequest('search2', { query, artistCount, albumCount, songCount }); const searchData = response.searchResult2 as { artist?: Artist[]; album?: Album[]; song?: Song[]; }; return { artists: searchData?.artist || [], albums: searchData?.album || [], songs: searchData?.song || [] }; } async getArtistInfo2(artistId: string, count = 20, includeNotPresent = false): Promise { const response = await this.makeRequest('getArtistInfo2', { id: artistId, count, includeNotPresent: includeNotPresent.toString() }); return response.artistInfo2 as ArtistInfo; } async getAlbumInfo2(albumId: string): Promise { const response = await this.makeRequest('getAlbumInfo2', { id: albumId }); return response.albumInfo2 as AlbumInfo; } async getStarred2(): Promise<{ starred2: { song?: Song[]; album?: Album[]; artist?: Artist[] } }> { try { const response = await this.makeRequest('getStarred2'); return response as { starred2: { song?: Song[]; album?: Album[]; artist?: Artist[] } }; } catch (error) { console.error('Failed to get starred items:', error); return { starred2: {} }; } } async getAlbumSongs(albumId: string): Promise { try { const response = await this.makeRequest('getAlbum', { id: albumId }); const albumData = response.album as { song?: Song[] }; return albumData?.song || []; } catch (error) { console.error('Failed to get album songs:', error); return []; } } async getArtistTopSongs(artistName: string, limit: number = 10): Promise { try { // Search for songs by the artist and return them sorted by play count const searchResult = await this.search2(artistName, 0, 0, limit * 3); // Filter songs that are actually by this artist (exact match) const artistSongs = searchResult.songs.filter(song => song.artist.toLowerCase() === artistName.toLowerCase() ); // Sort by play count (descending) and limit results return artistSongs .sort((a, b) => (b.playCount || 0) - (a.playCount || 0)) .slice(0, limit); } catch (error) { console.error('Failed to get artist top songs:', error); return []; } } } // Singleton instance management let navidromeInstance: NavidromeAPI | null = null; export function getNavidromeAPI(customConfig?: NavidromeConfig): NavidromeAPI | null { let config: NavidromeConfig; if (customConfig) { config = customConfig; } else { // Try to get config from localStorage first (client-side) if (typeof window !== 'undefined') { const savedConfig = localStorage.getItem('navidrome-config'); if (savedConfig) { try { config = JSON.parse(savedConfig); } catch (error) { console.error('Failed to parse saved Navidrome config:', error); config = getEnvConfig(); } } else { config = getEnvConfig(); } } else { // Server-side: use environment variables config = getEnvConfig(); } } if (!config.serverUrl || !config.username || !config.password) { // Return null instead of throwing an error when configuration is incomplete // console.log('Navidrome configuration is incomplete. Please configure in settings.'); return null; } // Always create a new instance if config is provided or if no instance exists if (customConfig || !navidromeInstance) { navidromeInstance = new NavidromeAPI(config); } return navidromeInstance; } function getEnvConfig(): NavidromeConfig { return { serverUrl: process.env.NEXT_PUBLIC_NAVIDROME_URL || '', username: process.env.NEXT_PUBLIC_NAVIDROME_USERNAME || '', password: process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD || '' }; } export function resetNavidromeAPI(): void { navidromeInstance = null; } export default NavidromeAPI;