Fix streamed EPG ingestion and atomic profile saves - #185
Conversation
|
|
||
| // The authoring UI may change profile metadata, canonical channel choices, and | ||
| // output entries together. Save them atomically, then rebuild the guide once. | ||
| router.put('/api/output-profiles/:slug', requireAuth, async (req, res) => { |
There was a problem hiding this comment.
🟡 Changes recommended
There are confirmed performance/operational issues (notably getGuideData allocating all programmes when unscoped, plus cache TTL timer behavior on dynamic TTL updates) and the new atomic save path needs structured error identifiers to preserve row-level feedback.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses two core scalability/usability issues in the IPTV Proxy: (1) bounded-memory EPG/XMLTV ingestion (including gzip streams) with programme indexing for guide queries, and (2) atomic “Save Output Profile” behavior via a single HTTP/MCP operation to avoid repeated refresh cycles.
Changes:
- Add streaming XMLTV decoding/parsing utilities with decoded-size limits and gzip support, and use them in EPG ingestion.
- Build and use an in-memory programme index to answer
/api/guide(and MCP guide reads) without reparsing the full merged XMLTV document. - Introduce an atomic output-profile save API (HTTP
PUT /api/output-profiles/:slug) and MCP tool (save_output_profile), and update the admin UI + tests accordingly.
File summaries
| File | Description |
|---|---|
| test/unit/xmltv-stream.test.js | Adds unit coverage for chunked parsing, gzip decoding, size limits, and a representative “large feed” scenario. |
| test/unit/cache-manager.test.js | Adds coverage for byte-bounded cache eviction behavior. |
| test/integration/output-profile-routes.test.js | Verifies atomic save behavior across profile metadata, canonical edits, and output entries. |
| test/integration/mcp.test.js | Adds integration coverage for the new save_output_profile MCP tool. |
| server/xmltv-stream.js | Introduces streaming XMLTV read/limit utilities (decodeXmltvStream, readXmltvRecords). |
| server/mcp.js | Adds save_output_profile MCP tool and surfaces it in workflow metadata. |
| server/epg.js | Switches ingestion to streamed XMLTV reading, adds byte-bounded EPG cache, and introduces programme indexing for guide queries. |
| server/canonical.js | Adds atomic HTTP save route for output profile edits + refresh/invalidate behavior. |
| libs/output-profile-service.js | Implements transactional saveOutputProfile() to apply UI edits atomically. |
| libs/cache-manager.js | Adds max-bytes cache bounding and background expiry purging. |
| admin/src/App.vue | Replaces many sequential PATCH calls with a single atomic PUT save request. |
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const json = await response.json(); | ||
| if (!response.ok) { | ||
| throw new Error(buildApiErrorMessage(json, 'Failed to save output profile')); | ||
| } |
| // Expiry must not depend on a later read of the same cache key. The | ||
| // timer is unref'd so a cache never keeps the service/test process alive. | ||
| this.cleanupTimer = | ||
| this.ttl > 0 | ||
| ? setInterval(() => this.purgeExpired(), Math.max(1000, Math.min(this.ttl, 60000))).unref() | ||
| : null; |
| if (result?.error === 'canonical-not-found') { | ||
| return res.status(404).json({ error: `Canonical channel not found: ${result.canonicalId}` }); | ||
| } | ||
| if (result?.error === 'binding-not-found' || result?.error === 'guide-binding-not-found') { | ||
| return res.status(404).json({ error: `Channel binding not found for canonical channel ${result.canonicalId}` }); | ||
| } | ||
| if (result?.error === 'entry-not-found') { | ||
| return res.status(404).json({ error: `Output profile entry not found for canonical channel ${result.canonicalId}` }); | ||
| } | ||
| if (result?.error === 'invalid-entry') { | ||
| return res.status(400).json({ error: `Invalid output profile entry for canonical channel ${result.canonicalId}` }); | ||
| } |
| let programmes = tvgId | ||
| ? guideProgrammeIndex.get(tvgId) || [] | ||
| : Array.from(guideProgrammeIndex.values()).flat(); |
| const closeTag = `</${activeType}>`; | ||
| const openingEnd = buffer.indexOf('>'); | ||
| const selfClosing = openingEnd !== -1 && /\/\s*$/.test(buffer.slice(0, openingEnd)); | ||
| const closeIndex = selfClosing ? -1 : buffer.toLowerCase().indexOf(closeTag); | ||
| const endIndex = selfClosing ? openingEnd + 1 : closeIndex === -1 ? -1 : closeIndex + closeTag.length; |
Summary
Fixes #172
Fixes #179
Validation