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;

View File

@ -195,34 +195,13 @@ class HomePage {
const section = document.getElementById('favorite-channels-section');
if (!list || !section) return;
const version = this.favoriteCatalogVersion = (this.favoriteCatalogVersion || 0) + 1;
try {
// Fetch favorite channels for current user
const favorites = await window.API.request('GET', '/favorites?itemType=channel');
if (!favorites || favorites.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
return;
}
// Ensure channel list is loaded to resolve channel details
const channelList = this.app.channelList;
if (!channelList.channels || channelList.channels.length === 0) {
await channelList.loadSources();
await channelList.loadChannels();
}
// Match favorites to channel data
const channels = [];
for (const fav of favorites) {
// Find channel in loaded channel list
const channel = channelList.channels.find(ch =>
String(ch.sourceId) === String(fav.source_id) &&
(String(ch.id) === String(fav.item_id) || String(ch.streamId) === String(fav.item_id))
);
if (channel) {
channels.push({ ...channel, favoriteId: fav.id });
}
}
const result = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200 });
if (version !== this.favoriteCatalogVersion) return;
const channels = result.items;
channelList.favoriteCatalog = channels;
if (channels.length === 0) {
list.innerHTML = '<div class="empty-state hint">Add channels to favorites from Live TV</div>';
@ -234,6 +213,7 @@ class HomePage {
// Attach click handlers
list.querySelectorAll('.channel-tile').forEach(tile => {
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, tile.dataset.channelId, tile.dataset.sourceId));
tile.addEventListener('click', () => {
const channelId = tile.dataset.channelId;
const sourceId = tile.dataset.sourceId;
@ -241,6 +221,48 @@ class HomePage {
});
});
let nextCursor = result.page.nextCursor;
if (result.page.hasMore) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'channel-tile channel-tile-action';
more.textContent = 'Load more';
list.appendChild(more);
more.addEventListener('click', async event => {
const keyboard = event.detail === 0;
const scrollLeft = list.scrollLeft;
more.disabled = true;
more.textContent = 'Loading…';
let firstNewTile;
try {
const page = await API.channels.liveCatalog({ favoriteOnly: true, limit: 200, cursor: nextCursor });
if (version !== this.favoriteCatalogVersion) return;
channelList.favoriteCatalog.push(...page.items);
for (const channel of page.items) {
const wrapper = document.createElement('div');
wrapper.innerHTML = this.createChannelTile(channel);
const tile = wrapper.firstElementChild;
tile.addEventListener('click', () => this.playChannel(channel.id, channel.sourceId));
tile.addEventListener('keydown', event => this.activateFavoriteTile(event, channel.id, channel.sourceId));
firstNewTile ||= tile;
list.insertBefore(tile, more);
}
nextCursor = page.page.nextCursor;
if (keyboard && firstNewTile) {
firstNewTile.focus({ preventScroll: true });
firstNewTile.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} else {
list.scrollLeft = scrollLeft;
}
more.hidden = !page.page.hasMore;
more.textContent = 'Load more';
this.updateScrollArrows();
} catch (err) {
more.textContent = 'Retry';
} finally { more.disabled = false; }
});
}
// Update scroll arrows after content renders
this.updateScrollArrows();
@ -254,9 +276,11 @@ class HomePage {
const logo = channel.tvgLogo || '/img/placeholder.png';
const logoUrl = logo.startsWith('http') ? `/api/proxy/image?url=${encodeURIComponent(logo)}` : logo;
const name = channel.name || 'Unknown';
const channelId = this.app.channelList.escapeHtml(String(channel.id));
const sourceId = this.app.channelList.escapeHtml(String(channel.sourceId));
return `
<div class="channel-tile" data-channel-id="${channel.id}" data-source-id="${channel.sourceId}">
<div class="channel-tile" role="button" tabindex="0" data-channel-id="${channelId}" data-source-id="${sourceId}">
<div class="tile-logo">
<img src="${logoUrl}" alt="${name}" loading="lazy" onerror="this.onerror=null;this.src='/img/placeholder.png'">
</div>
@ -265,29 +289,21 @@ class HomePage {
`;
}
playChannel(channelId, sourceId) {
// Navigate to Live TV and select the channel
this.app.navigateTo('live');
activateFavoriteTile(event, channelId, sourceId) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.playChannel(channelId, sourceId);
}
}
// Small delay to ensure page is ready
setTimeout(() => {
const channelList = this.app.channelList;
if (channelList) {
// Find and select the channel
const channel = channelList.channels.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId)
);
if (channel) {
channelList.selectChannel({
channelId: channel.id,
sourceId: channel.sourceId,
sourceType: channel.sourceType,
streamId: channel.streamId || '',
url: channel.url || ''
});
}
}
}, 100);
playChannel(channelId, sourceId) {
const channelList = this.app.channelList;
const channel = channelList.favoriteCatalog?.find(ch =>
String(ch.id) === String(channelId) && String(ch.sourceId) === String(sourceId));
if (!channel) return;
this.app.navigateTo('live');
// selectChannel resolves Home DTOs independently of the active Live filters.
channelList.selectChannel({ channelId: channel.id, sourceId: channel.sourceId });
}
renderHistory(items) {