feat(frontend): add stations library reader with tdd
This commit is contained in:
parent
a1e24c6a81
commit
a7256bc13c
7 changed files with 153 additions and 0 deletions
86
frontend/src/lib/stations.ts
Normal file
86
frontend/src/lib/stations.ts
Normal 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;
|
||||||
|
}
|
||||||
4
frontend/src/tests/fixtures/library/alpha/_meta.yml
vendored
Normal file
4
frontend/src/tests/fixtures/library/alpha/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: Alpha
|
||||||
|
description: alpha station
|
||||||
|
color: '#ff0000'
|
||||||
|
tags: [test, alpha]
|
||||||
2
frontend/src/tests/fixtures/library/bad-yaml/_meta.yml
vendored
Normal file
2
frontend/src/tests/fixtures/library/bad-yaml/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
name: "unterminated
|
||||||
|
description: never closes
|
||||||
4
frontend/src/tests/fixtures/library/beta/_meta.yml
vendored
Normal file
4
frontend/src/tests/fixtures/library/beta/_meta.yml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: Beta
|
||||||
|
description: beta station
|
||||||
|
color: '#00ff00'
|
||||||
|
tags: [test, beta]
|
||||||
1
frontend/src/tests/fixtures/library/beta/cover.jpg
vendored
Normal file
1
frontend/src/tests/fixtures/library/beta/cover.jpg
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
fake cover
|
||||||
0
frontend/src/tests/fixtures/library/empty/.gitkeep
vendored
Normal file
0
frontend/src/tests/fixtures/library/empty/.gitkeep
vendored
Normal file
56
frontend/src/tests/stations.test.ts
Normal file
56
frontend/src/tests/stations.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue