Add API documentation and underground station importer
- Introduced a new HTML documentation page for the oradio API, including a JavaScript file to handle dynamic content and API requests. - Added a CSS file for styling the documentation page. - Implemented an underground station importer script that fetches data from Radio-Browser and writes it to a JSON file. - Created a stats module to compute and manage vote and play statistics for radio stations. - Added a polyfill for modulepreload to ensure compatibility with older browsers.
This commit is contained in:
@@ -13,116 +13,116 @@ const MAX_HTML_BYTES = 256 * 1024;
|
||||
const RB_BASE = 'https://de1.api.radio-browser.info';
|
||||
|
||||
function withTimeout(ms) {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), ms);
|
||||
return { signal: ctl.signal, done: () => clearTimeout(t) };
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), ms);
|
||||
return { signal: ctl.signal, done: () => clearTimeout(t) };
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' },
|
||||
redirect: 'follow',
|
||||
signal: t.signal
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return null;
|
||||
let received = 0;
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.length;
|
||||
chunks.push(value);
|
||||
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch {} break; }
|
||||
}
|
||||
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
} finally { t.done(); }
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml' },
|
||||
redirect: 'follow',
|
||||
signal: t.signal
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return null;
|
||||
let received = 0;
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.length;
|
||||
chunks.push(value);
|
||||
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch { } break; }
|
||||
}
|
||||
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
} finally { t.done(); }
|
||||
}
|
||||
|
||||
async function head(url) {
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
|
||||
return res.ok;
|
||||
} catch { return false; } finally { t.done(); }
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
|
||||
return res.ok;
|
||||
} catch { return false; } finally { t.done(); }
|
||||
}
|
||||
|
||||
function abs(base, href) {
|
||||
if (!href) return null;
|
||||
try { return new URL(href, base).toString(); } catch { return null; }
|
||||
if (!href) return null;
|
||||
try { return new URL(href, base).toString(); } catch { return null; }
|
||||
}
|
||||
|
||||
// Extract candidate icon URLs from raw HTML. Returns array of { href, size } sorted best-first.
|
||||
function parseIconCandidates(html, baseUrl) {
|
||||
const out = [];
|
||||
// <link rel="...icon..." href="..." sizes="...">
|
||||
const linkRe = /<link\b([^>]*?)\/?>/gi;
|
||||
let m;
|
||||
while ((m = linkRe.exec(html))) {
|
||||
const attrs = m[1];
|
||||
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
if (!/icon/i.test(rel)) continue;
|
||||
const href = (/\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
|
||||
if (!href) continue;
|
||||
const sizes = (/\bsizes\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
const sz = parseInt((/(\d+)x\d+/.exec(sizes) || [])[1] || '0', 10);
|
||||
const apple = /apple-touch-icon/i.test(rel) ? 64 : 0; // bias: apple-touch-icons usually larger PNGs
|
||||
const u = abs(baseUrl, href);
|
||||
if (u) out.push({ href: u, score: sz + apple });
|
||||
}
|
||||
// <meta property="og:image" content="...">
|
||||
const metaRe = /<meta\b([^>]*?)\/?>/gi;
|
||||
while ((m = metaRe.exec(html))) {
|
||||
const attrs = m[1];
|
||||
const prop = (/\b(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
if (!/^og:image|^twitter:image/i.test(prop)) continue;
|
||||
const content = (/\bcontent\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
|
||||
const u = abs(baseUrl, content);
|
||||
if (u) out.push({ href: u, score: 200 }); // og:image preferred
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// de-dupe preserving order
|
||||
const seen = new Set();
|
||||
return out.filter((c) => (seen.has(c.href) ? false : (seen.add(c.href), true)));
|
||||
const out = [];
|
||||
// <link rel="...icon..." href="..." sizes="...">
|
||||
const linkRe = /<link\b([^>]*?)\/?>/gi;
|
||||
let m;
|
||||
while ((m = linkRe.exec(html))) {
|
||||
const attrs = m[1];
|
||||
const rel = (/\brel\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
if (!/icon/i.test(rel)) continue;
|
||||
const href = (/\bhref\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
|
||||
if (!href) continue;
|
||||
const sizes = (/\bsizes\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
const sz = parseInt((/(\d+)x\d+/.exec(sizes) || [])[1] || '0', 10);
|
||||
const apple = /apple-touch-icon/i.test(rel) ? 64 : 0; // bias: apple-touch-icons usually larger PNGs
|
||||
const u = abs(baseUrl, href);
|
||||
if (u) out.push({ href: u, score: sz + apple });
|
||||
}
|
||||
// <meta property="og:image" content="...">
|
||||
const metaRe = /<meta\b([^>]*?)\/?>/gi;
|
||||
while ((m = metaRe.exec(html))) {
|
||||
const attrs = m[1];
|
||||
const prop = (/\b(?:property|name)\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1] || '';
|
||||
if (!/^og:image|^twitter:image/i.test(prop)) continue;
|
||||
const content = (/\bcontent\s*=\s*["']([^"']+)["']/i.exec(attrs) || [])[1];
|
||||
const u = abs(baseUrl, content);
|
||||
if (u) out.push({ href: u, score: 200 }); // og:image preferred
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// de-dupe preserving order
|
||||
const seen = new Set();
|
||||
return out.filter((c) => (seen.has(c.href) ? false : (seen.add(c.href), true)));
|
||||
}
|
||||
|
||||
async function fromRadioBrowserByName(name) {
|
||||
if (!name) return null;
|
||||
try {
|
||||
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
|
||||
t.done();
|
||||
if (!res.ok) return null;
|
||||
const list = await res.json();
|
||||
const target = name.toLowerCase().trim();
|
||||
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
|
||||
const pick = exact || list[0];
|
||||
if (pick?.favicon) return pick.favicon;
|
||||
} catch {}
|
||||
return null;
|
||||
if (!name) return null;
|
||||
try {
|
||||
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
|
||||
t.done();
|
||||
if (!res.ok) return null;
|
||||
const list = await res.json();
|
||||
const target = name.toLowerCase().trim();
|
||||
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
|
||||
const pick = exact || list[0];
|
||||
if (pick?.favicon) return pick.favicon;
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fromHomepage(homepage) {
|
||||
if (!homepage) return null;
|
||||
let base;
|
||||
try { base = new URL(homepage); } catch { return null; }
|
||||
const html = await fetchText(base.toString());
|
||||
if (html) {
|
||||
const cands = parseIconCandidates(html, base.toString());
|
||||
for (const c of cands) {
|
||||
if (await head(c.href)) return c.href;
|
||||
if (!homepage) return null;
|
||||
let base;
|
||||
try { base = new URL(homepage); } catch { return null; }
|
||||
const html = await fetchText(base.toString());
|
||||
if (html) {
|
||||
const cands = parseIconCandidates(html, base.toString());
|
||||
for (const c of cands) {
|
||||
if (await head(c.href)) return c.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
// last resort: /favicon.ico
|
||||
const ico = `${base.origin}/favicon.ico`;
|
||||
if (await head(ico)) return ico;
|
||||
return null;
|
||||
// last resort: /favicon.ico
|
||||
const ico = `${base.origin}/favicon.ico`;
|
||||
if (await head(ico)) return ico;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,11 +131,11 @@ async function fromHomepage(homepage) {
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
export async function scrapeIcon(station) {
|
||||
if (!station) return null;
|
||||
// For non-RB stations, RB often still has an entry → cheap win.
|
||||
if (station.source !== 'radiobrowser') {
|
||||
const rb = await fromRadioBrowserByName(station.name);
|
||||
if (rb) return rb;
|
||||
}
|
||||
return fromHomepage(station.homepage);
|
||||
if (!station) return null;
|
||||
// For non-RB stations, RB often still has an entry → cheap win.
|
||||
if (station.source !== 'radiobrowser') {
|
||||
const rb = await fromRadioBrowserByName(station.name);
|
||||
if (rb) return rb;
|
||||
}
|
||||
return fromHomepage(station.homepage);
|
||||
}
|
||||
|
||||
@@ -2,64 +2,64 @@
|
||||
// Docs: https://api.radio-browser.info/
|
||||
|
||||
const SERVERS = [
|
||||
'https://de1.api.radio-browser.info',
|
||||
'https://nl1.api.radio-browser.info',
|
||||
'https://at1.api.radio-browser.info'
|
||||
'https://de1.api.radio-browser.info',
|
||||
'https://nl1.api.radio-browser.info',
|
||||
'https://at1.api.radio-browser.info'
|
||||
];
|
||||
|
||||
let activeServer = SERVERS[0];
|
||||
|
||||
async function rb(path, params) {
|
||||
const url = new URL(path, activeServer);
|
||||
if (params) for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null) url.searchParams.set(k, String(v));
|
||||
}
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'OnlineRadioExplorer/0.1' } });
|
||||
if (!res.ok) throw new Error(`Radio-Browser ${res.status}`);
|
||||
return res.json();
|
||||
const url = new URL(path, activeServer);
|
||||
if (params) for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null) url.searchParams.set(k, String(v));
|
||||
}
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'OnlineRadioExplorer/0.1' } });
|
||||
if (!res.ok) throw new Error(`Radio-Browser ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function search({ name, country, tag, limit = 30 }) {
|
||||
const list = await rb('/json/stations/search', {
|
||||
name, country, tag, limit, hidebroken: true, order: 'votes', reverse: true
|
||||
});
|
||||
return list.map(toCanonical);
|
||||
const list = await rb('/json/stations/search', {
|
||||
name, country, tag, limit, hidebroken: true, order: 'votes', reverse: true
|
||||
});
|
||||
return list.map(toCanonical);
|
||||
}
|
||||
|
||||
export async function byUuid(uuid) {
|
||||
const list = await rb('/json/stations/byuuid', { uuids: uuid });
|
||||
return list[0] ? toCanonical(list[0]) : null;
|
||||
const list = await rb('/json/stations/byuuid', { uuids: uuid });
|
||||
return list[0] ? toCanonical(list[0]) : null;
|
||||
}
|
||||
|
||||
function detectFormat(codec, url) {
|
||||
const c = (codec || '').toLowerCase();
|
||||
if (c.includes('mp3')) return 'mp3';
|
||||
if (c.includes('aac')) return 'aac';
|
||||
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
|
||||
if (url?.endsWith('.m3u8')) return 'hls';
|
||||
if (url?.endsWith('.m3u')) return 'm3u';
|
||||
if (url?.endsWith('.pls')) return 'pls';
|
||||
return 'unknown';
|
||||
const c = (codec || '').toLowerCase();
|
||||
if (c.includes('mp3')) return 'mp3';
|
||||
if (c.includes('aac')) return 'aac';
|
||||
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
|
||||
if (url?.endsWith('.m3u8')) return 'hls';
|
||||
if (url?.endsWith('.m3u')) return 'm3u';
|
||||
if (url?.endsWith('.pls')) return 'pls';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function toCanonical(s) {
|
||||
return {
|
||||
uuid: s.stationuuid || undefined,
|
||||
name: s.name,
|
||||
slug: `rb-${s.stationuuid}`,
|
||||
homepage: s.homepage || null,
|
||||
country: s.countrycode || s.country || null,
|
||||
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean),
|
||||
description: null,
|
||||
image_url: s.favicon || null,
|
||||
source: 'radiobrowser',
|
||||
source_ref: s.stationuuid,
|
||||
streams: [{
|
||||
url: s.url_resolved || s.url,
|
||||
format: detectFormat(s.codec, s.url_resolved || s.url),
|
||||
bitrate: s.bitrate || null,
|
||||
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
|
||||
priority: 0
|
||||
}]
|
||||
};
|
||||
return {
|
||||
uuid: s.stationuuid || undefined,
|
||||
name: s.name,
|
||||
slug: `rb-${s.stationuuid}`,
|
||||
homepage: s.homepage || null,
|
||||
country: s.countrycode || s.country || null,
|
||||
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean),
|
||||
description: null,
|
||||
image_url: s.favicon || null,
|
||||
source: 'radiobrowser',
|
||||
source_ref: s.stationuuid,
|
||||
streams: [{
|
||||
url: s.url_resolved || s.url,
|
||||
format: detectFormat(s.codec, s.url_resolved || s.url),
|
||||
bitrate: s.bitrate || null,
|
||||
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
|
||||
priority: 0
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,43 +9,43 @@ const SEED_DIR = resolve(__dirname, '../../data/seed');
|
||||
|
||||
// Deterministic UUID v5-style derived from slug; stable across DB rebuilds.
|
||||
function uuidFromSlug(slug) {
|
||||
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
|
||||
return [
|
||||
h.slice(0, 8),
|
||||
h.slice(8, 12),
|
||||
'5' + h.slice(13, 16),
|
||||
'8' + h.slice(17, 20),
|
||||
h.slice(20, 32)
|
||||
].join('-');
|
||||
const h = createHash('sha1').update('oradio:' + slug).digest('hex');
|
||||
return [
|
||||
h.slice(0, 8),
|
||||
h.slice(8, 12),
|
||||
'5' + h.slice(13, 16),
|
||||
'8' + h.slice(17, 20),
|
||||
h.slice(20, 32)
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function loadAllSeedFiles() {
|
||||
const files = readdirSync(SEED_DIR)
|
||||
.filter((f) => f.startsWith('stations') && f.endsWith('.json'))
|
||||
.sort();
|
||||
const all = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
|
||||
if (Array.isArray(data)) all.push(...data);
|
||||
} catch (err) {
|
||||
console.warn(`[seed] failed to load ${f}:`, err.message);
|
||||
const files = readdirSync(SEED_DIR)
|
||||
.filter((f) => f.startsWith('stations') && f.endsWith('.json'))
|
||||
.sort();
|
||||
const all = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(SEED_DIR, f), 'utf8'));
|
||||
if (Array.isArray(data)) all.push(...data);
|
||||
} catch (err) {
|
||||
console.warn(`[seed] failed to load ${f}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
return all;
|
||||
}
|
||||
|
||||
export function loadSeedFile() {
|
||||
return loadAllSeedFiles();
|
||||
return loadAllSeedFiles();
|
||||
}
|
||||
|
||||
export function loadCategoriesFile() {
|
||||
try {
|
||||
const txt = readFileSync(join(SEED_DIR, 'categories.json'), 'utf8');
|
||||
return JSON.parse(txt);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const txt = readFileSync(join(SEED_DIR, 'categories.json'), 'utf8');
|
||||
return JSON.parse(txt);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,70 +53,70 @@ export function loadCategoriesFile() {
|
||||
* the database. Existing stations are left untouched (admin edits are preserved).
|
||||
*/
|
||||
export function applySeed() {
|
||||
const db = getDb();
|
||||
const db = getDb();
|
||||
|
||||
const stationByUuid = db.prepare('SELECT id FROM stations WHERE uuid = ?');
|
||||
const streamByUuid = db.prepare('SELECT id FROM streams WHERE uuid = ?');
|
||||
const stationByUuid = db.prepare('SELECT id FROM stations WHERE uuid = ?');
|
||||
const streamByUuid = db.prepare('SELECT id FROM streams WHERE uuid = ?');
|
||||
|
||||
const insertStation = db.prepare(`
|
||||
const insertStation = db.prepare(`
|
||||
INSERT INTO stations (uuid, name, slug, homepage, country, genres, description, image_url, category, source, source_ref)
|
||||
VALUES (@uuid, @name, @slug, @homepage, @country, @genres, @description, @image_url, @category, 'seed', @slug)
|
||||
`);
|
||||
const insertStream = db.prepare(`
|
||||
const insertStream = db.prepare(`
|
||||
INSERT INTO streams (uuid, station_id, url, format, bitrate, label, priority)
|
||||
VALUES (@uuid, @station_id, @url, @format, @bitrate, @label, @priority)
|
||||
`);
|
||||
|
||||
const entries = loadAllSeedFiles();
|
||||
let inserted = 0;
|
||||
let streamsInserted = 0;
|
||||
let skipped = 0;
|
||||
const entries = loadAllSeedFiles();
|
||||
let inserted = 0;
|
||||
let streamsInserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const tx = db.transaction((list) => {
|
||||
for (const s of list) {
|
||||
const uuid = s.uuid || uuidFromSlug(s.slug);
|
||||
const existing = stationByUuid.get(uuid);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const info = insertStation.run({
|
||||
uuid,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
homepage: s.homepage ?? null,
|
||||
country: s.country ?? null,
|
||||
genres: JSON.stringify(s.genres ?? []),
|
||||
description: s.description ?? null,
|
||||
image_url: s.image_url ?? null,
|
||||
category: s.category ?? null
|
||||
});
|
||||
const stationId = info.lastInsertRowid;
|
||||
let priority = 0;
|
||||
for (const st of s.streams ?? []) {
|
||||
const streamUuid = st.uuid || randomUUID();
|
||||
if (streamByUuid.get(streamUuid)) continue;
|
||||
insertStream.run({
|
||||
uuid: streamUuid,
|
||||
station_id: stationId,
|
||||
url: st.url,
|
||||
format: st.format ?? 'unknown',
|
||||
bitrate: st.bitrate ?? null,
|
||||
label: st.label ?? null,
|
||||
priority: st.priority ?? priority
|
||||
});
|
||||
streamsInserted++;
|
||||
priority++;
|
||||
}
|
||||
inserted++;
|
||||
}
|
||||
});
|
||||
tx(entries);
|
||||
const tx = db.transaction((list) => {
|
||||
for (const s of list) {
|
||||
const uuid = s.uuid || uuidFromSlug(s.slug);
|
||||
const existing = stationByUuid.get(uuid);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const info = insertStation.run({
|
||||
uuid,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
homepage: s.homepage ?? null,
|
||||
country: s.country ?? null,
|
||||
genres: JSON.stringify(s.genres ?? []),
|
||||
description: s.description ?? null,
|
||||
image_url: s.image_url ?? null,
|
||||
category: s.category ?? null
|
||||
});
|
||||
const stationId = info.lastInsertRowid;
|
||||
let priority = 0;
|
||||
for (const st of s.streams ?? []) {
|
||||
const streamUuid = st.uuid || randomUUID();
|
||||
if (streamByUuid.get(streamUuid)) continue;
|
||||
insertStream.run({
|
||||
uuid: streamUuid,
|
||||
station_id: stationId,
|
||||
url: st.url,
|
||||
format: st.format ?? 'unknown',
|
||||
bitrate: st.bitrate ?? null,
|
||||
label: st.label ?? null,
|
||||
priority: st.priority ?? priority
|
||||
});
|
||||
streamsInserted++;
|
||||
priority++;
|
||||
}
|
||||
inserted++;
|
||||
}
|
||||
});
|
||||
tx(entries);
|
||||
|
||||
return { inserted, streamsInserted, skipped, total: entries.length };
|
||||
return { inserted, streamsInserted, skipped, total: entries.length };
|
||||
}
|
||||
|
||||
// Back-compat shim: bootstrap and reseed call applySeedIfEmpty(); now always merges.
|
||||
export function applySeedIfEmpty() {
|
||||
return applySeed();
|
||||
return applySeed();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user