refactor: dedup buildTrackList, extract topsong row helper, split romaji to lib

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-21 11:36:06 +02:00
1 parent 84f1fd006d
commit b9a5c6468f
4 files changed
+162 -171

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
const path = require('path');
const { createHash } = require('crypto');
// ─── KUROMOJI TOKENIZER ───────────────────────────────────────────────────────
let _tokenizer = null;
try {
const kuromoji = require('kuromoji');
const dicPath = path.join(path.dirname(require.resolve('kuromoji')), '..', 'dict');
kuromoji.builder({ dicPath })
.build((err, t) => {
if (err) console.warn('⚠️ kuromoji failed to load:', err.message);
else { _tokenizer = t; console.log('✅ kuromoji tokenizer ready'); }
});
} catch { console.warn('⚠️ kuromoji not installed — run: cd backend && npm install'); }
// ─── CONVERSION HELPERS ───────────────────────────────────────────────────────
function _kataToHira(s) { return s.replace(/[ァ-ヶ]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x60)); }
const _H2R = {
'あ':'a','い':'i','う':'u','え':'e','お':'o',
'か':'ka','き':'ki','く':'ku','け':'ke','こ':'ko',
'さ':'sa','し':'shi','す':'su','せ':'se','そ':'so',
'た':'ta','ち':'chi','つ':'tsu','て':'te','と':'to',
'な':'na','に':'ni','ぬ':'nu','ね':'ne','の':'no',
'は':'ha','ひ':'hi','ふ':'fu','へ':'he','ほ':'ho',
'ま':'ma','み':'mi','む':'mu','め':'me','も':'mo',
'や':'ya','ゆ':'yu','よ':'yo',
'ら':'ra','り':'ri','る':'ru','れ':'re','ろ':'ro',
'わ':'wa','を':'wo','ん':'n',
'が':'ga','ぎ':'gi','ぐ':'gu','げ':'ge','ご':'go',
'ざ':'za','じ':'ji','ず':'zu','ぜ':'ze','ぞ':'zo',
'だ':'da','ぢ':'di','づ':'du','で':'de','ど':'do',
'ば':'ba','び':'bi','ぶ':'bu','べ':'be','ぼ':'bo',
'ぱ':'pa','ぴ':'pi','ぷ':'pu','ぺ':'pe','ぽ':'po',
'きゃ':'kya','きゅ':'kyu','きょ':'kyo',
'しゃ':'sha','しゅ':'shu','しょ':'sho',
'ちゃ':'cha','ちゅ':'chu','ちょ':'cho',
'にゃ':'nya','にゅ':'nyu','にょ':'nyo',
'ひゃ':'hya','ひゅ':'hyu','ひょ':'hyo',
'みゃ':'mya','みゅ':'myu','みょ':'myo',
'りゃ':'rya','りゅ':'ryu','りょ':'ryo',
'ぎゃ':'gya','ぎゅ':'gyu','ぎょ':'gyo',
'じゃ':'ja','じゅ':'ju','じょ':'jo',
'びゃ':'bya','びゅ':'byu','びょ':'byo',
'ぴゃ':'pya','ぴゅ':'pyu','ぴょ':'pyo',
'ふぁ':'fa','ふぃ':'fi','ふぇ':'fe','ふぉ':'fo',
'てぃ':'ti','でぃ':'di','とぅ':'tu','どぅ':'du',
'ゐ':'i','ゑ':'e','ヴ':'vu',
};
function _hiraToRomaji(str) {
let out = '', i = 0;
while (i < str.length) {
if (str[i] === 'っ') {
const nxt = _H2R[str.slice(i+1,i+3)] || _H2R[str[i+1]] || '';
out += nxt[0] || ''; i++; continue;
}
const two = _H2R[str.slice(i,i+2)];
if (two) { out += two; i += 2; continue; }
out += _H2R[str[i]] ?? str[i]; i++;
}
return out;
}
const JP_RE = /[぀-ヿ㐀-䶿一-鿿＀-￯]/;
function toRomaji(text) {
if (!text || !_tokenizer || !JP_RE.test(text)) return text || '';
const tokens = _tokenizer.tokenize(text);
const hira = tokens.map(t => {
const r = t.reading;
return (r && r !== '*') ? _kataToHira(r) : t.surface_form;
}).join('');
return _hiraToRomaji(hira).toLowerCase();
}
// ─── INDEX BUILDER ────────────────────────────────────────────────────────────
const romajiCache = new Map();
const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
async function _fetchNdSongs(ndUrl, user, pass) {
const salt = Math.random().toString(36).slice(2, 10);
const token = createHash('md5').update(pass + salt).digest('hex');
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
const songs = [];
let offset = 0;
while (true) {
const r = await fetch(`${base}&query=&songCount=500&songOffset=${offset}`).then(r => r.json());
const page = r['subsonic-response']?.searchResult3?.song || [];
songs.push(...page);
if (page.length < 500) break;
offset += 500;
}
return songs;
}
async function buildRomajiIndex(ndUrl, user, pass) {
const key = `${ndUrl}|${user}`;
const cached = romajiCache.get(key);
if (cached && Date.now() - cached.ts < ROMAJI_TTL) return { fromCache: true, index: cached.index };
const songs = await _fetchNdSongs(ndUrl, user, pass);
const index = songs
.filter(s => JP_RE.test(s.title) || JP_RE.test(s.artist) || JP_RE.test(s.album))
.map(s => ({
id: s.id,
r: [toRomaji(s.title), toRomaji(s.artist), toRomaji(s.album)].join(' '),
title: s.title,
artist: s.artist || '',
album: s.album || '',
coverArt: s.coverArt || '',
duration: s.duration || 0,
albumId: s.albumId || '',
artistId: s.artistId || '',
}));
romajiCache.set(key, { ts: Date.now(), index });
return { fromCache: false, index };
}
function clearRomajiCache() { romajiCache.clear(); }
function isTokenizerReady() { return !!_tokenizer; }
module.exports = { buildRomajiIndex, clearRomajiCache, isTokenizerReady };
+5 -118
View File
@@ -10,7 +10,6 @@
const express = require('express'); const express = require('express');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { createHash } = require('crypto');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const https = require('https'); const https = require('https');
@@ -19,100 +18,7 @@ const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs'); const { jobs, updateJob, log } = require('./lib/jobs');
const { lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata'); const { lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata');
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady } = require('./lib/romaji');
// ─── ROMAJI INDEX ─────────────────────────────────────────────────────────────
// Converts kanji/kana → romaji so users can search Japanese songs phonetically.
let _tokenizer = null;
try {
const kuromoji = require('kuromoji');
const dicPath = path.join(path.dirname(require.resolve('kuromoji')), '..', 'dict');
kuromoji.builder({ dicPath })
.build((err, t) => {
if (err) console.warn('⚠️ kuromoji failed to load:', err.message);
else { _tokenizer = t; console.log('✅ kuromoji tokenizer ready'); }
});
} catch { console.warn('⚠️ kuromoji not installed — run: cd backend && npm install'); }
// katakana→hiragana shift (kuromoji readings come back in katakana)
function _kataToHira(s) { return s.replace(/[ァ-ヶ]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x60)); }
// Full hiragana → romaji table (includes digraphs + っ doubling)
const _H2R = {
'あ':'a','い':'i','う':'u','え':'e','お':'o',
'か':'ka','き':'ki','く':'ku','け':'ke','こ':'ko',
'さ':'sa','し':'shi','す':'su','せ':'se','そ':'so',
'た':'ta','ち':'chi','つ':'tsu','て':'te','と':'to',
'な':'na','に':'ni','ぬ':'nu','ね':'ne','の':'no',
'は':'ha','ひ':'hi','ふ':'fu','へ':'he','ほ':'ho',
'ま':'ma','み':'mi','む':'mu','め':'me','も':'mo',
'や':'ya','ゆ':'yu','よ':'yo',
'ら':'ra','り':'ri','る':'ru','れ':'re','ろ':'ro',
'わ':'wa','を':'wo','ん':'n',
'が':'ga','ぎ':'gi','ぐ':'gu','げ':'ge','ご':'go',
'ざ':'za','じ':'ji','ず':'zu','ぜ':'ze','ぞ':'zo',
'だ':'da','ぢ':'di','づ':'du','で':'de','ど':'do',
'ば':'ba','び':'bi','ぶ':'bu','べ':'be','ぼ':'bo',
'ぱ':'pa','ぴ':'pi','ぷ':'pu','ぺ':'pe','ぽ':'po',
'きゃ':'kya','きゅ':'kyu','きょ':'kyo',
'しゃ':'sha','しゅ':'shu','しょ':'sho',
'ちゃ':'cha','ちゅ':'chu','ちょ':'cho',
'にゃ':'nya','にゅ':'nyu','にょ':'nyo',
'ひゃ':'hya','ひゅ':'hyu','ひょ':'hyo',
'みゃ':'mya','みゅ':'myu','みょ':'myo',
'りゃ':'rya','りゅ':'ryu','りょ':'ryo',
'ぎゃ':'gya','ぎゅ':'gyu','ぎょ':'gyo',
'じゃ':'ja','じゅ':'ju','じょ':'jo',
'びゃ':'bya','びゅ':'byu','びょ':'byo',
'ぴゃ':'pya','ぴゅ':'pyu','ぴょ':'pyo',
'ふぁ':'fa','ふぃ':'fi','ふぇ':'fe','ふぉ':'fo',
'てぃ':'ti','でぃ':'di','とぅ':'tu','どぅ':'du',
'ゐ':'i','ゑ':'e','ヴ':'vu',
};
function _hiraToRomaji(str) {
let out = '', i = 0;
while (i < str.length) {
if (str[i] === 'っ') {
const nxt = _H2R[str.slice(i+1,i+3)] || _H2R[str[i+1]] || '';
out += nxt[0] || ''; i++; continue;
}
const two = _H2R[str.slice(i,i+2)];
if (two) { out += two; i += 2; continue; }
out += _H2R[str[i]] ?? str[i]; i++;
}
return out;
}
const JP_RE = /[぀-ヿ㐀-䶿一-鿿＀-￯]/;
function _toRomaji(text) {
if (!text || !_tokenizer || !JP_RE.test(text)) return text || '';
const tokens = _tokenizer.tokenize(text);
const hira = tokens.map(t => {
const r = t.reading;
return (r && r !== '*') ? _kataToHira(r) : t.surface_form;
}).join('');
return _hiraToRomaji(hira).toLowerCase();
}
// Cache: key = "ndUrl|user" → { ts, index }
const _romajiCache = new Map();
const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
async function _fetchNdSongs(ndUrl, user, pass) {
const salt = Math.random().toString(36).slice(2, 10);
const token = createHash('md5').update(pass + salt).digest('hex');
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
const songs = [];
let offset = 0;
while (true) {
const r = await fetch(`${base}&query=&songCount=500&songOffset=${offset}`).then(r => r.json());
const page = r['subsonic-response']?.searchResult3?.song || [];
songs.push(...page);
if (page.length < 500) break;
offset += 500;
}
return songs;
}
// ─── INIT ───────────────────────────────────────────────────────────────────── // ─── INIT ─────────────────────────────────────────────────────────────────────
const app = express(); const app = express();
@@ -169,29 +75,10 @@ function parseSpotifyUrl(url) {
app.get('/api/romaji-index', async (req, res) => { app.get('/api/romaji-index', async (req, res) => {
const { ndUrl, user, pass } = req.query; const { ndUrl, user, pass } = req.query;
if (!ndUrl || !user || !pass) return res.status(400).json({ error: 'missing params' }); if (!ndUrl || !user || !pass) return res.status(400).json({ error: 'missing params' });
if (!_tokenizer) return res.status(503).json({ error: 'tokenizer not ready — try again in a few seconds' }); if (!isTokenizerReady()) return res.status(503).json({ error: 'tokenizer not ready — try again in a few seconds' });
const key = `${ndUrl}|${user}`;
const cached = _romajiCache.get(key);
if (cached && Date.now() - cached.ts < ROMAJI_TTL) return res.json(cached.index);
try { try {
const songs = await _fetchNdSongs(ndUrl, user, pass); const { fromCache, index } = await buildRomajiIndex(ndUrl, user, pass);
const index = songs if (!fromCache) console.log(`✅ Romaji index built: ${index.length} JP songs`);
.filter(s => JP_RE.test(s.title) || JP_RE.test(s.artist) || JP_RE.test(s.album))
.map(s => ({
id: s.id,
r: [_toRomaji(s.title), _toRomaji(s.artist), _toRomaji(s.album)].join(' '),
title: s.title,
artist: s.artist || '',
album: s.album || '',
coverArt: s.coverArt || '',
duration: s.duration || 0,
albumId: s.albumId || '',
artistId: s.artistId || '',
}));
_romajiCache.set(key, { ts: Date.now(), index });
console.log(`✅ Romaji index built: ${index.length} JP songs (${songs.length} total)`);
res.json(index); res.json(index);
} catch (e) { } catch (e) {
console.error('Romaji index error:', e); console.error('Romaji index error:', e);
@@ -201,7 +88,7 @@ app.get('/api/romaji-index', async (req, res) => {
// DELETE /api/romaji-index — force rebuild on next request // DELETE /api/romaji-index — force rebuild on next request
app.delete('/api/romaji-index', (req, res) => { app.delete('/api/romaji-index', (req, res) => {
_romajiCache.clear(); clearRomajiCache();
res.json({ ok: true }); res.json({ ok: true });
}); });
+7 -17
View File
@@ -46,9 +46,9 @@ function _nsBridge(method, ...args) {
try { window.NSBridge[method](...args); } catch(_) {} try { window.NSBridge[method](...args); } catch(_) {}
} }
// Build the NSBridge Track JSON array from S.queue. // Convert a single Navidrome song to the NSBridge Track schema.
function buildTrackList() { function _trackToNative(s) {
return S.queue.map(s => ({ return {
id: s.id, id: s.id,
url: streamUrl(s.id), url: streamUrl(s.id),
title: s.title || '', title: s.title || '',
@@ -56,9 +56,12 @@ function buildTrackList() {
album: s.album || null, album: s.album || null,
artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null, artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null,
durationMs: Math.floor((s.duration || 0) * 1000), durationMs: Math.floor((s.duration || 0) * 1000),
})); };
} }
// Build the NSBridge Track JSON array from S.queue.
const buildTrackList = () => S.queue.map(_trackToNative);
// Push the current JS queue to native whenever S.queue changes (radio top-ups, etc.). // Push the current JS queue to native whenever S.queue changes (radio top-ups, etc.).
function _pushQueueIfBridge() { function _pushQueueIfBridge() {
if (!HAS_BRIDGE) return; if (!HAS_BRIDGE) return;
@@ -73,19 +76,6 @@ export function nativeInsertAt(song, position) {
try { window.NSBridge.addToQueue(JSON.stringify(_trackToNative(song)), position); } catch(_) {} try { window.NSBridge.addToQueue(JSON.stringify(_trackToNative(song)), position); } catch(_) {}
} }
// Convert a single Navidrome song to the NSBridge Track schema (used for addToQueue).
function _trackToNative(s) {
return {
id: s.id,
url: streamUrl(s.id),
title: s.title || '',
artist: s.artist || '',
album: s.album || null,
artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null,
durationMs: Math.floor((s.duration || 0) * 1000),
};
}
// ─── NATIVE → JS CALLBACKS ──────────────────────────────────────────────── // ─── NATIVE → JS CALLBACKS ────────────────────────────────────────────────
// window.NSPlayer is the surface that MusicService calls back into. // window.NSPlayer is the surface that MusicService calls back into.
// Installed here so it exists before onPageFinished fires. // Installed here so it exists before onPageFinished fires.
+24 -36
View File
@@ -310,6 +310,28 @@ export async function loadAlbums(type = _albumSort) {
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; } } catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
} }
// ─── SHARED TOPSONG ROW ───
// Used by loadArtistDetail (top songs) and loadArtistAllSongs.
function _makeTopsongRow(s, num, subtitle, list, idx) {
const cv = coverUrl(s.coverArt, 128);
if (s.starred && !s._starred) s._starred = true;
const row = document.createElement('div'); row.className = 'topsong-item';
row.innerHTML = `<div class="topsong-num">${num}</div>
<div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
<div style="min-width:0;flex:1"><div class="topsong-title">${esc(s.title)}</div>${subtitle ? `<div style="font-size:11px;color:var(--text2)">${esc(subtitle)}</div>` : ''}</div>
<div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>
<div class="song-actions" style="flex-shrink:0">
<button class="icon-btn${s._starred ? ' starred' : ''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn song-menu-btn" title="Mehr"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</div>`;
row.onclick = () => playQueue(list, idx);
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, list, idx); });
const btns = row.querySelectorAll('.icon-btn');
btns[0].onclick = e => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = e => { e.stopPropagation(); showCtxMenu(e, s, list, idx); };
return row;
}
// ─── ARTIST DETAIL ─── // ─── ARTIST DETAIL ───
export async function loadArtistDetail(artist) { export async function loadArtistDetail(artist) {
document.getElementById('artist-detail-name').textContent = artist.name; document.getElementById('artist-detail-name').textContent = artist.name;
@@ -395,25 +417,7 @@ export async function loadArtistDetail(artist) {
h.className = 'section-title'; h.style.marginBottom = '10px'; h.textContent = 'Top Songs'; h.className = 'section-title'; h.style.marginBottom = '10px'; h.textContent = 'Top Songs';
tsEl.appendChild(h); tsEl.appendChild(h);
const list = document.createElement('div'); list.style.marginBottom = '8px'; const list = document.createElement('div'); list.style.marginBottom = '8px';
topSongs.forEach((s, i) => { topSongs.forEach((s, i) => list.appendChild(_makeTopsongRow(s, i + 1, s.album || '', topSongs, i)));
const cv = coverUrl(s.coverArt, 128);
const row = document.createElement('div'); row.className = 'topsong-item';
if(s.starred && !s._starred) s._starred = true;
row.innerHTML = `<div class="topsong-num">${i+1}</div>
<div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
<div style="min-width:0;flex:1"><div class="topsong-title">${esc(s.title)}</div><div style="font-size:11px;color:var(--text2)">${esc(s.album||'')}</div></div>
<div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>
<div class="song-actions" style="flex-shrink:0">
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn song-menu-btn" title="Mehr"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</div>`;
row.onclick = () => playQueue(topSongs, i);
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, topSongs, i); });
const btns = row.querySelectorAll('.icon-btn');
btns[0].onclick = e => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = e => { e.stopPropagation(); showCtxMenu(e, s, topSongs, i); };
list.appendChild(row);
});
tsEl.appendChild(list); tsEl.appendChild(list);
if(ndAlbums.length) { if(ndAlbums.length) {
const total = ndAlbums.reduce((n, a) => n + (a.songCount || 0), 0); const total = ndAlbums.reduce((n, a) => n + (a.songCount || 0), 0);
@@ -580,23 +584,7 @@ export async function loadArtistAllSongs({ albums, artist }) {
h.textContent = s._alb?.name || ''; h.textContent = s._alb?.name || '';
el.appendChild(h); el.appendChild(h);
} }
const cv = coverUrl(s.coverArt, 60); el.appendChild(_makeTopsongRow(s, s.trackNumber || '', null, allSongs, i));
if(s.starred && !s._starred) s._starred = true;
const row = document.createElement('div'); row.className = 'topsong-item';
row.innerHTML = `<div class="topsong-num">${s.trackNumber||''}</div>
<div class="topsong-thumb">${cv?`<img src="${cv}" loading="lazy">`:'<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:16px">🎵</div>'}</div>
<div style="min-width:0;flex:1"><div class="topsong-title">${esc(s.title)}</div></div>
<div class="topsong-plays">${s.playCount?s.playCount.toLocaleString():''}</div>
<div class="song-actions" style="flex-shrink:0">
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn song-menu-btn" title="Mehr"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</div>`;
row.onclick = () => playQueue(allSongs, i);
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, allSongs, i); });
const btns = row.querySelectorAll('.icon-btn');
btns[0].onclick = e => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = e => { e.stopPropagation(); showCtxMenu(e, s, allSongs, i); };
el.appendChild(row);
}); });
} }