Skip to content

Add Preview tab with merged channel list, guide data, and stream viewer#84

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/add-preview-mode-feature
Draft

Add Preview tab with merged channel list, guide data, and stream viewer#84
Copilot wants to merge 3 commits intomainfrom
copilot/add-preview-mode-feature

Conversation

Copy link
Contributor

Copilot AI commented Mar 9, 2026

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 by tvgId, 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):

  • Channel table — logo, guide number, name, group, source, TVG-ID; sortable columns, virtual scroll, real-time search/filter across all text fields. Lazy-loads from /channels on first visit.
  • ▶ Watch modal per channel:
    • Native <video> element; Safari uses built-in HLS, other browsers use the bundled HLS.js (hls.js npm package, v1.5.15), with graceful degradation to direct src on failure.
    • Stream URL display with Copy and Open ↗ actions.
    • Guide panel showing current/upcoming programmes from /api/guide.
    • Full teardown on close (HLS.js instance destroyed, <video> reset).

Dependencies

  • hls.js@1.5.15 added to admin/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.

- 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>
Copilot AI changed the title [WIP] Add preview mode for output visualization Add Preview tab with merged channel list, guide data, and stream viewer Mar 9, 2026
// 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';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot any reason to not just include this as part of the build and not require an external download?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/guide endpoint in server/epg.js that returns current and upcoming programmes from the merged EPG, along with two helper functions (parseXMLTVDate and extractTextField).
  • New Preview tab in admin/src/App.vue featuring 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.15 as a dependency in admin/package.json for 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.

Comment on lines +1084 to +1085
const ch = state.previewWatchingChannel;
const url = `${window.location.origin}/stream/${encodeURIComponent(ch.source || '')}/${encodeURIComponent(ch.name || '')}`;
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}`;

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +76
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);
}
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +414 to +422
* 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;
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
* 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) => {
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
* 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) => {
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
app.get('/api/guide', asyncHandler(async (req, res) => {
app.get('/api/guide', epgLimiter, asyncHandler(async (req, res) => {

Copilot uses AI. Check for mistakes.
*/
function extractTextField(raw) {
if (raw == null) return '';
if (typeof raw === 'object') return String(raw['#text'] ?? raw);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 '';
}

Copilot uses AI. Check for mistakes.
Comment on lines +444 to +450
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;
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants