@sandakan/nora
Nora - GitHub Copilot Instructions
Install
agr install @sandakan/nora --target copilotWrites 1 file into .github/copilot-instructions.md, pinned to git-e9f81ce7.
- .github/copilot-instructions.md
Document
Nora - GitHub Copilot Instructions
AI Coding Agent Guidelines for Nora Music Player
Generated to help AI agents understand architectural patterns, conventions, and development workflows.
๐ฏ Project Overview
Nora is an elegant, feature-rich music player built with Electron and React, inspired by Oto Music (Android). It emphasizes simplicity, beautiful design, and essential music management features that default music apps often lack.
Core Technologies
- Runtime: Electron v39+ (main + renderer processes)
- UI Framework: React 19 with TypeScript (strict mode enabled)
- Build System: Vite + esbuild (electron-vite configuration)
- State Management: @tanstack/react-store with custom dispatch/store pattern
- Data Fetching: @tanstack/react-query with suspense queries
- Routing: TanStack Router
- Database: Drizzle ORM with PGlite (local PostgreSQL)
- Styling: Tailwind CSS v4 with dark mode support
- Internationalization: react-i18next
- Testing: Vitest with coverage reporting
Key Features
- Organize songs, artists, albums, and playlists
- Synced and unsynced lyrics support
- Media Session API integration
- Discord Rich Presence integration
- Last.fm scrobbling support
- Custom metadata editing (MP3 only via node-id3)
- Dynamic theme generation from album artwork
- Mini-player mode with compact controls
- Global keyboard shortcuts
๐ Project Structure
nora/
โโโ src/
โ โโโ main/ # Electron main process
โ โ โโโ main.ts # Entry point: window management, IPC setup
โ โ โโโ ipc.ts # IPC handler registration
โ โ โโโ db/ # Database layer (Drizzle ORM + PGlite)
โ โ โโโ core/ # Core business logic (library, playlists, etc.)
โ โ โโโ fs/ # File system watchers and operations
โ โ โโโ auth/ # Last.fm authentication
โ โ โโโ other/ # Artworks, Discord RPC, utilities
โ โ
โ โโโ preload/ # Electron preload scripts
โ โ โโโ index.ts # window.api bridge (IPC interface)
โ โ
โ โโโ renderer/ # React application
โ โ โโโ src/
โ โ โ โโโ App.tsx # Main app component (365 lines, down from 2,013)
โ โ โ โโโ hooks/ # 25+ custom React hooks for feature isolation
โ โ โ โโโ store/ # TanStack Store configuration
โ โ โ โโโ routes/ # TanStack Router routes
โ โ โ โโโ components/ # React components
โ โ โ โโโ other/ # Singleton services (AudioPlayer, PlayerQueue, etc.)
โ โ โ โโโ utils/ # Helper functions
โ โ โโโ index.html
โ โ
โ โโโ common/ # Shared utilities (main + renderer)
โ โ โโโ convert.ts
โ โ โโโ isLyricsSynced.ts
โ โ โโโ parseLyrics.ts
โ โ โโโ roundTo.ts
โ โ
โ โโโ types/ # TypeScript type definitions
โ โโโ app.d.ts # Core app types
โ โโโ [api].d.ts # External API types
โ
โโโ resources/ # Static assets (icons, SQL migrations)
โโโ build/ # Build artifacts and installer assets
โโโ test/ # Vitest test files
๐ฅ๏ธ Main Process Architecture (Electron)
The main process is the heart of Nora's Electron application, handling system-level operations, database management, file system watching, and IPC communication.
Main Process Entry Point (src/main/main.ts)
Responsibilities:
- Window lifecycle management (create, resize, position, state)
- Player type switching (normal, mini, full-screen)
- System integration (tray, taskbar, global shortcuts)
- App lifecycle events (startup, quit, before-quit cleanup)
- Power management (prevent sleep, battery detection)
- System theme watching
- Single instance lock enforcement
- Protocol handling (
nora://custom protocol) - Auto-launch configuration
Key Variables (module-level state):
export let mainWindow: BrowserWindow; // Main window instance
let tray: Tray; // System tray icon
let playerType: PlayerTypes; // 'normal' | 'mini' | 'full'
let isAudioPlaying: boolean; // Playback state for taskbar buttons
let currentSongPath: string; // For lyrics/metadata persistence
let powerSaveBlockerId: number | null; // Prevent display sleep during playback
Window Size Constants:
// Normal window
MAIN_WINDOW_DEFAULT_SIZE_X = 1280
MAIN_WINDOW_DEFAULT_SIZE_Y = 720
MAIN_WINDOW_MIN_SIZE = 700x500
// Mini player
MINI_PLAYER_MIN_SIZE = 270x200
MINI_PLAYER_MAX_SIZE = 510x300
MINI_PLAYER_ASPECT_RATIO = 17/10
Critical Functions:
createWindow(): Initialize BrowserWindow with preload script, frame settings, visual effectsmanageWindowFinishLoad(): Restore window position/size from settings, show windowhandleBeforeQuit(): Cleanup operations (save lyrics, metadata, close watchers, clear temp files)changePlayerType(type): Switch between normal/mini/full-screen modes with size/position restorationdataUpdateEvent(dataType, data, message): Debounced event aggregation for library updates
System Integration:
- Single Instance: Uses
app.requestSingleInstanceLock()to prevent multiple instances - Custom Protocol: Registers
nora://for file associations and auth callbacks (Last.fm) - Tray Menu: Show/hide app, exit option
- Global Shortcuts: F12 (devtools in development)
- Power Monitor: Detect AC/battery status, prevent display sleep during playback
IPC Handler Registration (src/main/ipc.ts)
Pattern: Centralized IPC registration in initializeIPC(mainWindow, abortSignal) function.
IPC Categories (matches preload bridge):
-
Window Controls (
ipcMain.on):app/close,app/minimize,app/toggleMaximize,app/hide,app/show- Player type changes:
changePlayerType(type)
-
Audio Library (
ipcMain.handle):getSong,getAllSongs,getAllHistorySongs,getAllFavoriteSongsgetSongInfo,getSongListeningData,updateSongListeningDataaddSongsFromFolderStructures,resyncSongsLibrary
-
Playlists (
ipcMain.handle):addNewPlaylist,removePlaylists,renameAPlaylistaddSongsToPlaylist,removeSongFromPlaylist,addArtworkToAPlaylistexportPlaylist,importPlaylist
-
Metadata Management (
ipcMain.handle):getSongId3Tags,updateSongId3Tags,isMetadataUpdatesPendingreParseSong(re-extract metadata from file)
-
Lyrics (
ipcMain.handle):getSongLyrics,saveLyricsToSonggetTranslatedLyrics,romanizeLyrics,convertLyricsToPinyin,convertLyricsToRomaja,resetLyrics
-
Search & Filtering (
ipcMain.handle):search(filters, value, updateHistory, useSimilarity)getArtistData,getGenresData,getAlbumData,getPlaylistData
-
External APIs (
ipcMain.handle):- Last.fm:
scrobbleSong,sendNowPlayingSongDataToLastFM,getSimilarTracksForASong,getAlbumInfoFromLastFM - Metadata search:
searchSongMetadataResultsInInternet,fetchSongMetadataFromInternet
- Last.fm:
-
File System Operations (
ipcMain.handle):getFolderStructures,getFolderData,removeAMusicFolderblacklistFolders,restoreBlacklistedFolders,toggleBlacklistedFoldersblacklistSongs,restoreBlacklistedSongsdeleteSongsFromSystem(with abort signal for cancellation)
-
System Dialogs (
ipcMain.handle):getImgFileLocation,getFolderLocation(useshowOpenDialog)
-
Settings & User Data (
ipcMain.handle):getUserData,getUserSettings,saveUserSettingsgetStorageUsage,getDatabaseMetrics
-
Theme & Visual (
ipcMain.handle):generatePalettes(extract color palettes from artwork)getArtworksForMultipleArtworksCover(for playlist covers)
-
Event Listeners (
ipcMain.on):app/changeAppTheme,app/player/songPlaybackStateChangeapp/setDiscordRpcActivity,app/networkStatusChangeapp/stopScreenSleeping,app/allowScreenSleepingapp/resetApp,app/restartRenderer,app/restartAppapp/openLogFile,app/openInBrowser,app/openDevTools
Handler Pattern:
// Async operations (return data)
ipcMain.handle('app/getSong', (_, id: string) => sendAudioData(id));
// Fire-and-forget (no return)
ipcMain.on('app/player/songPlaybackStateChange', (_, isPlaying: boolean) => toggleAudioPlayingState(isPlaying));
// With abort signal (cancellable long operations)
ipcMain.handle('app/deleteSongsFromSystem', (_, paths: string[], isPermanent: boolean) =>
deleteSongsFromSystem(paths, abortSignal, isPermanent),
);
Database Layer (src/main/db/)
Technology: Drizzle ORM with PGlite (local PostgreSQL in WASM)
Structure:
db/
โโโ db.ts # Database initialization, migrations, seeding
โโโ schema.ts # Drizzle table schemas
โโโ seed.ts # Default data seeding
โโโ queries/ # Organized query modules
โโโ songs.ts # Song CRUD operations
โโโ artists.ts # Artist queries
โโโ albums.ts # Album queries
โโโ playlists.ts # Playlist management
โโโ genres.ts # Genre operations
โโโ folders.ts # Music folder tracking
โโโ history.ts # Listening history
โโโ listens.ts # Song play counts and stats
โโโ settings.ts # User preferences
โโโ artworks.ts # Artwork caching
โโโ palettes.ts # Color palette storage
โโโ queue.ts # Queue state persistence
โโโ search.ts # Search history
โโโ other.ts # Database metrics, utilities
Database Initialization (db.ts):
// PGlite with extensions
const pgliteInstance = await PGlite.create(DB_PATH, {
extensions: { pg_trgm, citext }, // Full-text search, case-insensitive text
});
// Drizzle ORM instance
export const db = drizzle(pgliteInstance, { schema });
// Run migrations automatically on startup
await migrate(db, { migrationsFolder });
await seedDatabase(); // Insert default settings if needed
// Graceful shutdown
export const closeDatabaseInstance = async () => {
await pgliteInstance.close();
};
Query Pattern (example from songs.ts):
import { db } from '../db';
import { songs, artists } from '../schema';
export async function getSongById(id: number) {
const [song] = await db.select().from(songs).where(eq(songs.id, id)).limit(1);
return song;
}
export async function getAllSongs(sortType?: SongSortTypes, filterType?: SongFilterTypes) {
let query = db.select().from(songs);
if (filterType === 'favorites') {
query = query.where(eq(songs.isFavorite, true));
}
if (sortType === 'aToZ') {
query = query.orderBy(asc(songs.title));
}
return await query;
}
Migrations: SQL files in resources/drizzle/ (managed by drizzle-kit generate)
Core Business Logic (src/main/core/)
Organization: Each feature in its own file (50+ files).
Key Modules:
-
Library Management:
addMusicFolder.ts: Scan folder, parse songs, insert to DBcheckForNewSongs.ts: Periodic library synccheckForStartUpSongs.ts: Load queue on app launchgetAllSongs.ts,getSongInfo.ts: Song retrieval with pagination
-
Playlists:
addNewPlaylist.ts,removePlaylists.ts,renameAPlaylist.tsaddSongsToPlaylist.ts,removeSongFromPlaylist.tsaddArtworkToAPlaylist.ts: Custom playlist coversexportPlaylist.ts,importPlaylist.ts: M3U support
-
Metadata:
sendSongId3Tags.ts: Read tags via music-metadataupdateSongId3Tags.ts(main): Write tags via node-id3 (MP3 only)saveLyricsToSong.ts: Embed lyrics in ID3 tagsconvertParsedLyricsToNodeID3Format.ts: Format conversion
-
Search & Discovery:
fetchArtistData.ts,fetchAlbumData.ts,getGenresInfo.tsgetArtistDuplicates.ts,resolveDuplicates.ts: Duplicate detection/mergingresolveSeparateArtists.ts: Split combined artist entriesresolveFeaturingArtists.ts: Extract featuring artists from titles
-
External APIs:
fetchSongInfoFromLastFM.ts: Scrobbling, metadata enrichmentgetArtistInfoFromNet.ts: Artist bio and imagesgetSongLyrics.ts: Fetch from multiple lyrics APIs
-
User Data:
toggleLikeSongs.ts,toggleLikeArtists.ts: Favorites managementupdateSongListeningData.ts: Play counts, skip counts, last playedgetListeningData.ts: Analytics dataclearSongHistory.ts: Privacy/cleanup
-
File Operations:
deleteSongsFromSystem.ts: Delete files (with abort support)blacklistSongs.ts,blacklistFolders.ts: Exclusion filterssaveArtworkToSystem.ts: Export artwork as image
-
Data Portability:
exportAppData.ts,importAppData.ts: Full app backup/restoregetStorageUsage.ts: Disk usage stats
Pattern (typical core function):
// src/main/core/toggleLikeSongs.ts
import { db } from '@main/db/db';
import { songs } from '@main/db/schema';
import { dataUpdateEvent } from '@main/main';
import logger from '@main/logger';
export default async function toggleLikeSongs(songIds: string[], isLikeSong?: boolean) {
try {
const songIdsNum = songIds.map(Number);
// Update database
await db
.update(songs)
.set({ isFavorite: isLikeSong ?? true })
.where(inArray(songs.id, songIdsNum));
// Notify renderer of data change
dataUpdateEvent('songs/favoriteStatus', songIds);
logger.info(`Toggled like status for ${songIds.length} songs`, { songIds, isLikeSong });
return { success: true };
} catch (error) {
logger.error('Failed to toggle like songs', { songIds, error });
throw error;
}
}
File System Watchers (src/main/fs/)
Purpose: Real-time library synchronization when files change.
Key Files:
addWatchersToFolders.ts: Watch music folders for song additions/removalsaddWatchersToParentFolders.ts: Watch parent directories for folder renamescheckFolderForContentModifications.ts: Detect new/deleted songscheckForFolderModifications.ts: Handle folder renames/movescontrolAbortControllers.ts: Cancellation for long-running watchersresolveFilePaths.ts: Path normalization (handlenora://protocol)
Watcher Pattern:
// Uses Node.js fs.watch() with recursive option
const watcher = fs.watch(folderPath, { recursive: true }, (eventType, filename) => {
if (eventType === 'rename') {
// Song added or deleted
checkFolderForContentModifications(folderPath);
}
});
// Cleanup on app quit
abortController.signal.addEventListener('abort', () => watcher.close());
Debouncing: Events are aggregated and sent to renderer after 1 second delay (via dataUpdateEvent() in main.ts).
Other Services (src/main/other/)
-
Artwork Management (
artworks.ts):- Extract embedded artwork from audio files
- Cache artwork to temp directory
- Generate artwork URLs (
nora://localfiles/...)
-
Discord Rich Presence (
discordRPC.ts):- Integration with Discord RPC library
- Display currently playing song with artwork
-
Color Palette Generation (
generatePalette.ts):- Extract dominant colors from artwork using
node-vibrant - Used for dynamic themes in renderer
- Extract dominant colors from artwork using
-
Last.fm Integration (
lastFm/):- Scrobbling (
scrobbleSong.ts) - Now playing updates (
sendNowPlayingSongDataToLastFM.ts) - Similar tracks (
getSimilarTracks.ts) - Album info (
getAlbumInfoFromLastFM.ts) - Authentication (
../auth/manageLastFmAuth.ts)
- Scrobbling (
Song Parsing (src/main/parseSong/)
Purpose: Extract metadata from audio files.
Process:
- Read file with
music-metadatalibrary - Extract tags (title, artist, album, year, genre, etc.)
- Extract embedded artwork
- Generate song ID (hash of file path)
- Store in database
Supported Formats: MP3, WAV, OGG, AAC, M4A, M4R, OPUS, FLAC (from package.json)
Metadata Editing: Only MP3 files support writing tags (via node-id3 library).
Logging (src/main/logger.ts)
Library: Winston logger with file and console transports.
Log Levels: error, warn, info, debug, verbose
Log File Location: app.getPath('userData')/logs/app.log
Pattern:
import logger from '@main/logger';
logger.info('User added songs to playlist', { playlistId, songIds });
logger.error('Failed to fetch lyrics', { error, songId });
logger.debug('Database query executed', { query, duration });
Main Process State Management
Key Insight: Unlike the renderer (which uses TanStack Store), the main process uses module-level variables for state:
// main.ts
export let mainWindow: BrowserWindow; // Exported for access in other modules
let playerType: PlayerTypes; // Private module state
let currentSongPath: string; // Persisted for cleanup operations
State Persistence: User settings stored in database via saveUserSettings() (not localStorage).
State Synchronization: Main process notifies renderer via:
mainWindow.webContents.send(channel, data)for eventsdataUpdateEvent()for debounced library updates- IPC responses for request/reply patterns
๐๏ธ Architecture Patterns
1. Custom Hook Architecture
Philosophy: App.tsx has been refactored from 2,013 lines to 365 lines (~82% reduction) by extracting logic into focused, reusable hooks.
Hook Categories:
- Lifecycle Hooks:
useAppLifecycle,useAppUpdates - Player Hooks:
useAudioPlayer,usePlayerControl,usePlayerQueue,usePlayerNavigation - Queue Hooks:
useQueueManagement,usePlayerQueue - Settings Hooks:
usePlaybackSettings,useDynamicTheme - Integration Hooks:
useMediaSession,useDiscordRpc,useListeningData - UI Hooks:
useContextMenu,useWindowManagement,usePromptMenu,useNotifications - Utility Hooks:
useKeyboardShortcuts,useNetworkConnectivity,useDataSync,useBooleanStateChange
Hook Patterns:
// โ
GOOD: Module-level singleton for services accessed by intervals/timers
const player = new AudioPlayer();
export function useAudioPlayer() {
useEffect(() => {
const interval = setInterval(() => {
// player is always the same instance, no stale closures
if (!player.paused) dispatchCurrentSongTime();
}, 100);
return () => clearInterval(interval);
}, []);
return player; // Return instance directly (not wrapped in ref)
}
// โ BAD: Ref-based singletons with intervals lead to stale closure issues
export function useAudioPlayer() {
const playerRef = useRef<AudioPlayer>();
useEffect(() => {
playerRef.current = new AudioPlayer(); // Ref assigned after effect creation
const interval = setInterval(() => {
// playerRef.current may be null/stale when closure was created
if (!playerRef.current?.paused) dispatchCurrentSongTime();
}, 100);
return () => clearInterval(interval);
}, []);
return playerRef.current; // Timing issues: ref not ready yet
}
Key Insight: For singleton services (AudioPlayer, PlayerQueue) that are accessed by intervals, timers, or event handlers, use module-level initialization (before the hook function), NOT refs. Refs inside hooks with intervals can capture stale/null references due to closure timing.
2. State Management
TanStack Store (src/renderer/src/store/store.ts):
import { Store } from '@tanstack/store';
import { reducer as appReducer, DEFAULT_REDUCER_DATA } from '../other/appReducer';
import storage from '../utils/localStorage';
export const store = new Store(DEFAULT_REDUCER_DATA);
export const dispatch = (options: AppReducerStateActions) => {
store.setState((state) => {
return appReducer(state, options);
});
};
// Automatically sync state to localStorage
store.subscribe((state) => {
storage.setLocalStorage(state.currentVal.localStorage);
});
Pattern:
- Centralized store with reducer pattern (similar to Redux)
dispatch()for all state updates- Automatic localStorage persistence via subscription
- Access state in components:
store.stateor hooks:useStore(store, (state) => state.propertyName)
Common Dispatch Actions:
dispatch({ type: 'UPDATE_CURRENT_SONG_DATA', data: songData });
dispatch({ type: 'UPDATE_PLAYER_STATE', data: { isPlaying: true } });
dispatch({ type: 'UPDATE_QUEUE_DATA', data: queueData });
dispatch({ type: 'UPDATE_LOCAL_STORAGE', data: localStorage });
3. IPC Communication (Electron)
Preload Bridge (src/preload/index.ts):
Exposes window.api to renderer process with categorized namespaces:
// Main categories
window.api.properties; // App properties (isInDevelopment, commandLineArgs)
window.api.windowControls; // Window management (minimize, maximize, close, etc.)
window.api.playerControls; // Playback control (play/pause, skip, like, etc.)
window.api.audioLibraryControls; // Library operations (getSong, getAllSongs, etc.)
window.api.theme; // Theme management (changeAppTheme, listenForSystemThemeChanges)
window.api.dataUpdates; // Real-time library updates (onSongDataUpdates, etc.)
window.api.quitEvent; // App lifecycle (beforeQuitEvent, etc.)
window.api.folderData; // Folder operations (addMusicFolders, etc.)
window.api.playlistControls; // Playlist CRUD operations
window.api.lyricsData; // Lyrics fetching and management
window.api.settingsHelpers; // Settings utilities (networkStatusChange, etc.)
window.api.unknownSource; // External file associations
Patterns:
-
Invoke (async):
await window.api.audioLibraryControls.getSong(songId) -
Send (fire-and-forget):
window.api.playerControls.songPlaybackStateChange(true) -
Event listeners (with cleanup):
useEffect(() => { const handleEvent = (e: unknown) => { /* handler */ }; window.api.playerControls.toggleSongPlayback(handleEvent); return () => { window.api.playerControls.removeTogglePlaybackStateEvent(handleEvent); }; }, [dependencies]);
Main Process (src/main/ipc.ts):
- Registers all IPC handlers using
ipcMain.handle()(async) andipcMain.on()(sync) - Handlers call business logic in
src/main/core/orsrc/main/db/
4. Event-Driven Architecture
Custom Events:
// Player position updates (dispatched by useAudioPlayer every 100ms)
const playerPositionChange = new CustomEvent('player/positionChange', {
detail: roundTo(player.currentTime || 0, 2),
});
document.dispatchEvent(playerPositionChange);
// Queue changes (dispatched by PlayerQueue class)
this.emit('queueChange', this.queue);
this.emit('positionChange', this.currentSongIndex);
Pattern: Use event emitters (PlayerQueue) and CustomEvents (document-level) for real-time updates without tight coupling.
5. Data Fetching with TanStack Query
All data fetching follows a centralized query key pattern using @lukemorales/query-key-factory. Every query is
defined in src/renderer/src/queries/ with the same IPC request structure.
Query Module Organization
| Module | Queries | Purpose |
|---|---|---|
songs.ts | all(), allSongInfo(), singleSongInfo(), favorites(), history(), queue(), similarTracks() | Song catalog and playlist-specific queries |
aritsts.ts | all(), single(), fetchOnlineInfo() | Artist data and online metadata |
albums.ts | all(), single() | Album queries |
playlists.ts | all(), single(), songArtworks() | Playlist data and artwork |
home.ts | recentlyPlayedSongs(), recentSongArtists(), mostLovedSongs() | Home page metrics |
genres.ts | all(), single() | Genre queries |
listens.ts | single() | Song listening data |
search.ts | recentResults(), query() | Search queries and history |
settings.ts | Settings/user preferences with mutations | User configuration |
other.ts | databaseMetrics() | App-level metrics |
Query Keys Factory Pattern
import { createQueryKeys } from '@lukemorales/query-key-factory';
// Simple query (no parameters)
export const homeQuery = createQueryKeys('home', {
recentlyPlayedSongs: {
queryKey: null,
queryFn: async (): Promise<SongData[]> => {
try {
const { data: playlists } = await window.api.playlistsData.getPlaylistData([SpecialPlaylists.History]);
const historyPlaylist = playlists[0];
if (!historyPlaylist?.songs.length) return [];
const songs = await window.api.audioLibraryControls.getSongInfo(
historyPlaylist.songs,
undefined,
undefined,
35,
true,
);
return Array.isArray(songs) ? songs : [];
} catch (error) {
console.error(error);
return [];
}
},
},
});
// Parameterized query
export const songQuery = createQueryKeys('songs', {
all: (data: { sortType: SongSortTypes; filterType?: SongFilterTypes; start?: number; end?: number }) => {
const { sortType = 'addedOrder', start = 0, end = 0 } = data;
return {
queryKey: [`sortType=${sortType}`, `start=${start}`, `end=${end}`, `limit=${end - start}`],
queryFn: () => window.api.audioLibraryControls.getAllSongs(sortType, undefined, { start, end }),
};
},
});
Usage Patterns
Route Loader (Pre-fetching):
export const Route = createFileRoute('/main-player/home/')({
component: HomePage,
loader: async () => {
await queryClient.ensureQueryData(songQuery.all({ sortType: 'dateAddedDescending', start: 0, end: 30 }));
await queryClient.ensureQueryData(homeQuery.recentlyPlayedSongs);
},
});
Component (Suspense):
const { data: recentlyPlayedSongs } = useSuspenseQuery(homeQuery.recentlyPlayedSongs);
const { data: latestSongs } = useSuspenseQuery(songQuery.all({ sortType: 'dateAddedDescending', start: 0, end: 30 }));
Best Practices
- Keep queries pure: All error handling inside queryFn; return safe defaults (empty arrays, null)
- Centralize all queries: Define in
src/renderer/src/queries/*.ts, never inline in components - Array stability: Sort array parameters before joining in cache keys:
songIds=${[...songIds].sort().join(',')} - Type-safe defaults: Return
Promise<SongData[]>(empty array) instead of throwing - Pre-fetch in loaders: Use
queryClient.ensureQueryData()in route loaders for better UX - Invalidate after mutations: Call
queryClient.invalidateQueries()to refresh stale data
For detailed patterns, file structure, and debugging tips, see the TanStack Query Patterns skill.
๐จ Styling and Theming
Tailwind CSS v4
- Configuration:
tailwind.config.js - Plugin:
@tailwindcss/viteintegrated inelectron.vite.config.ts - Dark mode: Toggled via
document.body.classList.toggle('dark')(managed inuseDynamicThemehook)
Dynamic Theme System
Pattern (in useDynamicTheme hook):
- Extract color palette from song artwork using
node-vibrant - Apply colors to CSS variables or Tailwind classes
- Support background images with blur/opacity overlays
- Dark mode detection from settings:
userSettings.isDarkMode
Dark Mode Management:
// useDynamicTheme.tsx
useEffect(() => {
const { data: userSettings } = useSuspenseQuery(settingsQuery.all);
if (userSettings.isDarkMode) {
document.body.classList.add('dark');
} else {
document.body.classList.remove('dark');
}
}, [userSettings.isDarkMode]);
๐งช Testing
Vitest Configuration (vitest.config.ts)
- Test Files:
test/**/*.test.ts - Coverage: Collected in
coverage/directory with v8 provider - Environment: Node.js
- Path Aliases: Configured for
@renderer,@main,@common, etc.
Running Tests
npm test # Run all tests in watch mode
npm run coverage # Run tests with coverage report
npm run check-types # TypeScript type checking
npm run lint # ESLint
npm run prettier-check # Prettier formatting check
Test Patterns
// Example test structure
import { describe, test, expect } from 'vitest';
import { parseLyrics } from '@common/parseLyrics';
describe('parseLyrics', () => {
test('should parse synced lyrics', () => {
const input = '[00:12.00]Line 1\n[00:15.00]Line 2';
const result = parseLyrics(input);
expect(result.isSynced).toBe(true);
expect(result.lyrics).toHaveLength(2);
});
});
Mocking Patterns
// Mock with vi.fn() and vi.spyOn()
import { vi } from 'vitest';
// Module mocking
vi.mock('../../../src/main/logger', () => ({
default: {
info: vi.fn((...data) => console.log(...data)),
error: vi.fn((...data) => console.error(...data)),
warn: vi.fn((...data) => console.warn(...data)),
},
}));
// Spy on console methods
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
๐จ Development Workflows
Build Commands
# Development
npm start # Preview production build
npm run dev # Hot-reload development mode
# Type Checking
npm run typecheck # Check all TypeScript
npm run typecheck:node # Check main process only
npm run typecheck:web # Check renderer process only
# Building
npm run build # Build all processes (main + preload + renderer)
npm run build:win # Build Windows installer (x64 + arm64)
npm run build:win-x64 # Build Windows installer (x64 only)
npm run build:mac # Build macOS installer
npm run build:linux # Build Linux installer
npm run build:unpack # Build without packaging (for testing)
# Database (Drizzle ORM)
npm run db:migrate # Run pending migrations
npm run db:generate # Generate migration files from schema
npm run db:push # Push schema changes without migrations
npm run db:studio # Open Drizzle Studio (database GUI)
npm run db:drop # Drop database (custom script)
# Routing (TanStack Router)
npm run renderer:generate-routes # Generate route tree
npm run renderer:watch-routes # Watch and auto-generate routes
Project-Specific npm Scripts
# Code Quality
npm run format # Auto-fix formatting with Prettier
npm run lint-fix # Auto-fix linting issues
npm run eslint-inspector # Open ESLint config inspector
# Pre-commit
npm run husky-test # Run before commits (Prettier + tests)
๐จ Common Pitfalls and Solutions
1. Stale Closures with Intervals
Problem: Using useRef for singletons accessed by intervals/timers leads to stale references.
Solution: Use module-level initialization (see Architecture Patterns > Custom Hook Architecture).
2. Event Listener Cleanup
Problem: Forgetting to remove IPC event listeners causes memory leaks.
Solution: Always return cleanup function in useEffect:
useEffect(() => {
const handler = (e: unknown) => {
/* ... */
};
window.api.playerControls.toggleSongPlayback(handler);
return () => {
window.api.playerControls.removeTogglePlaybackStateEvent(handler);
};
}, [dependencies]);
3. localStorage Sync Timing
Problem: localStorage updates may not be immediately available after dispatch.
Solution: Store subscription in store.ts ensures automatic persistence. For immediate reads, use
storage.getLocalStorage() directly.
4. Dark Mode Not Updating
Problem: Dark mode class not applied to document.body.
Solution: Ensure useDynamicTheme is called in App.tsx and uses useSuspenseQuery(settingsQuery.all) for reactive
updates.
5. TanStack Router Migration (In Progress)
Status: Custom page navigation (changeCurrentActivePage, updatePageHistoryIndex) is deprecated but still present
in App.tsx (~90 lines).
Action Required: Do not add new dependencies on these functions. Use TanStack Router's <Link>, useNavigate(),
and useRouter() instead.
Cleanup Planned: These functions will be removed once all pages migrate to TanStack Router routes.
๐ Coding Conventions
Naming Conventions
Follow the language-agnostic style guide in coding_style_guide.instructions.md:
- Descriptive Names: Use clear, context-rich names (avoid
data,user,info,temp)- โ
authenticatedUser,songMetadata,playlistQueue - โ
user,data,list
- โ
- Functions: Start with verbs
- โ
calculateDuration(),fetchSongData(),validatePlaylist() - โ
duration(),song(),playlist()
- โ
- Constants:
UPPERCASE_WITH_UNDERSCORES- โ
MAX_QUEUE_SIZE,DEFAULT_VOLUME
- โ
Function Structure
- Keep Functions Small: Aim for <30-50 lines
- Single Responsibility: One clear purpose per function
- Guard Clauses: Use early returns to avoid deep nesting
// โ
GOOD: Guard clauses flatten logic
function playSong(songId: string) {
if (!songId) {
console.error('No song ID provided');
return;
}
const song = await getSongById(songId);
if (!song) {
console.error('Song not found');
return;
}
// Main logic here (flat, readable)
player.loadSong(song);
player.play();
}
// โ BAD: Nested conditionals
function playSong(songId: string) {
if (songId) {
getSongById(songId).then((song) => {
if (song) {
player.loadSong(song);
player.play();
} else {
console.error('Song not found');
}
});
} else {
console.error('No song ID provided');
}
}
Import Organization
Use eslint-plugin-simple-import-sort for automatic sorting:
// 1. External dependencies
import { useEffect, useCallback } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
// 2. Internal path aliases
import { settingsQuery } from '@renderer/queries';
import storage from '@renderer/utils/localStorage';
// 3. Relative imports
import AudioPlayer from '../other/player';
๐ Key Files Reference
Critical Files (Always Check Before Changes)
Renderer Process:
| File | Purpose | Why Important |
|---|---|---|
src/renderer/src/App.tsx | Main app component | Central integration point for all hooks, currently ~365 lines (down from 2,013) |
src/renderer/src/store/store.ts | Global state management | All state updates go through dispatch() |
src/renderer/src/hooks/useAppLifecycle.tsx | App initialization | Event listener setup, lifecycle management (~355 lines) |
src/renderer/src/other/appReducer.tsx | State reducer logic | Defines all state update actions |
Main Process:
| File | Purpose | Why Important |
|---|---|---|
src/main/main.ts | Electron entry point | Window initialization, app lifecycle, system integration (835 lines) |
src/main/ipc.ts | IPC handler registration | Maps all IPC calls to main process logic, 100+ handlers |
src/main/db/db.ts | Database initialization | PGlite setup, migrations, Drizzle ORM instance |
src/main/db/schema.ts | Database schema | All table definitions (songs, artists, albums, playlists, etc.) |
src/main/db/queries/songs.ts | Song CRUD operations | Most frequently used database queries |
src/main/db/queries/settings.ts | User settings | Get/save app preferences (stored in DB, not localStorage) |
src/main/logger.ts | Logging infrastructure | Winston logger configuration for debugging |
IPC Bridge:
| File | Purpose | Why Important |
|---|---|---|
src/preload/index.ts | IPC bridge | Defines entire window.api interface exposed to renderer (583 lines) |
Configuration:
| File | Purpose | Why Important |
|---|---|---|
package.json | Dependencies & scripts | Build commands, supported file extensions, npm scripts |
electron.vite.config.ts | Build configuration | Main + preload + renderer process bundling |
drizzle.config.ts | Database ORM config | Migration paths, schema location |
Singleton Services (Module-Level)
| File | Service | Pattern |
|---|---|---|
src/renderer/src/other/player.ts | AudioPlayer class | Module-level instance in useAudioPlayer.tsx |
src/renderer/src/other/playerQueue.ts | PlayerQueue class | Ref-based in usePlayerQueue.tsx (initialized from localStorage) |
Configuration Files
| File | Purpose |
|---|---|
electron.vite.config.ts | Build configuration (main + preload + renderer) |
tailwind.config.js | Tailwind CSS customization |
tsconfig.json / tsconfig.*.json | TypeScript compiler options (multiple configs for different processes) |
drizzle.config.ts | Drizzle ORM database configuration |
vitest.config.ts | Vitest testing configuration |
electron-builder.yml | Electron installer configuration |
tsr.config.json | TanStack Router configuration |
๐ Next Steps for AI Agents
When Starting a New Task
- Read
README.mdfor feature overview and user-facing functionality - Check
changelog.mdfor recent changes and ongoing work - Review
package.jsonfor available scripts and supported file formats - Scan
src/renderer/src/App.tsxto understand current hook integration - Check
src/preload/index.tsfor available IPC methods - Read relevant hook files in
src/renderer/src/hooks/for feature-specific logic
When Adding New Features
- Create a focused custom hook (avoid adding logic directly to App.tsx)
- Follow module-level singleton pattern for services with intervals/timers
- Add IPC methods in
src/preload/index.tsandsrc/main/ipc.tsif main process access is needed - Update state via
dispatch()for UI updates - For data fetching:
- Create a new query module in
src/renderer/src/queries/usingcreateQueryKeys - Define all related queries in one factory
- Use
useSuspenseQuery()in components, not custom fetch hooks - Pre-fetch in route loaders with
queryClient.ensureQueryData()
- Create a new query module in
- Add cleanup functions for all event listeners
For detailed TanStack Query patterns, see the TanStack Query Patterns skill.
When Refactoring
- Check
REFACTORING_APP_ANALYSIS.md(if exists) for ongoing refactoring plans - Maintain single responsibility for each hook/component
- Extract reusable logic into utility functions in
src/renderer/src/utils/orsrc/common/ - Consolidate query modules if similar data fetches can be grouped (e.g., artist + album queries in
artists.ts) - Test incrementally after each change (use
npm test)
When Fixing Bugs
- Check
get_errorstool output for TypeScript/ESLint errors - Review event listener cleanup for memory leaks
- Verify localStorage sync for state persistence issues
- Check IPC handler existence in
src/main/ipc.tsfor "method not found" errors - Validate hook dependencies in
useEffectarrays - Inspect query cache for stale data issues: use React Query DevTools or
queryClient.getQueryData()
๐ Additional Resources
- Electron Docs: https://www.electronjs.org/docs/latest
- TanStack Store: https://tanstack.com/store/latest
- TanStack Query: https://tanstack.com/query/latest
- TanStack Router: https://tanstack.com/router/latest
- Tailwind CSS v4: https://tailwindcss.com/docs
- Drizzle ORM: https://orm.drizzle.team/docs
Agent Skills
- TanStack Query Patterns (
.agents/skills/tanstack-query-patterns/SKILL.md): Comprehensive guide for creating and consuming queries following Nora conventions (query module organization, file structure, error handling, cache invalidation, debugging)
๐ค Contributing
When making changes:
- Run type checks:
npm run typecheck - Run tests:
npm test - Format code:
npm run format - Fix linting:
npm run lint-fix - Test production build:
npm run build:unpackโnpm start
Pre-commit: Husky runs npm run husky-test (Prettier check + tests).
โจ Current Status
Refactoring Progress: App.tsx reduced from 2,013 lines to 365 lines (81.9% reduction).
Remaining Work:
- Phase 5.1: Remove deprecated page navigation (~90 lines) - blocked on TanStack Router migration
- Phase 13: Final cleanup (~15-20 lines) - polish and remove commented code
Target: 250-270 lines (~87-88% total reduction) after TanStack Router migration completes.
This document is maintained to help AI coding agents be immediately productive in the Nora codebase. Update as architectural patterns evolve.
Trust
Not scanned yet. Artifacts are graded after they are crawled, so a recently discovered one may have no result for a while.
Versions
git-e9f81ce729872026-08-04