{"record":{"id":"795a550676936125","repo":"jackwener/OpenCLI","slug":"parse-error-795a55","errorCode":"PARSE_ERROR","errorMessage":"Audio URL not found in episode payload","messagePattern":"Audio URL not found in episode payload","errorType":"error_code","errorClass":"CliError","httpStatus":null,"severity":"error","filePath":"clis/xiaoyuzhou/download.js","lineNumber":34,"sourceCode":"    browser: false,\n    args: [\n        { name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' },\n        { name: 'output', default: './xiaoyuzhou-downloads', help: 'Output directory' },\n    ],\n    columns: ['title', 'podcast', 'status', 'size', 'file'],\n    func: async (args) => {\n        const credentials = loadXiaoyuzhouCredentials();\n        const response = await requestXiaoyuzhouJson('/v1/episode/get', {\n            query: { eid: args.id },\n            credentials,\n        });\n        const ep = response.data;\n        if (!ep) {\n            throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');\n        }\n        const audioUrl = ep.media?.source?.url;\n        if (!audioUrl) {\n            throw new CliError('PARSE_ERROR', 'Audio URL not found in episode payload', 'Episode payload does not expose media.source.url');\n        }\n        const output = String(args.output || './xiaoyuzhou-downloads');\n        const ext = path.extname(new URL(audioUrl).pathname) || '.mp3';\n        const title = String(ep.title || 'episode');\n        const filename = `${args.id}_${sanitizeFilename(title, 80) || 'episode'}${ext}`;\n        const outputDir = path.join(output, String(args.id));\n        fs.mkdirSync(outputDir, { recursive: true });\n        const destPath = path.join(outputDir, filename);\n        const result = await httpDownload(audioUrl, destPath, {\n            timeout: 60000,\n        });\n        return [{\n                title,\n                podcast: ep.podcast?.title || '',\n                status: result.success ? 'success' : 'failed',\n                size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),\n                file: result.success ? destPath : '-',\n            }];","sourceCodeStart":16,"sourceCodeEnd":52,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/clis/xiaoyuzhou/download.js#L16-L52","documentation":"Thrown by the download command (clis/xiaoyuzhou/download.js:34) as a CliError with code PARSE_ERROR when the episode payload fetched from /v1/episode/get exists but does not expose an audio URL at media.source.url. The library requires that exact nested path to start the audio download; a schema drift or missing media block makes the download impossible.","triggerScenarios":"The API returns an episode object whose ep.media?.source?.url is undefined/null: episodes with no audio stream (text-only or video-only), API schema changes moving the audio URL, or partial/degraded API responses missing the media object.","commonSituations":"Xiaoyuzhou updates their API response shape so media.source.url moves (version drift); trying to download a video episode or an episode still processing with no audio attached; an intermediary/proxy stripping fields from the response.","solutions":["Dump the raw episode payload and locate where the audio URL now lives, then adjust the access path in download.js if you control it","Check whether the library needs updating for a changed API schema (media.source.url moved)","Confirm the episode actually has downloadable audio (open it in the app/website); skip text/video-only episodes","Retry later if the episode was just published — media processing may not be complete"],"exampleFix":"// before\nconst audioUrl = ep.media?.source?.url;\n// after\nconst audioUrl = ep.media?.source?.url ?? ep.media?.url ?? ep.audio?.url;","handlingStrategy":"type-guard","validationCode":"function episodeHasAudio(ep) {\n  return Boolean(ep && typeof ep === 'object' && typeof ep.media?.source?.url === 'string' && ep.media.source.url.length > 0);\n}\n// call the API, then: if (!episodeHasAudio(response.data)) skip download;","typeGuard":"function hasAudioUrl(ep) {\n  return typeof ep?.media?.source?.url === 'string' && ep.media.source.url.length > 0;\n}","tryCatchPattern":"try {\n  await cli.download({ id });\n} catch (error) {\n  if (error.code === 'PARSE_ERROR') {\n    console.error('Episode payload lacks media.source.url — episode may have no audio or the API schema changed');\n  } else throw error;\n}","preventionTips":["Check episodes have audio in the app before scripting downloads","Log the raw episode payload once per run to detect API schema drift early","Keep the CLI updated when Xiaoyuzhou changes its API response shape","Skip freshly published episodes until media processing completes"],"tags":["parse-error","api-schema","missing-field","cli"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}