Add Preview tab with merged channel list, guide data, and stream viewer#84
Add Preview tab with merged channel list, guide data, and stream viewer#84
Conversation
- server/epg.js: Add extractTextField() helper and parseXMLTVDate() for XMLTV parsing - server/epg.js: Add /api/guide endpoint returning current & upcoming programmes for a tvg-id - admin/src/App.vue: Add Preview tab with searchable channel list, ▶ Watch modal, stream URL copy, and EPG guide panel Co-authored-by: cbulock <176519+cbulock@users.noreply.github.com>
admin/src/App.vue
Outdated
| // HLS.js is loaded on-demand from CDN when a user clicks Watch and the browser | ||
| // does not support HLS natively (i.e., non-Safari browsers). The version is | ||
| // pinned so that automatic CDN updates don't change behaviour unexpectedly. | ||
| const HLS_JS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js'; |
There was a problem hiding this comment.
@copilot any reason to not just include this as part of the build and not require an external download?
There was a problem hiding this comment.
Good call. Added hls.js@1.5.15 as a proper npm dependency in admin/package.json and replaced the dynamic CDN script injection with a direct import Hls from 'hls.js' — so it gets bundled by Vite at build time with no runtime network dependency. Done in 69c3845.
- Add hls.js@1.5.15 to admin/package.json dependencies - Import Hls directly in App.vue instead of dynamic CDN script injection - Remove CDN URL constant and loadHlsJs() dynamic loader function - setupVideoPlayer() now uses the bundled Hls import synchronously Co-authored-by: cbulock <176519+cbulock@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a Preview tab to the admin UI, enabling operators to view the full merged channel list, inspect EPG guide data, and play back live streams directly from the admin interface. It includes a new server endpoint and corresponding client-side UI.
Changes:
- New
GET /api/guideendpoint inserver/epg.jsthat returns current and upcoming programmes from the merged EPG, along with two helper functions (parseXMLTVDateandextractTextField). - New Preview tab in
admin/src/App.vuefeaturing a searchable/sortable channel table, a video player modal with HLS.js support and native fallback, stream URL display with copy/open actions, and an EPG guide panel. - Added
hls.js@1.5.15as a dependency inadmin/package.jsonfor HLS stream playback in non-Safari browsers.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
server/epg.js |
Adds parseXMLTVDate, extractTextField helpers and new /api/guide endpoint for querying EPG programme data by channel |
admin/src/App.vue |
Adds Preview tab with channel table, video player modal (HLS.js + native fallback), guide panel, and related reactive state/computed properties |
admin/package.json |
Adds hls.js@^1.5.15 dependency for HLS stream playback |
admin/package-lock.json |
Lock file entry for hls.js@1.5.15 |
Files not reviewed (1)
- admin/package-lock.json: Language not supported
You can also share your feedback on Copilot code review. Take the survey.
| const ch = state.previewWatchingChannel; | ||
| const url = `${window.location.origin}/stream/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`; |
There was a problem hiding this comment.
The stream URL construction (/stream/${encodeURIComponent(ch.source)}/${encodeURIComponent(ch.name)}) is duplicated here, in setupVideoPlayer (line 1038), and in the previewStreamUrl computed (line 1344). Consider reusing the previewStreamUrl computed value with window.location.origin prepended, instead of reconstructing the URL from scratch, to avoid drift between the three copies.
| const ch = state.previewWatchingChannel; | |
| const url = `${window.location.origin}/stream/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`; | |
| const relativeUrl = previewStreamUrl.value; | |
| if (!relativeUrl) { | |
| message.error('No stream URL available'); | |
| return; | |
| } | |
| const url = `${window.location.origin}${relativeUrl}`; |
| function extractTextField(raw) { | ||
| if (raw == null) return ''; | ||
| if (typeof raw === 'object') return String(raw['#text'] ?? raw); | ||
| return String(raw); | ||
| } | ||
|
|
||
| /** | ||
| * Parse an XMLTV date string (e.g. "20240115143000 +0000") into a JS Date. | ||
| * Returns null when the string cannot be parsed. | ||
| * @param {string|number} str | ||
| * @returns {Date|null} | ||
| */ | ||
| function parseXMLTVDate(str) { | ||
| if (!str) return null; | ||
| const match = String(str).match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})?/); | ||
| if (!match) return null; | ||
| const [, year, month, day, hour, min, sec, tz] = match; | ||
| let tzOffset = 0; | ||
| if (tz) { | ||
| const sign = tz[0] === '+' ? 1 : -1; | ||
| const tzH = parseInt(tz.slice(1, 3), 10); | ||
| const tzM = parseInt(tz.slice(3, 5), 10); | ||
| tzOffset = sign * (tzH * 60 + tzM) * 60 * 1000; | ||
| } | ||
| const utc = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec); | ||
| return new Date(utc - tzOffset); | ||
| } |
There was a problem hiding this comment.
The new parseXMLTVDate and extractTextField utility functions have no test coverage. This repo has comprehensive unit tests for other pure utility functions (e.g., test/unit/channel-matcher.test.js, test/unit/parseM3U.test.js, test/unit/cache-manager.test.js). These functions handle non-trivial XMLTV date parsing with timezone offsets and text field extraction with multiple input shapes — both are good candidates for unit tests. Additionally, the new /api/guide endpoint has no integration test, while the related /api/epg/validate endpoint patterns in test/integration/epg.test.js suggest integration tests would be appropriate.
| * tvgId — the tvg-id / XMLTV channel id to look up (required) | ||
| * hours — how many hours ahead to return (default 24, max 48) | ||
| */ | ||
| app.get('/api/guide', asyncHandler(async (req, res) => { | ||
| if (!mergedEPG) { | ||
| return res.status(503).json({ error: 'EPG not loaded yet' }); | ||
| } | ||
|
|
||
| const tvgId = req.query.tvgId ? String(req.query.tvgId) : null; |
There was a problem hiding this comment.
The JSDoc comment states tvgId is "required", but the code treats it as optional — when absent, all programmes across all channels are returned (up to the 20-entry limit). Either validate that tvgId is provided and return a 400 if missing, or update the comment to indicate it is optional.
| * tvgId — the tvg-id / XMLTV channel id to look up (required) | ||
| * hours — how many hours ahead to return (default 24, max 48) | ||
| */ | ||
| app.get('/api/guide', asyncHandler(async (req, res) => { |
There was a problem hiding this comment.
The /api/guide endpoint is missing requireAuth middleware. Every other /api/* endpoint in the codebase is protected by requireAuth (e.g., server/config.js, server/backup.js, server/cache.js, server/health.js, server/mapping.js, server/usage.js). Without it, unauthenticated users can query EPG guide data when admin auth is enabled.
Note: the existing /api/epg/validate endpoint in this same file (line 382) also lacks requireAuth, so this may be a pre-existing pattern in epg.js, but it should still be addressed. Import requireAuth from './auth.js' and add it as middleware to the route.
| * tvgId — the tvg-id / XMLTV channel id to look up (required) | ||
| * hours — how many hours ahead to return (default 24, max 48) | ||
| */ | ||
| app.get('/api/guide', asyncHandler(async (req, res) => { |
There was a problem hiding this comment.
The /api/guide endpoint parses the full mergedEPG XML string on every request with no rate limiting. All other endpoints in this codebase use a rate limiter (e.g., epgLimiter on /xmltv.xml, channelHealthLimiter, cacheLimiter, etc.). Parsing potentially large XML on each request without rate limiting could be exploited for resource exhaustion. Consider adding a rate limiter (the existing epgLimiter or a new one) to this route, consistent with the pattern used throughout the codebase.
| app.get('/api/guide', asyncHandler(async (req, res) => { | |
| app.get('/api/guide', epgLimiter, asyncHandler(async (req, res) => { |
| */ | ||
| function extractTextField(raw) { | ||
| if (raw == null) return ''; | ||
| if (typeof raw === 'object') return String(raw['#text'] ?? raw); |
There was a problem hiding this comment.
When raw is an object that lacks a #text property (e.g., { '@_lang': 'en' } from a <title lang="en"/> with no text content), raw['#text'] is undefined and ?? raw falls through to String(raw), which produces "[object Object]". Consider handling arrays (fast-xml-parser can produce arrays for repeated elements like multiple <title> tags) and objects without #text more gracefully — for instance, by checking Array.isArray(raw) and extracting text from the first element, and falling back to '' instead of String(raw) when #text is missing.
| if (typeof raw === 'object') return String(raw['#text'] ?? raw); | |
| // fast-xml-parser may return arrays for repeated elements; use the first entry | |
| if (Array.isArray(raw)) { | |
| if (raw.length === 0) return ''; | |
| return extractTextField(raw[0]); | |
| } | |
| if (typeof raw === 'object') { | |
| // Typical shape: { '#text': 'Title', '@_lang': 'en' } | |
| if (Object.prototype.hasOwnProperty.call(raw, '#text')) { | |
| const value = raw['#text']; | |
| return value == null ? '' : String(value); | |
| } | |
| // Avoid String(raw) => "[object Object]" when there is no text content | |
| return ''; | |
| } |
| return stopMs > now - 60 * 1000 && startMs < cutoff; | ||
| }); | ||
|
|
||
| // Sort by start time ascending | ||
| programmes.sort((a, b) => { | ||
| const aMs = parseXMLTVDate(a['@_start'])?.getTime() || 0; | ||
| const bMs = parseXMLTVDate(b['@_start'])?.getTime() || 0; |
There was a problem hiding this comment.
parseXMLTVDate is called again for every comparison during sorting, despite having already been called for each programme during the preceding filter step. In a comparison-based sort, this means O(n log n) regex+Date constructions. Consider computing and caching the start timestamp (e.g., via a Map or by attaching it to each programme object) before sorting, so each programme's date is parsed only once.
| return stopMs > now - 60 * 1000 && startMs < cutoff; | |
| }); | |
| // Sort by start time ascending | |
| programmes.sort((a, b) => { | |
| const aMs = parseXMLTVDate(a['@_start'])?.getTime() || 0; | |
| const bMs = parseXMLTVDate(b['@_start'])?.getTime() || 0; | |
| // Cache the start time in milliseconds so we do not need to re-parse during sorting | |
| p._startMs = startMs; | |
| return stopMs > now - 60 * 1000 && startMs < cutoff; | |
| }); | |
| // Sort by start time ascending | |
| programmes.sort((a, b) => { | |
| const aMs = typeof a._startMs === 'number' ? a._startMs : 0; | |
| const bMs = typeof b._startMs === 'number' ? b._startMs : 0; |
Adds a Preview tab to the admin UI so operators can verify the full merged output — channel list, EPG coverage, and live stream playback — without leaving the admin interface.
Server (
server/epg.js)GET /api/guide?tvgId=&hours=— new endpoint serving current + upcoming programmes from the in-memory merged EPG. Filters bytvgId, 24h window (max 48h), up to 20 entries, sorted by start time.parseXMLTVDate(str)— parses XMLTV timestamps (20240115143000 +0000) with full timezone offset support.extractTextField(raw)— normalises fast-xml-parser text nodes that may be a raw string or{ '#text': '...', '@_lang': '...' }.Admin UI (
admin/src/App.vue)New Preview tab (between Mapping and Health):
/channelson first visit.<video>element; Safari uses built-in HLS, other browsers use the bundled HLS.js (hls.jsnpm package,v1.5.15), with graceful degradation to directsrcon failure./api/guide.<video>reset).Dependencies
hls.js@1.5.15added toadmin/package.json— bundled by Vite at build time; no external CDN download required at runtime.Screenshots
Channel list

Watch modal with stream player and guide

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.