Halo Music: a multi-source music app on Cloudflare Pages, D1 and Electron

2026-10-16 · 30 min · Stack Frame

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.

1. The architecture at a glance

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.

2. Frontend: one static entry point carrying a whole player

2.1 Driving interaction with plain runtime state

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.

2.2 Parallel multi-source search, then one merge and dedupe

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.

2.3 Lazy detail loading, audio resolved only on play

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.

2.4 One clock for LRC lyrics and the player timeline

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.

2.5 Responsive layout is not just shrinking

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.

3. HALO Track: the data model that isolates platform differences

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.

4. Backend: Pages Functions as adapter and safety boundary

4.1 Files as routes

Cloudflare Pages maps files under functions/api/ to /api/*. The main endpoints fall into three groups:

EndpointPurpose
GET /api/musicSearch, detail, audio resolution, proxying and caching
POST /api/register, POST /api/login, POST /api/logoutAccounts and sessions
GET/PUT /api/libraryRead or overwrite the current user's favourites and playlists
POST /api/import-playlistResolve a public playlist server-side into HALO Tracks
GET /api/meUsername 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.

4.2 Caching and verifying resolved addresses

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:

  1. Build a cache key from song id, quality and duration.
  2. Check the Worker's in-process LRU, then the shared D1 cache.
  3. On a miss, try the primary endpoint and a fallback.
  4. Validate the returned address with an HTTP request, song id and duration.
  5. Store the verified candidate; on playback failure, force a re-resolve with 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.

4.3 Importing public playlists server-side

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.

5. How does the D1 database do local-first sync?

D1 is SQLite. The core tables:

TablePurpose
music_usersUsername, password hash and random salt
music_sessionsSession token, username and expiry
music_librariesUser favourites and user playlists as JSON
music_cacheShared audio and detail cache
search_cacheShort-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.

6. How does the Electron app keep accounts safe?

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=.

7. Tests, development and deployment

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.

8. What the project taught me

What is worth recording is not any single platform API but several reusable engineering decisions:

  1. Unify the domain model before adding sources. HALO Track keeps platform differences inside adapters; the player and playlist logic never learn upstream field names.
  2. Separate search from playback resolution. Search optimises for parallelism and tolerance; resolution optimises for validation, caching and retry. Their performance goals differ.
  3. Never treat temporary resources as database facts. Audio URLs expire; store the song id and display data, and re-resolve on play.
  4. Local-first removes dependence on login and network. localStorage makes favourites usable on a weak connection; D1 adds cross-device sync for signed-in users.
  5. Reuse the web build on desktop. Electron supplies a native window and a security boundary; business logic, player and API stay one implementation.

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.