diff --git a/frontend/src/lib/stations.ts b/frontend/src/lib/stations.ts new file mode 100644 index 0000000..2da1526 --- /dev/null +++ b/frontend/src/lib/stations.ts @@ -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 { + try { + await access(p); + return true; + } catch { + return false; + } +} + +export async function readMeta(libraryRoot: string, id: string): Promise { + 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; + 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 { + 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; +} diff --git a/frontend/src/tests/fixtures/library/alpha/_meta.yml b/frontend/src/tests/fixtures/library/alpha/_meta.yml new file mode 100644 index 0000000..32485f2 --- /dev/null +++ b/frontend/src/tests/fixtures/library/alpha/_meta.yml @@ -0,0 +1,4 @@ +name: Alpha +description: alpha station +color: '#ff0000' +tags: [test, alpha] diff --git a/frontend/src/tests/fixtures/library/bad-yaml/_meta.yml b/frontend/src/tests/fixtures/library/bad-yaml/_meta.yml new file mode 100644 index 0000000..d78037a --- /dev/null +++ b/frontend/src/tests/fixtures/library/bad-yaml/_meta.yml @@ -0,0 +1,2 @@ +name: "unterminated +description: never closes diff --git a/frontend/src/tests/fixtures/library/beta/_meta.yml b/frontend/src/tests/fixtures/library/beta/_meta.yml new file mode 100644 index 0000000..b716328 --- /dev/null +++ b/frontend/src/tests/fixtures/library/beta/_meta.yml @@ -0,0 +1,4 @@ +name: Beta +description: beta station +color: '#00ff00' +tags: [test, beta] diff --git a/frontend/src/tests/fixtures/library/beta/cover.jpg b/frontend/src/tests/fixtures/library/beta/cover.jpg new file mode 100644 index 0000000..f6f3e8e --- /dev/null +++ b/frontend/src/tests/fixtures/library/beta/cover.jpg @@ -0,0 +1 @@ +fake cover diff --git a/frontend/src/tests/fixtures/library/empty/.gitkeep b/frontend/src/tests/fixtures/library/empty/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/tests/stations.test.ts b/frontend/src/tests/stations.test.ts new file mode 100644 index 0000000..890f875 --- /dev/null +++ b/frontend/src/tests/stations.test.ts @@ -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([]); + }); +});