I wanted a music tool that works the moment you open a browser: type a song name, get results from several platforms at once; click a track and it plays with scrolling lyrics; keep favourites and organise them into playlists. That idea became HALO Music, my own music square.
The point was never to rebuild one platform's page. It was to fit multiple audio sources, a browser player, user data and a desktop wrapper into one lightweight architecture. The frontend is a static page deployable straight to Cloudflare Pages; Pages Functions provide the API; D1 holds accounts, libraries and caches; and Electron wraps the same web app as a Windows desktop client.
The project is for learning, research and demonstration. Music content, audio URLs, lyrics and cover art belong to the respective platforms and original authors; use must comply with local law, upstream terms of service and copyright policy.
There is no separate frontend and desktop player. Both the browser and Electron load the same index.html; the only difference is whether a native window wraps it:
flowchart TB
subgraph Client[Client]
Browser[Browser]
Electron[Electron BrowserWindow]
UI[index.html\nHTML + CSS + runtime JS]
Audio[HTMLAudioElement]
Local[localStorage\ntheme / favourites / playlists]
end
subgraph Cloudflare[Cloudflare Pages]
Static[Pages static assets]
Functions[Pages Functions\n/api/*]
D1[(D1 SQLite)]
end
subgraph Sources[Upstream platforms]
Netease[Netease Cloud Music]
QQ[QQ Music]
Kuwo[Kuwo Music]
Joox[JOOX / fallback resolver]
Qishui[Qishui playlist pages]
end
Browser --> Static
Electron --> UI
Static --> UI
UI --> Audio
UI --> Local
UI --> Functions
Functions --> D1
Functions --> QQ
Functions --> Kuwo
Functions --> Qishui
UI --> Netease
UI --> Joox
Functions --> Joox
The split buys three things: static assets ride Pages' CDN; requests needing keys, signatures or CORS handling live in Functions; and player state stays in the browser, so audio never streams through my own server.
To keep deployment and the desktop build small, there is no large UI framework. Structure, styles and runtime logic live in index.html, styles.css and app.js. A handful of variables describe the current session:
let tracks = []
let currentIndex = 0
let currentList = tracks
let isPlaying = false
let repeatOn = true
let liked = new Set(JSON.parse(localStorage.getItem("halo-liked-tracks") || "[]"))
const audio = new Audio()
Search, player, lyrics and queue all re-render around that state. Track rows, album cards and player buttons use event delegation, so clicking a song only updates the index, the playback context and the <audio> element — never a second player instance.
The platforms' search responses differ. Each adapter extracts song id, title, artist, album, cover and duration into a common object. In the browser, search fans out to Netease, QQ Music and Kuwo at once:
const results = await Promise.allSettled([
searchNetease(keyword, limit),
searchQQ(keyword, limit),
searchKuwo(keyword, limit),
])
const groups = results.map((item) =>
item.status === "fulfilled" ? item.value : [],
)
return interleave(groups)
Promise.allSettled is deliberate: when one source is temporarily down the others should still show. interleave alternates platforms so the first half of the list is not all from one place, and dedupeTracks filters on platform id plus a normalised "title + artist" key. Normalisation strips spaces, hyphens and brackets so the same recording does not appear three times.
Server-side adapters also cover QQ, Kuwo and JOOX — cases that need proxying or a fallback resolver — while playlist import additionally supports public Netease, Qishui and QQ links. That lets "search indexing" and "audio URL resolution" evolve separately: one platform changing its API cannot take every feature down with it.
Search results carry only the metadata needed to render a list; real playback URLs for QQ and Kuwo tracks are fetched when the user presses play. ensureTrackDetails picks an adapter from track.source and collapses concurrent requests for the same track through loadingPromise:
async function ensureTrackDetails(track) {
if (track.detailsLoaded && track.audioUrl) return true
if (track.loadingPromise) return track.loadingPromise
track.loadingPromise = resolveBySource(track)
.finally(() => { track.loadingPromise = null })
return track.loadingPromise
}
Only after an address arrives does the code set audio.src and call audio.load(). On failure it never writes short-lived URLs permanently into favourites or playlists; it offers refresh, an alternate candidate, or a switch to another source. Netease paths go through a compatible resolver for both URL and lyrics, while QQ and Kuwo prefer /api/music.
Lyric sync does not start another timer; it listens to the <audio> element's timeupdate. The parser handles multiple time tags per line and both mm:ss.xx and mm:ss:xxx forms:
function parseLrc(text) {
const lines = []
for (const line of String(text || "").split(/\r?\n/)) {
const tags = [...line.matchAll(/\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)]
const content = line.replace(/\[[^\]]+\]/g, "").trim()
if (!content) continue
for (const tag of tags) {
const fraction = tag[3] ? Number(`0.${tag[3].padEnd(3, "0").slice(0, 3)}`) : 0
lines.push({ time: Number(tag[1]) * 60 + Number(tag[2]) + fraction, text: content })
}
}
return lines.sort((a, b) => a.time - b.time)
}
Rendering walks backwards to the last line with line.time <= audio.currentTime and marks it active; clicking a lyric line sets audio.currentTime to that timestamp. Progress bar, lyric highlight and current track therefore stay consistent by construction.
Desktop is a three-column workspace: search / player and lyrics / queue. Mobile switches to bottom navigation between search, lyrics and library. Layout uses CSS Grid, media queries and independent scroll containers, so lyrics and queue never push the page past the viewport. Theme, favourites and playlists sit in localStorage, so search and local organisation work before you ever sign in.
If every component read platform-specific fields, adding a source would turn into conditional hell. The project therefore defines one track shape:
{
uid: "qq-song-id",
source: "qq", // netease / qq / kuwo / joox
title: "Song title",
artist: "Artist",
album: "Album",
cover: "https://...",
songid: "platform song id",
quality: "lossless",
qualityLabel: "Lossless",
pay: "",
audioUrl: null, // runtime field
lrc: null // runtime field
}
The view layer only reads stable fields — title, artist, album, cover, source; adapters translate platform responses into them; the detail layer fills audioUrl and lyrics by source. serializeTrack() filters out short-lived audio URLs, lyrics and detail state, persisting only reusable identifiers and display data, so expired resources never reach localStorage or D1.
Cloudflare Pages maps files under functions/api/ to /api/*. The main endpoints fall into three groups:
| Endpoint | Purpose |
|---|---|
GET /api/music | Search, detail, audio resolution, proxying and caching |
POST /api/register, POST /api/login, POST /api/logout | Accounts and sessions |
GET/PUT /api/library | Read or overwrite the current user's favourites and playlists |
POST /api/import-playlist | Resolve a public playlist server-side into HALO Tracks |
GET /api/me | Username for the current session |
/api/music dispatches on action: qq_search, kuwo_search, qq_detail, kuwo_detail, qq_audio and kuwo_audio. The server caps keyword length, result count and upstream timeouts so one misbehaving platform cannot occupy the whole Function invocation.
Audio resolution hits short-lived URLs, dead candidates and upstream throttling far more often than search, so the strategy is candidates plus playability checks plus caching:
refresh=1.The in-memory LRU holds about 500 entries, search caching defaults to roughly 45 seconds, and D1 acts as the cross-request, cross-instance second level. Only reusable resolution results are cached — audio files are never copied into my storage, and temporary URLs are never treated as permanent links.
Public playlist import lives in import-playlist.js, because link resolution, pagination and upstream requests belong on the server. The Function recognises public Netease, Qishui and QQ links, resolves official short links when needed, reads song information from pages or APIs, and converts everything into HALO Tracks.
Import caps are explicit: at most the first 1,200 tracks for a QQ playlist, 2,000 for Netease, and a 4 MB page-read limit for Qishui. Bounding request volume protects upstream and keeps the Function inside its execution budget.
D1 is SQLite. The core tables:
| Table | Purpose |
|---|---|
music_users | Username, password hash and random salt |
music_sessions | Session token, username and expiry |
music_libraries | User favourites and user playlists as JSON |
music_cache | Shared audio and detail cache |
search_cache | Short-lived search cache |
Favourites and playlists are "write localStorage first, sync to D1 asynchronously": signed out, the data belongs to that browser; signed in, the page submits the library to /api/library, and a weak connection still leaves local data usable. The cloud library is stored as JSON — simple, appropriate to a personal project. Collaboration, incremental sync or audit trails would warrant splitting tracks, playlists and the relation between them into proper tables.
Passwords are never stored in plaintext. The server uses Web Crypto PBKDF2-SHA-256 with 100,000 iterations and a 16-byte random salt, writing only hash and salt to D1. After login a random UUID becomes a session token in an HttpOnly; Secure; SameSite=Lax cookie, valid for 30 days by default.
The desktop security goal is "a window shell only, never Node privileges for the page":
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
}
preload.cjs exposes only minimal platform information through contextBridge, and external links open in the system browser. The main process creates a 1440×920 window (minimum 960×640) and can point at a custom Pages domain via HALO_MUSIC_URL or --url=.
Local development needs Node.js 20 LTS or newer, npm and Wrangler 4:
npm install
npx wrangler d1 execute halo-music-db --local --file schema.sql
npm run dev
Tests use Node's built-in node:test and cover sessions, favourites and playlist persistence, field normalisation across platforms, playlist link recognition, QQ signing and pagination, LRC parsing, audio container detection, next-track playback context, plus a syntax check of the browser's inline scripts:
npm test
Deployment publishes the static files and Functions together, with D1 bound in wrangler.toml:
name = "halo-music"
compatibility_date = "2025-01-01"
pages_build_output_dir = "."
[[d1_databases]]
binding = "DB"
database_name = "halo-music-db"
database_id = "your D1 database_id"
The desktop build reuses the deployed Pages URL, so there is no second local HTTP server:
npm run desktop:dev
npm run desktop:all
npm run desktop:portable
Windows installers and the portable build land in release/; the app id is com.halomusic.desktop.
What is worth recording is not any single platform API but several reusable engineering decisions:
Clear boundaries remain: upstream APIs and audio addresses can change without warning, Functions execution time limits bulk import, and the frontend entry point could still be split into modules. The more valuable next steps are a finer-grained playlist model, incremental sync, Playwright visual regression, and stable contract tests per source adapter.
If you want to read the implementation, the halo_music source is open. The project started as a small "search and play" tool and grew into a complete playground spanning web, serverless, SQLite caching and a desktop shell.
Along the same chain: Building a test management system from scratch: Vue 3 + Django and The tech stack behind this blog: Vue 3 + Django, split front and back.