import { ref, get, set, remove, onValue, off, update } from 'firebase/database'; import { database } from './config'; import { debugLog } from '../utils/logger'; import type { Song, QueueItem, Controller, Singer, DisabledSong } from '../types'; // Utility functions for sequential key management const findNextSequentialKey = (existingKeys: string[]): number => { const numericKeys = existingKeys .filter(key => /^\d+$/.test(key)) // Only consider numerical keys .map(key => parseInt(key, 10)) .sort((a, b) => a - b); // Find the first gap in the sequence, or use the next number after the highest let nextKey = 0; for (let i = 0; i < numericKeys.length; i++) { if (numericKeys[i] !== i) { nextKey = i; break; } nextKey = i + 1; } return nextKey; }; const shiftDownAfterDeletion = ( items: Record, removedKey: string, itemName: string = 'item' ): Record => { const updates: Record = {}; const removedKeyNum = parseInt(removedKey, 10); debugLog(`shiftDownAfterDeletion - removing ${itemName} with key:`, removedKeyNum); // Remove the target item updates[removedKey] = null; // Shift down all items that come after the removed one Object.entries(items).forEach(([key, item]) => { if (item && key !== removedKey) { const keyNum = parseInt(key, 10); if (keyNum > removedKeyNum) { // This item comes after the removed one, shift it down const newKey = (keyNum - 1).toString(); debugLog(`shiftDownAfterDeletion - shifting: ${key} -> ${newKey}`); updates[newKey] = item; updates[key] = null; // Remove from old position } } }); debugLog(`shiftDownAfterDeletion - updates to apply:`, updates); return updates; }; // Basic CRUD operations for controllers export const controllerService = { // Get a specific controller getController: async (controllerName: string) => { const controllerRef = ref(database, `controllers/${controllerName}`); const snapshot = await get(controllerRef); return snapshot.exists() ? snapshot.val() : null; }, // Set/update a controller setController: async (controllerName: string, data: Controller) => { const controllerRef = ref(database, `controllers/${controllerName}`); await set(controllerRef, data); }, // Update specific parts of a controller updateController: async (controllerName: string, updates: Partial) => { const controllerRef = ref(database, `controllers/${controllerName}`); await update(controllerRef, updates); }, // Listen to controller changes in real-time subscribeToController: (controllerName: string, callback: (data: Controller | null) => void) => { const controllerRef = ref(database, `controllers/${controllerName}`); onValue(controllerRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : null); }); // Return unsubscribe function return () => off(controllerRef); } }; // Queue management operations export const queueService = { // Add a song to the queue addToQueue: async (controllerName: string, queueItem: Omit) => { const queueRef = ref(database, `controllers/${controllerName}/player/queue`); const queueSnapshot = await get(queueRef); const currentQueue = queueSnapshot.exists() ? queueSnapshot.val() : {}; // Find the next available sequential key const nextKey = findNextSequentialKey(Object.keys(currentQueue)); debugLog('addToQueue - existing keys:', Object.keys(currentQueue)); debugLog('addToQueue - next key:', nextKey); const newQueueItemRef = ref(database, `controllers/${controllerName}/player/queue/${nextKey}`); await set(newQueueItemRef, queueItem); return { key: nextKey.toString() }; }, // Remove a song from the queue removeFromQueue: async (controllerName: string, queueItemKey: string) => { const queueRef = ref(database, `controllers/${controllerName}/player/queue`); const queueSnapshot = await get(queueRef); if (!queueSnapshot.exists()) { throw new Error('Queue not found'); } const queue = queueSnapshot.val(); debugLog('removeFromQueue - original queue:', queue); // Find the item to remove and get its key const itemToRemove = Object.entries(queue).find(([key, item]) => key === queueItemKey && item ); if (!itemToRemove) { throw new Error('Queue item not found'); } const [removedKey, removedItem] = itemToRemove; debugLog('removeFromQueue - removing item:', removedItem, 'with key:', removedKey); // Use utility function to create shift-down updates const updates = shiftDownAfterDeletion(queue, removedKey, 'queue item'); // Update order property for shifted items Object.entries(updates).forEach(([key, item]) => { if (item && key !== removedKey) { const keyNum = parseInt(key, 10); const originalKeyNum = parseInt(removedKey, 10); if (keyNum > originalKeyNum) { // This is a shifted item, update its order (updates[key] as QueueItem).order = keyNum; } } }); // Apply all updates atomically await update(queueRef, updates); return { updates }; }, // Update queue item updateQueueItem: async (controllerName: string, queueItemKey: string, updates: Partial) => { const queueItemRef = ref(database, `controllers/${controllerName}/player/queue/${queueItemKey}`); await update(queueItemRef, updates); }, // Reorder the queue reorderQueue: async (controllerName: string, reorderedItems: QueueItem[]) => { const queueRef = ref(database, `controllers/${controllerName}/player/queue`); const queueSnapshot = await get(queueRef); if (!queueSnapshot.exists()) { throw new Error('Queue not found'); } const queue = queueSnapshot.val(); debugLog('reorderQueue - original queue:', queue); debugLog('reorderQueue - reordered items:', reorderedItems); // Create updates object const updates: Record = {}; // First, remove all existing items Object.keys(queue).forEach(key => { updates[key] = null; }); // Then, add back the reordered items with sequential keys and order reorderedItems.forEach((item, index) => { const newKey = index.toString(); const newOrder = index + 1; debugLog(`reorderQueue - setting: key ${newKey}, order ${newOrder}, song: ${item.song.title}`); updates[newKey] = { ...item, order: newOrder }; }); debugLog('reorderQueue - updates to apply:', updates); // Apply all updates atomically await update(queueRef, updates); return { updates }; }, // Clean up queue with inconsistent keys (migrate push ID keys to sequential numerical keys) cleanupQueueKeys: async (controllerName: string) => { const queueRef = ref(database, `controllers/${controllerName}/player/queue`); const snapshot = await get(queueRef); if (!snapshot.exists()) return; const queue = snapshot.val(); const updates: Record = {}; let hasChanges = false; // Find all push ID keys (non-numerical keys) const pushIdKeys = Object.keys(queue).filter(key => !/^\d+$/.test(key)); if (pushIdKeys.length === 0) return; // No cleanup needed // Get existing numerical keys to find the next available key const existingNumericalKeys = Object.keys(queue) .filter(key => /^\d+$/.test(key)) .map(key => parseInt(key, 10)) .sort((a, b) => a - b); let nextKey = existingNumericalKeys.length > 0 ? Math.max(...existingNumericalKeys) + 1 : 0; // Migrate push ID items to sequential numerical keys pushIdKeys.forEach(pushIdKey => { const item = queue[pushIdKey]; // Remove the old item with push ID updates[pushIdKey] = null; // Add the item with sequential numerical key updates[nextKey.toString()] = item; nextKey++; hasChanges = true; }); if (hasChanges) { await update(queueRef, updates); } }, // Listen to queue changes subscribeToQueue: (controllerName: string, callback: (data: Record) => void) => { const queueRef = ref(database, `controllers/${controllerName}/player/queue`); onValue(queueRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(queueRef); } }; // Player state operations export const playerService = { // Update player state updatePlayerState: async (controllerName: string, state: Partial) => { const playerRef = ref(database, `controllers/${controllerName}/player`); await update(playerRef, state); }, // Update just the player state value updatePlayerStateValue: async (controllerName: string, stateValue: string) => { const stateRef = ref(database, `controllers/${controllerName}/player/state`); await set(stateRef, stateValue); }, // Listen to player state changes subscribeToPlayerState: (controllerName: string, callback: (data: Controller['player']) => void) => { const playerRef = ref(database, `controllers/${controllerName}/player`); onValue(playerRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(playerRef); } }; // History operations export const historyService = { // Add song to history addToHistory: async (controllerName: string, song: Omit) => { const historyRef = ref(database, `controllers/${controllerName}/history`); const historySnapshot = await get(historyRef); const currentHistory = historySnapshot.exists() ? historySnapshot.val() : {}; // Find the next available sequential key const nextKey = findNextSequentialKey(Object.keys(currentHistory)); debugLog('addToHistory - existing keys:', Object.keys(currentHistory)); debugLog('addToHistory - next key:', nextKey); const newHistoryRef = ref(database, `controllers/${controllerName}/history/${nextKey}`); await set(newHistoryRef, song); return { key: nextKey.toString() }; }, // Remove song from history removeFromHistory: async (controllerName: string, historyItemKey: string) => { const historyRef = ref(database, `controllers/${controllerName}/history`); const historySnapshot = await get(historyRef); if (!historySnapshot.exists()) { throw new Error('History not found'); } const history = historySnapshot.val(); debugLog('removeFromHistory - original history:', history); // Find the item to remove and get its key const itemToRemove = Object.entries(history).find(([key, item]) => key === historyItemKey && item ); if (!itemToRemove) { throw new Error('History item not found'); } const [removedKey, removedItem] = itemToRemove; debugLog('removeFromHistory - removing item:', removedItem, 'with key:', removedKey); // Use utility function to create shift-down updates const updates = shiftDownAfterDeletion(history, removedKey, 'history item'); // Apply all updates atomically await update(historyRef, updates); }, // Listen to history changes subscribeToHistory: (controllerName: string, callback: (data: Record) => void) => { const historyRef = ref(database, `controllers/${controllerName}/history`); onValue(historyRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(historyRef); } }; // Favorites operations export const favoritesService = { // Add song to favorites addToFavorites: async (controllerName: string, song: Omit) => { const favoritesRef = ref(database, `controllers/${controllerName}/favorites`); const favoritesSnapshot = await get(favoritesRef); const currentFavorites = favoritesSnapshot.exists() ? favoritesSnapshot.val() : {}; // Find the next available sequential key const nextKey = findNextSequentialKey(Object.keys(currentFavorites)); debugLog('addToFavorites - existing keys:', Object.keys(currentFavorites)); debugLog('addToFavorites - next key:', nextKey); const newFavoriteRef = ref(database, `controllers/${controllerName}/favorites/${nextKey}`); await set(newFavoriteRef, song); return { key: nextKey.toString() }; }, // Remove song from favorites removeFromFavorites: async (controllerName: string, songKey: string) => { const favoritesRef = ref(database, `controllers/${controllerName}/favorites`); const favoritesSnapshot = await get(favoritesRef); if (!favoritesSnapshot.exists()) { throw new Error('Favorites not found'); } const favorites = favoritesSnapshot.val(); debugLog('removeFromFavorites - original favorites:', favorites); // Find the item to remove and get its key const itemToRemove = Object.entries(favorites).find(([key, item]) => key === songKey && item ); if (!itemToRemove) { throw new Error('Favorite item not found'); } const [removedKey, removedItem] = itemToRemove; debugLog('removeFromFavorites - removing item:', removedItem, 'with key:', removedKey); // Use utility function to create shift-down updates const updates = shiftDownAfterDeletion(favorites, removedKey, 'favorite item'); // Apply all updates atomically await update(favoritesRef, updates); }, // Listen to favorites changes subscribeToFavorites: (controllerName: string, callback: (data: Record) => void) => { const favoritesRef = ref(database, `controllers/${controllerName}/favorites`); onValue(favoritesRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(favoritesRef); } }; // Singer management operations export const singerService = { // Add a new singer addSinger: async (controllerName: string, singerName: string) => { const singersRef = ref(database, `controllers/${controllerName}/player/singers`); const singersSnapshot = await get(singersRef); const currentSingers = singersSnapshot.exists() ? singersSnapshot.val() : {}; // Check if singer already exists const existingSinger = Object.values(currentSingers).find((singer) => (String((singer as Singer).name || '').toLowerCase() === String(singerName || '').toLowerCase()) ); if (existingSinger) { throw new Error('Singer already exists'); } // Find the next available sequential key const nextKey = findNextSequentialKey(Object.keys(currentSingers)); debugLog('addSinger - existing keys:', Object.keys(currentSingers)); debugLog('addSinger - next key:', nextKey); // Create new singer with current timestamp const newSinger: Omit = { name: singerName, lastLogin: new Date().toISOString() }; // Add to singers list with sequential key const newSingerRef = ref(database, `controllers/${controllerName}/player/singers/${nextKey}`); await set(newSingerRef, newSinger); return { key: nextKey.toString() }; }, // Remove singer and all their queue items removeSinger: async (controllerName: string, singerName: string) => { // First, remove all queue items for this singer const queueRef = ref(database, `controllers/${controllerName}/player/queue`); const queueSnapshot = await get(queueRef); if (queueSnapshot.exists()) { const queue = queueSnapshot.val(); const updates: Record = {}; // Find all queue items for this singer and mark them for removal Object.entries(queue).forEach(([key, item]) => { if (item && (item as QueueItem).singer.name === singerName) { updates[key] = null; // Mark for removal } }); // Remove the queue items if (Object.keys(updates).length > 0) { await update(queueRef, updates); } } // Then, remove the singer from the singers list and shift down const singersRef = ref(database, `controllers/${controllerName}/player/singers`); const singersSnapshot = await get(singersRef); if (singersSnapshot.exists()) { const singers = singersSnapshot.val(); debugLog('removeSinger - original singers:', singers); // Find the singer to remove and get their key const singerToRemove = Object.entries(singers).find(([, singer]) => singer && (singer as Singer).name === singerName ); if (!singerToRemove) { debugLog('removeSinger - singer not found:', singerName); return; } const [removedKey, removedSinger] = singerToRemove; debugLog('removeSinger - removing singer:', removedSinger, 'with key:', removedKey); // Use utility function to create shift-down updates const updates = shiftDownAfterDeletion(singers, removedKey, 'singer'); // Apply all updates atomically if (Object.keys(updates).length > 0) { await update(singersRef, updates); } } }, // Listen to singers changes subscribeToSingers: (controllerName: string, callback: (data: Record) => void) => { const singersRef = ref(database, `controllers/${controllerName}/player/singers`); onValue(singersRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(singersRef); } }; // Settings operations export const settingsService = { // Get current settings getSettings: async (controllerName: string) => { const settingsRef = ref(database, `controllers/${controllerName}/player/settings`); const snapshot = await get(settingsRef); return snapshot.exists() ? snapshot.val() : null; }, // Update a specific setting updateSetting: async (controllerName: string, setting: string, value: boolean) => { const settingRef = ref(database, `controllers/${controllerName}/player/settings/${setting}`); await set(settingRef, value); }, // Update multiple settings at once updateSettings: async (controllerName: string, settings: Record) => { const settingsRef = ref(database, `controllers/${controllerName}/player/settings`); await update(settingsRef, settings); }, // Listen to settings changes subscribeToSettings: (controllerName: string, callback: (data: Record) => void) => { const settingsRef = ref(database, `controllers/${controllerName}/player/settings`); onValue(settingsRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(settingsRef); } }; // Disabled songs management operations export const disabledSongsService = { // Generate a hash for the song path to use as a Firebase-safe key generateSongKey: (songPath: string): string => { // Simple hash function for the path let hash = 0; for (let i = 0; i < songPath.length; i++) { const char = songPath.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); // Convert to base36 for shorter keys }, // Add a song to the disabled list addDisabledSong: async (controllerName: string, song: Song) => { debugLog('disabledSongsService.addDisabledSong called with:', { controllerName, song }); if (!controllerName) { throw new Error('Controller name is required'); } if (!song.path) { throw new Error('Song path is required'); } if (!song.artist || !song.title) { throw new Error('Song artist and title are required'); } const songKey = disabledSongsService.generateSongKey(song.path); debugLog('Generated song key:', songKey); const disabledSongRef = ref(database, `controllers/${controllerName}/disabledSongs/${songKey}`); const disabledSong = { path: song.path, artist: song.artist, title: song.title, key: song.key, disabledAt: new Date().toISOString(), }; debugLog('Saving disabled song:', disabledSong); // Add timeout to prevent hanging const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Operation timed out')), 10000); }); const savePromise = set(disabledSongRef, disabledSong); await Promise.race([savePromise, timeoutPromise]); debugLog('Disabled song saved successfully'); }, // Remove a song from the disabled list removeDisabledSong: async (controllerName: string, songPath: string) => { const songKey = disabledSongsService.generateSongKey(songPath); const disabledSongRef = ref(database, `controllers/${controllerName}/disabledSongs/${songKey}`); // Add timeout to prevent hanging const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Operation timed out')), 10000); }); const removePromise = remove(disabledSongRef); await Promise.race([removePromise, timeoutPromise]); }, // Check if a song is disabled isSongDisabled: async (controllerName: string, songPath: string): Promise => { const songKey = disabledSongsService.generateSongKey(songPath); const disabledSongRef = ref(database, `controllers/${controllerName}/disabledSongs/${songKey}`); const snapshot = await get(disabledSongRef); return snapshot.exists(); }, // Get all disabled songs getDisabledSongs: async (controllerName: string) => { const disabledSongsRef = ref(database, `controllers/${controllerName}/disabledSongs`); const snapshot = await get(disabledSongsRef); return snapshot.exists() ? snapshot.val() : {}; }, // Get disabled song paths as a Set for fast lookup getDisabledSongPaths: async (controllerName: string): Promise> => { const disabledSongs = await disabledSongsService.getDisabledSongs(controllerName); return new Set(Object.values(disabledSongs as Record).map((song) => song.path)); }, // Listen to disabled songs changes subscribeToDisabledSongs: (controllerName: string, callback: (data: Record) => void) => { const disabledSongsRef = ref(database, `controllers/${controllerName}/disabledSongs`); onValue(disabledSongsRef, (snapshot) => { callback(snapshot.exists() ? snapshot.val() : {}); }); return () => off(disabledSongsRef); } };