All files / web/src/lib/song-share songPlan.ts

98.38% Statements 61/62
57.14% Branches 8/14
100% Functions 3/3
98.38% Lines 61/62

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 631x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 28x 28x 28x   28x 1x 56x 56x 56x 1x 1x 7x 7x 7x 7x 7x 7x 7x 7x 14x 14x 14x 14x 14x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x  
/**
 * Canonical parser for a session song's `llmOutput` composition plan.
 *
 * The admin songs page and the public `/song/[code]` page both need the title,
 * style tags, and per-section lyrics out of `llmOutput`. This is the single
 * place that shape is decoded so the two surfaces never fork their parsing
 * (they keep their own visual treatments, but consume the same data).
 *
 * Defensive over `unknown` — older rows predate parts of the schema and must
 * degrade to empty arrays rather than throw.
 */
 
export interface ParsedSongSection {
  name: string
  lines: string[]
  localStyles: string[]
  negativeLocalStyles: string[]
  durationMs: number
}
 
export interface ParsedSongPlan {
  title: string | null
  globalStyles: string[]
  negativeGlobalStyles: string[]
  totalDurationMs: number
  sections: ParsedSongSection[]
}
 
function asRecord(value: unknown): Record<string, unknown> | null {
  return value && typeof value === 'object' && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : null
}
 
function stringArray(value: unknown): string[] {
  return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
}
 
export function parseSongPlan(llmOutput: unknown): ParsedSongPlan {
  const output = asRecord(llmOutput)
  const plan = asRecord(output?.plan)
  const rawSections = Array.isArray(plan?.sections) ? plan!.sections : []
 
  const sections: ParsedSongSection[] = rawSections
    .map((raw) => asRecord(raw))
    .filter((s): s is Record<string, unknown> => s !== null)
    .map((s) => ({
      name: typeof s.section_name === 'string' ? s.section_name : 'Untitled section',
      lines: stringArray(s.lines),
      localStyles: stringArray(s.positive_local_styles),
      negativeLocalStyles: stringArray(s.negative_local_styles),
      durationMs: typeof s.duration_ms === 'number' ? s.duration_ms : 0,
    }))
 
  return {
    title: typeof output?.title === 'string' ? output.title : null,
    globalStyles: stringArray(plan?.positive_global_styles),
    negativeGlobalStyles: stringArray(plan?.negative_global_styles),
    totalDurationMs: sections.reduce((sum, s) => sum + s.durationMs, 0),
    sections,
  }
}