{"record":{"id":"1f09ffc0ff454590","repo":"jamiepine/voicebox","slug":"story-has-no-audio-items","errorCode":null,"errorMessage":"Story has no audio items","messagePattern":"Story has no audio items","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"warning","filePath":"backend/routes/stories.py","lineNumber":222,"sourceCode":"    if item is None:\n        raise HTTPException(status_code=404, detail=\"Story item or version not found\")\n    return item\n\n\n@router.get(\"/stories/{story_id}/export-audio\")\nasync def export_story_audio(\n    story_id: str,\n    db: Session = Depends(get_db),\n):\n    \"\"\"Export story as single mixed audio file.\"\"\"\n    try:\n        story = db.query(database.Story).filter_by(id=story_id).first()\n        if not story:\n            raise HTTPException(status_code=404, detail=\"Story not found\")\n\n        audio_bytes = await stories.export_story_audio(story_id, db)\n        if not audio_bytes:\n            raise HTTPException(status_code=400, detail=\"Story has no audio items\")\n\n        safe_name = \"\".join(c for c in story.name if c.isalnum() or c in (\" \", \"-\", \"_\")).strip()\n        if not safe_name:\n            safe_name = \"story\"\n        filename = f\"{safe_name}.wav\"\n\n        return StreamingResponse(\n            io.BytesIO(audio_bytes),\n            media_type=\"audio/wav\",\n            headers={\"Content-Disposition\": safe_content_disposition(\"attachment\", filename)},\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        raise HTTPException(status_code=500, detail=str(e))\n","sourceCodeStart":204,"sourceCodeEnd":238,"githubUrl":"https://github.com/jamiepine/voicebox/blob/51f49dea198384b4eb6087b72c17057c6eb1c1cd/backend/routes/stories.py#L204-L238","documentation":"Raised (HTTP 400) by GET /stories/{story_id}/export-audio when the story exists but stories.export_story_audio returns None/falsy. The service returns None when the story has no DBStoryItem rows at all, or none of its items join to a DBGeneration (so there is no audio to mix into a single file). The 400 (rather than 404) signals 'story exists but is empty'.","triggerScenarios":"Calling export on a freshly created story with no clips added, or a story whose items all reference deleted generations so the inner join yields zero rows.","commonSituations":"Export button enabled on an empty story; user deletes every generation but the story shell remains; importing a story that lost its generation references.","solutions":["Disable the export control client-side until the story has at least one item with audio.","If hit unexpectedly, inspect the story items and confirm each item's generation_id resolves to an existing generation.","Treat 400 here as 'add clips first', not a server fault."],"exampleFix":"// before\n<button onClick={() => exportStory(story.id)}>Export</button>\n\n// after\n<button disabled={story.item_count === 0} onClick={() => exportStory(story.id)}>Export</button>","handlingStrategy":"validation","validationCode":"function canExport(story) {\n  return Boolean(story && (story.item_count ?? 0) > 0);\n}\nif (!canExport(story)) { notify('Add at least one clip before exporting'); return; }","typeGuard":"function hasPlayableItems(story) {\n  return Array.isArray(story.items) && story.items.some(i => i.generation_id);\n}","tryCatchPattern":"try { await api.exportStoryAudio(storyId); }\ncatch (e) {\n  if (e.status === 400) notify('This story has no audio to export');\n  else throw e;\n}","preventionTips":["Disable export until the story has at least one item with audio.","Treat 400 from this endpoint as an empty-state, not an error to retry.","Periodically reconcile items whose generation_id no longer exists."],"tags":["api","stories","fastapi","validation","empty-state"],"backgroundTag":null,"analyzedSha":"51f49dea198384b4eb6087b72c17057c6eb1c1cd","analyzedAt":"2026-08-12T16:51:42.824Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}