feat(live): paginate catalog and search server-side (#49)

Load Live channels from a filtered SQLite cursor API, keep navigation and guide consumers working across pages, and retain the QA reproduction coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 22:43:29 +02:00
parent 8239e6043b
commit e2249154a5
13 changed files with 1280 additions and 387 deletions

View File

@ -11,28 +11,71 @@ class GuidePage {
// EPG guide will lazy load when shown
}
async show() {
// Ensure channel data is loaded before rendering EPG
// This fixes a race condition where navigating directly to the Guide page
// before visiting Live TV would result in an empty EPG.
async loadCatalog() {
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
const revision = API.liveCatalogRevision || 0;
if (channelList.guideCatalog && channelList.guideCatalogRevision === revision) return;
if (this.catalogLoad?.revision === revision) return this.catalogLoad.promise;
const promise = (async () => {
const channels = [];
let cursor;
do {
// Guide ignores group visibility; EpgGuide filters individually hidden channels.
const result = await API.channels.liveCatalog({ limit: 500, cursor, includeHidden: true });
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
channels.push(...result.items);
const next = result.page.hasMore ? result.page.nextCursor : null;
if (result.page.hasMore && (!next || next === cursor)) throw new Error('Invalid catalogue cursor');
cursor = next;
} while (cursor);
await channelList.loadHiddenItems();
if (revision !== (API.liveCatalogRevision || 0)) throw new Error('Catalogue changed');
channelList.guideCatalog = channels;
channelList.guideCatalogRevision = revision;
})();
this.catalogLoad = { revision, promise };
try { await promise; }
finally {
if (this.catalogLoad?.promise === promise) this.catalogLoad = null;
}
}
// Only load EPG data if not already loaded
if (!this.app.epgGuide.programmes || this.app.epgGuide.programmes.length === 0) {
await this.app.epgGuide.loadEpg();
} else {
// Just re-render with existing data (updates time position)
this.app.epgGuide.render();
async show() {
const visit = this.visit = (this.visit || 0) + 1;
const container = this.app.epgGuide.container;
const channelList = this.app.channelList;
const needsCatalog = !channelList.guideCatalog || channelList.guideCatalogRevision !== (API.liveCatalogRevision || 0);
if (needsCatalog) {
container.textContent = 'Loading…';
container.setAttribute('aria-busy', 'true');
}
try {
await this.loadCatalog();
if (visit !== this.visit) return;
if (!this.app.epgGuide.programmes?.length) {
await this.app.epgGuide.loadEpg();
} else {
this.app.epgGuide.render();
}
} catch (err) {
if (visit !== this.visit) return;
container.textContent = 'Error loading channels';
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'btn btn-sm';
retry.textContent = 'Retry';
retry.addEventListener('click', () => this.show());
container.appendChild(retry);
} finally {
if (visit === this.visit) container.setAttribute('aria-busy', 'false');
}
}
hide() {
// Page is hidden
this.visit = (this.visit || 0) + 1;
this.app.epgGuide.container.setAttribute('aria-busy', 'false');
}
}
window.GuidePage = GuidePage;