feat(frontend): add stations library reader with tdd

This commit is contained in:
devilreef 2026-04-30 09:31:59 +06:00
parent a1e24c6a81
commit a7256bc13c
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
7 changed files with 153 additions and 0 deletions

View file

@ -0,0 +1,86 @@
import { readdir, readFile, stat, access } from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
import type { Station } from './types.ts';
const ID_RE = /^[a-z0-9-]+$/;
const COVER_NAMES = ['cover.jpg', 'cover.png', 'cover.webp'];
async function fileExists(p: string): Promise<boolean> {
try {
await access(p);
return true;
} catch {
return false;
}
}
export async function readMeta(libraryRoot: string, id: string): Promise<Station | null> {
if (!ID_RE.test(id)) return null;
const dir = path.join(libraryRoot, id);
let raw: string;
try {
raw = await readFile(path.join(dir, '_meta.yml'), 'utf8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err) {
console.warn(`[stations] malformed _meta.yml for ${id}:`, (err as Error).message);
return null;
}
if (!parsed || typeof parsed !== 'object') {
console.warn(`[stations] _meta.yml for ${id} is not an object`);
return null;
}
const m = parsed as Record<string, unknown>;
const name = typeof m.name === 'string' ? m.name : null;
const description = typeof m.description === 'string' ? m.description : '';
const color = typeof m.color === 'string' ? m.color : '#888';
const tags = Array.isArray(m.tags) ? m.tags.filter((t): t is string => typeof t === 'string') : [];
if (!name) {
console.warn(`[stations] _meta.yml for ${id} missing 'name'`);
return null;
}
let cover: string | null = null;
for (const c of COVER_NAMES) {
if (await fileExists(path.join(dir, c))) {
cover = `/api/stations/${id}/cover`;
break;
}
}
return {
id,
name,
description,
color,
tags,
mounts: { mp3: `/${id}.mp3`, opus: `/${id}.opus` },
cover,
};
}
export async function listStations(libraryRoot: string): Promise<Station[]> {
let entries: string[];
try {
entries = await readdir(libraryRoot);
} catch {
return [];
}
const out: Station[] = [];
for (const id of entries.sort()) {
const full = path.join(libraryRoot, id);
let s;
try {
s = await stat(full);
} catch {
continue;
}
if (!s.isDirectory()) continue;
const meta = await readMeta(libraryRoot, id);
if (meta) out.push(meta);
}
return out;
}

View file

@ -0,0 +1,4 @@
name: Alpha
description: alpha station
color: '#ff0000'
tags: [test, alpha]

View file

@ -0,0 +1,2 @@
name: "unterminated
description: never closes

View file

@ -0,0 +1,4 @@
name: Beta
description: beta station
color: '#00ff00'
tags: [test, beta]

View file

@ -0,0 +1 @@
fake cover

View file

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { listStations, readMeta } from '@lib/stations';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.resolve(HERE, 'fixtures/library');
describe('readMeta', () => {
it('returns null for missing _meta.yml', async () => {
expect(await readMeta(FIXTURES, 'empty')).toBeNull();
});
it('returns null for malformed yaml (logged, not thrown)', async () => {
expect(await readMeta(FIXTURES, 'bad-yaml')).toBeNull();
});
it('returns null when the id has uppercase letters (regex rejection, no fs read)', async () => {
expect(await readMeta(FIXTURES, 'NotLower')).toBeNull();
});
it('parses a valid station', async () => {
const s = await readMeta(FIXTURES, 'alpha');
expect(s).toEqual({
id: 'alpha',
name: 'Alpha',
description: 'alpha station',
color: '#ff0000',
tags: ['test', 'alpha'],
mounts: { mp3: '/alpha.mp3', opus: '/alpha.opus' },
cover: null,
});
});
it('sets cover when a cover file exists', async () => {
const s = await readMeta(FIXTURES, 'beta');
expect(s?.cover).toBe('/api/stations/beta/cover');
});
});
describe('listStations', () => {
it('returns only valid stations, sorted by id', async () => {
const ss = await listStations(FIXTURES);
expect(ss.map((s) => s.id)).toEqual(['alpha', 'beta']);
});
it('skips empty and bad-yaml folders', async () => {
const ss = await listStations(FIXTURES);
expect(ss.find((s) => s.id === 'empty')).toBeUndefined();
expect(ss.find((s) => s.id === 'bad-yaml')).toBeUndefined();
});
it('handles a non-existent root gracefully', async () => {
expect(await listStations('/no/such/dir')).toEqual([]);
});
});