IMPORTANT: This file contains long-term architectural knowledge. When making meaningful architectural changes, update both this file and README.md to keep documentation in sync.
B-Player is an Electron application with a clear frontend/backend separation:
┌─────────────────────────────────────────────────────────────────┐
│ Electron Process │
├─────────────────────────────────┬───────────────────────────────┤
│ Main Process (Backend) │ Renderer Process (Frontend) │
│ electron/ │ src/ │
├─────────────────────────────────┼───────────────────────────────┤
│ • IPC Handlers (handler.ts) │ • React Components │
│ • Database (db.ts) │ • Redux Store (store/) │
│ • Services (Music/Playlist) │ • RTK Query (apiSlice.ts) │
│ • File System I/O │ • UI Rendering │
│ • yt-dlp Integration │ • User Interactions │
└─────────────────────────────────┴───────────────────────────────┘
IPC Communication (ipcBaseQuery + Context Bridge)
- better-sqlite3 - Synchronous SQLite for data persistence
- ytdlp-nodejs - Music downloading from web sources
- @ffprobe-installer/ffprobe - Metadata extraction (duration, file size)
- Zod - Runtime type validation for all data
Music Pieces Table:
CREATE TABLE music_pieces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL,
hash TEXT UNIQUE NOT NULL, -- SHA1 of source URL for deduplication
srcLink TEXT NOT NULL,
author TEXT NOT NULL,
authorLink TEXT NOT NULL,
duration REAL NOT NULL, -- In seconds
fileSize INTEGER NOT NULL, -- In bytes
playCount INTEGER DEFAULT 0,
lastPlayed DATETIME
)Playlists Table:
CREATE TABLE playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL UNIQUE,
description TEXT,
isPinned INTEGER DEFAULT 0, -- 0 or 1
songCount INTEGER DEFAULT 0, -- Denormalized for performance
totalDuration INTEGER DEFAULT 0 -- Denormalized for performance
)Junction Table (Many-to-Many):
CREATE TABLE playlist_music (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlistId INTEGER NOT NULL,
musicId INTEGER NOT NULL,
position INTEGER NOT NULL, -- Order within playlist
addedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (playlistId) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (musicId) REFERENCES music_pieces(id) ON DELETE CASCADE,
UNIQUE(playlistId, position)
)Key Design Decisions:
- Denormalization:
songCountandtotalDurationstored on playlists to avoid expensive joins - Automatic Timestamps: SQLite triggers update
updatedAton any row modification - Cascade Deletes: Deleting a music piece removes all junction records automatically
- Position-Based Ordering: Explicit
positionfield allows custom ordering
Hash-Based Directory Structure:
userData/music_storage/
└── <sha1_hash_of_url>/
├── audio.{ext} # Downloaded audio file (original extension)
└── thumbnail.{ext} # Cover art (extension from source)
Benefits:
- Automatic deduplication (same URL = same directory)
- Safe deletion (delete entire directory by hash)
- File integrity verification via hash
Context Bridge (electron/preload.ts):
contextBridge.exposeInMainWorld('ipcRenderer', {
invoke(...args) { /* safe wrapper */ },
on(...args) { /* safe wrapper */ },
off(...args) { /* safe wrapper */ },
send(...args) { /* safe wrapper */ }
});IPC Handlers (electron/handler.ts):
All backend operations exposed via ipcMain.handle():
| Channel | Purpose | Returns |
|---|---|---|
getAllMusicPieces |
Fetch all music | MusicPiece[] |
getAllPlaylists |
Fetch all playlists | Playlist[] |
getPlaylistWithMusic |
Fetch playlist with tracks | PlaylistWithMusic |
importMusic |
Import single track | MusicPiece |
importPlaylist |
Import entire playlist | Playlist |
deleteMusicPiece |
Delete track from library | void |
deletePlaylist |
Delete playlist | void |
removeMusicFromPlaylist |
Remove track from playlist | void |
updatePlaylist |
Update playlist metadata | Playlist |
updateLastPlayed |
Track playback stats | void |
getMusicDir |
Get file path for hash | string |
openInExplorer |
Open system file browser | void |
Progress Updates:
During imports, backend sends progress via event.sender.send('import-progress', progress):
interface DownloadProgress {
stage: 'scraping' | 'downloading' | 'metadata' | 'complete';
current?: number;
total?: number;
message: string;
}MusicService (electron/db.ts):
createMusicPiece()- Insert with Zod validationgetMusicPieceById()- Fetch by IDgetMusicPieceByHash()- Fetch by hash (for dedup check)getAllMusicPieces()- Fetch allupdateLastPlayed()- Increment play count, update timestampdeleteMusicPiece()- Delete from DB, update playlists, remove files
PlaylistService (electron/db.ts):
createPlaylist()- Insert with Zod validationgetPlaylistById()- Fetch by IDgetPlaylistByName()- Fetch by name (for system playlists)getAllPlaylists()- Fetch allgetPlaylistWithMusic()- Fetch with tracks, ordered by positioninsertMusicToPlaylist()- Add track at position, shift othersremoveMusicFromPlaylist()- Remove track, reorder remainingupdatePlaylist()- Update name/descriptiondeletePlaylist()- Delete playlist and junctions
Music Import Flow:
- User pastes URL → Frontend calls
importMusic - Scrape metadata (title, author, thumbnail) using hidden BrowserWindow
- Calculate SHA1 hash of URL
- Check if exists by hash (skip if already in library)
- Download audio via yt-dlp to hash-based directory
- Extract duration/size via FFprobe
- Download thumbnail
- Create database record
- Send progress updates throughout
Playlist Import Flow:
- Scrape playlist metadata and all track URLs
- Create playlist record
- For each track: import (skip existing), add to junction table
- Update denormalized
songCountandtotalDuration
- React 18 - Functional components with hooks
- Redux Toolkit - Global state management
- RTK Query - Server state (IPC calls) with caching
- Tailwind CSS - Utility-first styling
- TypeScript - Type safety
Redux Store Structure:
{
api: {
// RTK Query managed state
queries: { /* query results */ },
mutations: { /* mutation results */ },
provided: { /* cache tags */ },
subscriptions: { /* active subscriptions */ }
},
musicPlayer: {
queue: MusicPiece[], // Current playback queue
currentIndex: number, // Currently playing track (-1 if none)
queueSourcePlaylist: string, // Origin of current queue
isPlaying: boolean,
activePlaylist: string, // Currently selected in sidebar
viewMode: "grid" | "list",
isLoading: boolean,
loadingMessage?: string,
loadingProgress: number // 0-100 during import
}
}State Update Pattern: All state updates go through Redux actions — never mutate state directly.
Custom Base Query (src/store/slices/apiSlice.ts:8-15):
const ipcBaseQuery = async (args: { channel: string; args?: unknown[] }) => {
try {
const result = await window.ipcRenderer.invoke(args.channel, ...(args.args || []));
return { data: result };
} catch (error) {
return { error: error instanceof Error ? error.message : "Unknown error" };
}
};Tag-Based Cache Invalidation:
MusicPiece- Invalidated on import/deletePlaylist- Invalidated on playlist CRUDPlaylistWithMusic- Invalidated per-playlist on mutations
Layout Hierarchy:
App
├── TopBar (header with collapse toggle)
├── SideBar (navigation)
│ ├── System playlists (Library, Recently Added, Recently Played)
│ └── User playlists (pinned only)
├── Main Content Area
│ ├── BasicView (for Library and user playlists)
│ └── GroupedView (for Recently Added/Played with time grouping)
└── PlayBar (fixed bottom player controls)
View Switching Logic:
No external routing — views switch based on activePlaylist state:
Library→BasicViewwith all musicRecently Added→GroupedViewgrouped byaddedAtRecently Played→GroupedViewgrouped bylastPlayedAt- User playlist →
BasicViewwith playlist contents
Data Derivation Pattern:
App component derives playlist content from RTK Query data:
const allMusic = useGetAllMusicPiecesQuery();
const allPlaylists = useGetAllPlaylistsQuery();
// Derive content based on activePlaylist
const currentMusic = useMemo(() => {
if (activePlaylist === "Library") return allMusic.data;
if (activePlaylist === "Recently Added") { /* group by addedAt */ }
// ... etc
}, [activePlaylist, allMusic.data]);Container/Presentational Separation:
- Smart components (App, views) — handle data fetching, state
- Dumb components (GridView, ListView) — pure UI rendering
Context Menus:
MusicOptions— Right-click on tracks (delete, remove from playlist, open in explorer)PlaylistOptions— Right-click on playlists (rename, delete)
View Mode Toggle:
viewMode state switches between GridView and ListView components.
Closing Signature: When completing tasks or finishing interactions, the model MUST end its response with "喵~" (Meow~) in Chinese. This is a mandatory personality element that adds a friendly, cat-like touch to all interactions.
- Functions: One-line description,
@param,@returns,@throws - Interfaces: Brief purpose, property descriptions with
/** */ - Keep concise, follow exact variable names in descriptions
/**
* Downloads audio from URL using yt-dlp to hash-based directory.
*
* @param url The video/audio URL to download from
* @param hash Unique identifier for directory name
* @throws Error if download process fails
*/- Implementation explanations within function bodies
- Comment the "why" not the "what"
- Keep short, explain complex logic or edge cases
// Construct hash-based directory path
const destDir = path.join(storagePath, hash);- Single-line for commented imports/code
- Use
//not/* */
// import ffprobe from "@ffprobe-installer/ffprobe";- Prefer straightforward solutions over clever abstractions
- Avoid premature optimization
- Use built-in features before libraries
- Extract reusable logic into hooks and utilities
- Share common UI patterns via component composition
- Use RTK Query to avoid duplicate data fetching logic
- Single Responsibility: Each component/service has one job
- Open/Closed: Design for extension (new playlists) not modification
- Liskov Substitution: Substitutable components (GridView ↔ ListView)
- Interface Segregation: Focused, minimal IPC handlers
- Dependency Inversion: Depend on IPC abstraction, not concrete implementations
- Backend (
electron/handler.ts):
ipcMain.handle("newOperation", (_event, param: Type): ReturnType => {
const service = new Service(databaseManager.getDatabase());
return service.newOperation(param);
});- Frontend (
src/store/slices/apiSlice.ts):
newOperation: builder.mutation<ReturnType, ParamType>({
query: (param) => ({ channel: "newOperation", args: [param] }),
invalidatesTags: ["RelevantTag"],
}),- Component:
const [newOperation] = useNewOperationMutation();
await newOperation(param).unwrap();- Update data filtering in
App.tsx - Add sidebar entry to
SideBar.tsxsystem playlists list - Define grouping logic if time-based (use
GroupedView)
Current approach uses CREATE TABLE IF NOT EXISTS. For migrations:
- Add version tracking table
- Write migration functions
- Run migrations on app startup before initializing services
When to update this file:
- Adding new IPC handlers or patterns
- Changing database schema
- Introducing new component patterns
- Architectural refactoring
When to update README.md:
- New user-facing features
- Technology stack changes
- Build/dev workflow changes
- Installation or setup changes
Rule: If the change affects long-term code structure or how future developers will work with the codebase, update CLAUDE.md. If it affects how users understand or use the app, update README.md. For significant changes, update both.