diff --git a/app/renderer.js b/app/renderer.js
index db17db6..0914779 100644
--- a/app/renderer.js
+++ b/app/renderer.js
@@ -476,6 +476,9 @@ let movieCatalogInfo = null;
let movieCatalogError = '';
let movieCatalogRetryPage = 1;
const loadedProviderMovies = new Map();
+const loadedProviderShows = new Map();
+let seriesCatalogError = '';
+let seriesCatalogRetryPage = 1;
let activeLiveCountry = '';
let activeLiveLanguage = '';
let activeLiveWindow = 'now';
@@ -786,6 +789,10 @@ const isCurrentSearchRequest = (term, sequence, scope) => (
);
const localSearchResults = (term, scope = getSearchScope()) => {
+ if (scope.category === 'Watch' && scope.subcategory === 'TV Shows' && !TMDB_API_KEY) {
+ return rankSearchResults([...loadedProviderShows.values(), ...WATCH_CATALOG]
+ .filter(item => itemMatchesSearchScope(item, scope)), term);
+ }
if (scope.category === 'Watch' && scope.subcategory === 'Movies' && !TMDB_API_KEY) {
return rankSearchResults([...loadedProviderMovies.values(), ...WATCH_CATALOG]
.filter(item => itemMatchesSearchScope(item, scope)), term);
@@ -947,6 +954,10 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
watchBrowseKey = key;
watchBrowseLoading = true;
watchBrowseLoaded = false;
+ if (activeSubcategory === 'TV Shows') {
+ seriesCatalogError = '';
+ seriesCatalogRetryPage = page;
+ }
if (activeSubcategory === 'Movies') {
movieCatalogError = '';
movieCatalogRetryPage = page;
@@ -970,6 +981,13 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
liveDirectoryFacets = directory.facets || { countries: [], languages: [], sports: [] };
liveDirectoryTotal = Number(directory.total) || items.length;
liveDirectoryCached = Boolean(directory.cached);
+ } else if (filter.source === 'provider-shows') {
+ const response = await window.harbor?.getVidSrcShows?.(page);
+ if (response?.status !== 'ok') throw new Error(response?.message || 'TV library is unavailable.');
+ const result = watchBrowseApi.providerShowsPage(response.data, page);
+ items = result.items.map(item => ({ ...item, sources: [{ name: 'EMBED PROVIDER', badge: 'badge-embed' }] }));
+ items.forEach(item => loadedProviderShows.set(mediaKey(item), item));
+ canLoadMore = result.canLoadMore;
} else if (filter.source === 'provider-catalog' || (activeSubcategory === 'Movies' && !TMDB_API_KEY)) {
const result = await loadVidSrcMovieCatalog(page);
items = result.items.filter((item) => itemMatchesWatchFilter(item));
@@ -991,7 +1009,7 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
? [...watchBrowseItems, ...items.filter((item) => !watchBrowseItems.some((existing) => mediaKey(existing) === mediaKey(item)))]
: items;
watchBrowsePage = page;
- watchBrowseCanLoadMore = (filter.source === 'tmdb' || activeSubcategory === 'Movies') && canLoadMore;
+ watchBrowseCanLoadMore = (filter.source === 'tmdb' || filter.source === 'provider-shows' || activeSubcategory === 'Movies') && canLoadMore;
watchBrowseLoading = false;
watchBrowseLoaded = true;
if ((filter.source === 'iptv' || filter.source === 'free-events') && query.trim()) {
@@ -1004,6 +1022,9 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
if (activeSubcategory === 'Movies') {
movieCatalogError = 'Movie library could not be loaded. ' + (error?.message || 'Try again.');
if (!append) watchBrowseItems = localWatchBrowseItems();
+ } else if (filter.source === 'provider-shows') {
+ seriesCatalogError = 'TV library could not be loaded. ' + (error?.message || 'Try again.');
+ if (!append) watchBrowseItems = localWatchBrowseItems();
} else watchBrowseItems = localWatchBrowseItems();
watchBrowseCanLoadMore = false;
watchBrowseLoading = false;
@@ -1017,7 +1038,7 @@ const loadActiveWatchBrowse = async ({ append = false, requestedPage = 1 } = {})
}
}
renderResources();
- showStatusToast(activeSubcategory === 'Movies' ? 'Movie library unavailable' : 'Live catalog is temporarily unavailable', 'Showing Harbor’s built-in picks instead.');
+ showStatusToast(activeSubcategory === 'Movies' ? 'Movie library unavailable' : activeSubcategory === 'TV Shows' ? 'TV library unavailable' : 'Live catalog is temporarily unavailable', 'Showing Harbor’s built-in picks instead.');
}
};
@@ -1309,7 +1330,7 @@ const applySearchBatch = (term, sequence, scope, localResults, batches, totals,
const searchGlobalMedia = async (term, sequence, scope, page = 1, append = false) => {
const normalizedTerm = normalizeSearchText(term);
if (normalizedTerm.length < 2 || !isCurrentSearchRequest(term, sequence, scope)) return;
- if (scope.category === 'Watch' && scope.subcategory === 'Movies' && !TMDB_API_KEY) {
+ if (scope.category === 'Watch' && ['Movies', 'TV Shows'].includes(scope.subcategory) && !TMDB_API_KEY) {
currentMediaList = localSearchResults(term, scope);
searchState = { term: normalizedTerm, loading: false, total: currentMediaList.length, page: 1, canLoadMore: false, partial: false };
renderResources();
@@ -1491,6 +1512,15 @@ const beginSearch = (term) => {
return;
}
+ // Provider-only catalog search changes as more pages are loaded. Recompute
+ // local results each time rather than reuse a remote-search cache entry.
+ if (scope.category === 'Watch' && ['Movies', 'TV Shows'].includes(scope.subcategory) && !TMDB_API_KEY) {
+ currentMediaList = localSearchResults(term, scope);
+ searchState = { term: normalizedTerm, loading: false, total: currentMediaList.length, page: 1, canLoadMore: false, partial: false };
+ renderResources();
+ return;
+ }
+
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.createdAt < SEARCH_CACHE_TTL_MS) {
currentMediaList = [...cached.items];
@@ -2733,6 +2763,12 @@ const renderResources = () => {
retry.addEventListener('click', () => void loadActiveWatchBrowse({requestedPage:movieCatalogRetryPage}));
resultsSection.append(retry);
}
+ if (activeCategory === 'Watch' && activeSubcategory === 'TV Shows' && seriesCatalogError) {
+ headingCopy.append(createElement('p', '', seriesCatalogError));
+ const retry = Object.assign(createElement('button', 'button-secondary', 'Retry TV library'), { type: 'button' });
+ retry.addEventListener('click', () => void loadActiveWatchBrowse({ append: watchBrowseItems.length > 0 && seriesCatalogRetryPage > 1, requestedPage: seriesCatalogRetryPage }));
+ resultsSection.append(retry);
+ }
if (watchBrowseCanLoadMore && !watchBrowseLoading) {
const moreWrap = createElement('div', 'load-more-wrap');
const moreButton = Object.assign(createElement('button', 'button-secondary load-more-button', 'Show more'), { type: 'button' });
@@ -2745,7 +2781,8 @@ const renderResources = () => {
resourceList.replaceChildren(...rails);
- searchInput.placeholder = activeCategory === 'Watch' && activeSubcategory === 'Movies' && !TMDB_API_KEY ? 'Search loaded movies' : 'Search ' + searchScopeName;
+ searchInput.placeholder = activeCategory === 'Watch' && !TMDB_API_KEY && ['Movies', 'TV Shows'].includes(activeSubcategory)
+ ? 'Search loaded ' + (activeSubcategory === 'Movies' ? 'movies' : 'TV shows') : 'Search ' + searchScopeName;
searchInput.setAttribute('aria-label', 'Search ' + searchScopeName);
heroKicker.textContent = query.trim() ? 'Search ' + searchScopeName : config.kicker;
directoryTitle.textContent = query.trim()
diff --git a/electron/main.cjs b/electron/main.cjs
index 888e9e0..b6643ce 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -254,6 +254,28 @@ const fetchVidSrcMovies = async (_event, requestedPage) => {
}
};
+const fetchVidSrcShows = async (_event, requestedPage) => {
+ const page = requestedPage === undefined ? 1 : Number(requestedPage);
+ if (!Number.isSafeInteger(page) || page < 1) {
+ return { status: 'error', message: 'TV library page must be a positive integer.' };
+ }
+ const endpoint = `https://vidapi.ru/tvshows/latest/page-${page}.json`;
+ try {
+ const response = await net.fetch(endpoint, {
+ headers: { accept: 'application/json', 'user-agent': `Harbor/${app.getVersion()}` },
+ signal: AbortSignal.timeout(12000)
+ });
+ if (!response.ok) return { status: 'error', message: `TV library returned HTTP ${response.status}.` };
+ const data = await response.json();
+ if (!Array.isArray(data.items) || !Number.isSafeInteger(data.total_pages) || !Number.isSafeInteger(data.total)) {
+ return { status: 'error', message: 'TV library returned an unexpected response.' };
+ }
+ return { status: 'ok', source: 'VidAPI', data };
+ } catch (error) {
+ return { status: 'error', message: error instanceof Error ? error.message : 'TV library request failed.' };
+ }
+};
+
const isAllowedStreamUrl = (candidate) => {
if (candidate === 'about:blank') return true;
try {
@@ -515,6 +537,7 @@ app.whenReady().then(() => {
});
ipcMain.handle('harbor:get-directory-links', () => readDirectoryLinks());
ipcMain.handle('harbor:get-vidsrc-movies', fetchVidSrcMovies);
+ ipcMain.handle('harbor:get-vidsrc-shows', fetchVidSrcShows);
ipcMain.handle('harbor:open-directory-link', async (_event, candidate) => {
if (typeof candidate !== 'string') return { status: 'invalid' };
const link = readDirectoryLinks().find((entry) => entry.url === candidate);
diff --git a/electron/preload.cjs b/electron/preload.cjs
index dca38ed..656d7fb 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('harbor', {
removeGame: (libraryId) => ipcRenderer.invoke('harbor:remove-game', libraryId),
getDirectoryLinks: () => ipcRenderer.invoke('harbor:get-directory-links'),
getVidSrcMovies: (page) => ipcRenderer.invoke('harbor:get-vidsrc-movies', page),
+ getVidSrcShows: (page) => ipcRenderer.invoke('harbor:get-vidsrc-shows', page),
openDirectoryLink: (url) => ipcRenderer.invoke('harbor:open-directory-link', url),
exportUserData: (state) => ipcRenderer.invoke('harbor:export-user-data', state),
importUserData: () => ipcRenderer.invoke('harbor:import-user-data'),
diff --git a/package.json b/package.json
index 73e693c..913fd4a 100644
--- a/package.json
+++ b/package.json
@@ -16,8 +16,9 @@
"start": "electron .",
"icon:render": "electron scripts/render-icon.cjs",
"tv:icon": "electron scripts/render-tv-icon.cjs",
- "test": "npm run test:static && node tests/vidsrc-catalog.test.cjs && node tests/player-readiness.test.cjs && node tests/prune-old-downloads.test.cjs",
+ "test": "npm run test:static && node tests/vidsrc-catalog.test.cjs && node tests/provider-shows.test.cjs && node tests/player-readiness.test.cjs && node tests/prune-old-downloads.test.cjs",
"test:consumer": "npm test && npm run test:desktop-smoke && npm run test:player",
+ "test:provider-shows:ui": "node tests/run-desktop-smoke.cjs provider-shows-smoke.cjs",
"test:player": "node tests/run-player-smoke.cjs",
"test:all": "npm run test:consumer && npm run test:tv-smoke",
"test:desktop-smoke": "node tests/run-desktop-smoke.cjs",
diff --git a/shared/watch-browse.js b/shared/watch-browse.js
index eca5461..e027046 100644
--- a/shared/watch-browse.js
+++ b/shared/watch-browse.js
@@ -37,6 +37,7 @@
{ id: 'documentary', label: 'Documentary', source: 'tmdb', endpoint: 'discover/movie', mediaType: 'movie', params: { with_genres: '99', sort_by: 'popularity.desc' }, local: { terms: ['documentary'] } }
],
'TV Shows': [
+ { id: 'latest-library', label: 'Latest Library', source: 'provider-shows', mediaType: 'tv' },
{ id: 'popular', label: 'Popular', source: 'tmdb', endpoint: 'tv/popular', mediaType: 'tv' },
{ id: 'airing-today', label: 'Airing Today', source: 'tmdb', endpoint: 'tv/airing_today', mediaType: 'tv' },
{ id: 'on-tv', label: 'On TV', source: 'tmdb', endpoint: 'tv/on_the_air', mediaType: 'tv' },
@@ -132,6 +133,47 @@
return { endpoint: filter.endpoint, mediaType: filter.mediaType, params: params };
}
+ // The public provider feed has no language/country data. Animation alone
+ // must not be labelled Anime; keep these entries as television series.
+ function normalizeProviderShow(item) {
+ if (!item || typeof item !== 'object') return null;
+ var id = String(item.tmdb_id || item.tmdbId || item.id || '');
+ if (!/^[1-9][0-9]*$/.test(id)) return null;
+ var title = String(item.title || item.name || '').trim();
+ if (!title) return null;
+ var genres = Array.isArray(item.genres) ? item.genres : String(item.genre || '').split(',');
+ genres = genres.map(function (genre) { return typeof genre === 'string' ? genre.trim() : genre && genre.name || ''; }).filter(Boolean);
+ var rating = Number(item.rating == null ? item.vote_average : item.rating);
+ var poster = String(item.poster_url || item.poster_path || '');
+ if (poster.charAt(0) === '/') poster = 'https://image.tmdb.org/t/p/w500' + poster;
+ if (!/^https?:\/\//i.test(poster)) poster = '';
+ return {
+ id: 'vidsrc-tv-' + id, tmdbId: id, name: title, category: 'Watch', type: 'tv',
+ year: String(item.year || String(item.first_air_date || '').slice(0, 4)),
+ rating: Number.isFinite(rating) ? rating.toFixed(1) : '',
+ sections: ['TV Show', 'Series'].concat(genres), artworkUrl: poster,
+ overview: item.overview || 'Listed in the provider TV library. Choose a playback server to try this title.'
+ };
+ }
+
+ function providerShowsPage(data, page) {
+ if (!data || !Array.isArray(data.items) || !Number.isSafeInteger(data.total_pages)
+ || data.total_pages < 1 || !Number.isSafeInteger(data.total) || data.total < 0
+ || (data.page != null && data.page !== page)) throw new Error('TV library returned an unexpected response.');
+ var seen = {};
+ var items = data.items.map(normalizeProviderShow).filter(function (item) {
+ if (!item || seen[item.id]) return false;
+ seen[item.id] = true; return true;
+ });
+ return { items: items, page: page, total: data.total, totalPages: data.total_pages, canLoadMore: page < data.total_pages };
+ }
+
+ function loadProviderShows(page, request) {
+ if (!Number.isSafeInteger(page) || page < 1) return Promise.reject(new Error('TV library page must be a positive integer.'));
+ return Promise.resolve().then(function () { return request('https://vidapi.ru/tvshows/latest/page-' + page + '.json'); })
+ .then(function (data) { return providerShowsPage(data, page); });
+ }
+
function matchesLocalFilter(item, section, filterId) {
var filter = getFilter(section, filterId);
var rules = filter && (filter.local || (filter.terms ? { terms: filter.terms } : null));
@@ -545,6 +587,9 @@
}
return {
+ normalizeProviderShow: normalizeProviderShow,
+ providerShowsPage: providerShowsPage,
+ loadProviderShows: loadProviderShows,
taxonomy: taxonomy,
getFilters: getFilters,
getFilter: getFilter,
diff --git a/tests/provider-shows-smoke.cjs b/tests/provider-shows-smoke.cjs
new file mode 100644
index 0000000..aff00e2
--- /dev/null
+++ b/tests/provider-shows-smoke.cjs
@@ -0,0 +1,128 @@
+const assert = require('node:assert/strict');
+const connect = async (port, predicate) => {
+ const targets = await fetch(`http://127.0.0.1:${port}/json`).then((response) => response.json());
+ const target = targets.find(predicate);
+ assert(target, `Debug target missing on port ${port}`);
+ const socket = new WebSocket(target.webSocketDebuggerUrl);
+ const pending = new Map();
+ let sequence = 0;
+ socket.addEventListener('message', (event) => {
+ const message = JSON.parse(event.data.toString());
+ if (!pending.has(message.id)) return;
+ const { resolve, reject } = pending.get(message.id);
+ pending.delete(message.id);
+ if (message.error || message.result?.exceptionDetails) {
+ reject(new Error(message.error?.message || message.result.exceptionDetails.exception?.description));
+ } else resolve(message.result?.identifier ?? message.result?.result?.value);
+ });
+ await new Promise((resolve, reject) => {
+ socket.addEventListener('open', resolve, { once: true });
+ socket.addEventListener('error', reject, { once: true });
+ });
+ return {
+ command: (method, params = {}) => new Promise((resolve, reject) => {
+ const id = ++sequence;
+ pending.set(id, { resolve, reject });
+ socket.send(JSON.stringify({ id, method, params }));
+ }),
+ evaluate: (expression) => new Promise((resolve, reject) => {
+ const id = ++sequence;
+ pending.set(id, { resolve, reject });
+ socket.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: {
+ expression, awaitPromise: true, returnByValue: true, userGesture: true
+ } }));
+ }),
+ close: () => socket.close()
+ };
+};
+
+
+const run = async () => {
+ let renderer, main;
+ try {
+ renderer = await connect(process.argv[2], t => t.type === 'page' && /\/app\/index\.html(?:$|[?#])/i.test(t.url));
+ main = await connect(process.env.HARBOR_QA_MAIN_PORT, () => true);
+ await renderer.command('Page.enable');
+ for (const configured of [false, true]) {
+ await main.evaluate(`(() => {
+ const net = process.mainModule.require('electron').net;
+ if (!globalThis.__showsOriginalFetch) globalThis.__showsOriginalFetch = net.fetch;
+ globalThis.__showsRequests = []; globalThis.__showsFailNext = true;
+ net.fetch = async (url, options) => {
+ if (!String(url).startsWith('https://vidapi.ru/tvshows/latest/')) return globalThis.__showsOriginalFetch.call(net, url, options);
+ const page = Number(String(url).match(/page-(\\d+)/)[1]);
+ globalThis.__showsRequests.push(page);
+ if (page === 2 && globalThis.__showsFailNext) { globalThis.__showsFailNext = false; return {ok:false,status:503}; }
+ const row = id => ({tmdb_id:id,title:'Harbor Fixture Series '+id,genre:'Drama',rating:'7.5'});
+ return {ok:true,json:async()=>({page,total_pages:3,total:3,items:page===1?[row(910001),row(910001)]:page===2?[row(910001),row(910002)]:[]})};
+ }; return true;
+ })()`);
+ // Override only this isolated QA document, before production config loads.
+ // Exercise both key modes regardless of the CI/release build configuration.
+ const injection = await renderer.command('Page.addScriptToEvaluateOnNewDocument', { source: `
+ window.__showsMode = ${configured};
+ Object.defineProperty(window, 'HARBOR_CONFIG', {
+ get: () => Object.freeze({ tmdbApiKey: ${JSON.stringify(configured ? 'harborfixturecatalogkey00000000000' : '')} }),
+ set: () => {}, configurable: true
+ });
+ const originalFetch = window.fetch;
+ window.__showsTmdbRequests = 0;
+ window.fetch = (input, options) => {
+ const url = new URL(typeof input === 'string' ? input : input.url, location.href);
+ if (url.origin !== 'https://api.themoviedb.org') return originalFetch(input, options);
+ if (url.pathname.endsWith('/search/tv')) window.__showsTmdbRequests++;
+ const results = url.pathname.endsWith('/search/tv')
+ ? [{ id: 910002, name: 'Harbor Fixture Series 910002', genre_ids: [18], first_air_date: '2024-01-01', vote_average: 7.5 }] : [];
+ return Promise.resolve(new Response(JSON.stringify({ page: 1, total_pages: 1, total_results: results.length, results }),
+ { status: 200, headers: { 'content-type': 'application/json' } }));
+ };
+ ` });
+ await renderer.command('Page.reload');
+ const deadline = Date.now() + 12000;
+ while (true) {
+ try { if (await renderer.evaluate(`window.__showsMode === ${configured} && typeof setActiveSection === 'function'`)) break; } catch { /* Navigation replaces the old context. */ }
+ if (Date.now() > deadline) throw new Error('QA document-start injection did not become ready');
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+ const result = await renderer.evaluate(`(async () => {
+ const wait = async predicate => { const end=Date.now()+12000; while(!predicate()) { if(Date.now()>end) throw new Error('Shows UI did not settle'); await new Promise(r=>setTimeout(r,25)); } };
+ await wait(()=>typeof setActiveSection==='function');
+ if(Boolean(TMDB_API_KEY)!==${configured}) throw new Error('QA key-mode injection did not apply');
+ setActiveSection('Watch');
+ const category=[...document.querySelectorAll('.category-button')].find(b=>b.querySelector('.category-name')?.textContent==='TV Shows');
+ if(!category) throw new Error('TV Shows category missing'); category.click();
+ await wait(()=>watchBrowseLoaded&&!watchBrowseLoading&&watchBrowsePage===1);
+ const names=()=>[...document.querySelectorAll('#resource-list .category-grid .media-card-title')].map(n=>n.textContent.trim());
+ const search = term => { searchInput.value=term; searchInput.dispatchEvent(new Event('input',{bubbles:true})); };
+ const pageOne=names(); const filter=document.querySelector('#watch-filter-list .active')?.textContent.trim();
+ search('Harbor Fixture Series 910002'); await wait(()=>!searchState.loading); const missing=[...document.querySelectorAll('#resource-list .media-card-title')].map(n=>n.textContent.trim()); search('');
+ document.querySelector('#resource-list .load-more-button').click();
+ await wait(()=>!watchBrowseLoading&&Boolean(seriesCatalogError));
+ const outage=names(); const retry=[...document.querySelectorAll('#resource-list button')].find(b=>b.textContent==='Retry TV library');
+ if(!retry) throw new Error('Production retry button missing'); retry.click();
+ await wait(()=>!watchBrowseLoading&&!seriesCatalogError&&watchBrowsePage===2);
+ const pageTwo=names();
+ search('Harbor Fixture Series 910002'); await wait(()=>!searchState.loading); const found=[...document.querySelectorAll('#resource-list .media-card-title')].map(n=>n.textContent.trim());
+ const placeholder=searchInput.placeholder; const summary=document.querySelector('#resource-list').textContent;
+ search(''); document.querySelector('#resource-list .load-more-button').click();
+ await wait(()=>!watchBrowseLoading&&watchBrowsePage===3);
+ return {configured:${configured},tmdbRequests:window.__showsTmdbRequests,pageOne,filter,missing,outage,pageTwo,found,placeholder,summary,final:names(),more:Boolean(document.querySelector('#resource-list .load-more-button'))};
+ })()`);
+ const first='Harbor Fixture Series 910001', second='Harbor Fixture Series 910002';
+ assert.deepEqual(result.pageOne,[first]); assert.match(result.filter,/Latest Library/);
+ assert.deepEqual(result.missing,configured?[second]:[]); assert.deepEqual(result.outage,[first]);
+ assert.deepEqual(result.pageTwo,[first,second]); assert.deepEqual(result.found,[second]);
+ assert.match(result.placeholder,configured?/Search TV Shows/:/Search loaded TV shows/); assert.match(result.summary,/1 match in TV Shows/);
+ assert.deepEqual(result.final,[first,second]); assert.equal(result.more,false);
+ if (!configured) assert.equal(result.tmdbRequests,0,'No-key Shows search must not contact TMDB');
+ else assert(result.tmdbRequests > 0,'Configured-key mode must exercise the metadata search fixture');
+ const requests=await main.evaluate('globalThis.__showsRequests'); assert.deepEqual(requests,[1,2,2,3]);
+ await renderer.command('Page.removeScriptToEvaluateOnNewDocument', { identifier: injection });
+ console.log(JSON.stringify({production:'renderer -> preload IPC -> main net.fetch fixture',...result,requests},null,2));
+ }
+ } finally {
+ if(main) { await main.evaluate(`if(globalThis.__showsOriginalFetch)process.mainModule.require('electron').net.fetch=globalThis.__showsOriginalFetch; true`).catch(()=>{}); main.close(); }
+ if(renderer)renderer.close();
+ }
+};
+run().catch(error=>{console.error(error);process.exitCode=1;});
diff --git a/tests/provider-shows.test.cjs b/tests/provider-shows.test.cjs
new file mode 100644
index 0000000..19dd52d
--- /dev/null
+++ b/tests/provider-shows.test.cjs
@@ -0,0 +1,100 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const api = require('../shared/watch-browse.js');
+const read = file => process.env.HARBOR_QA_ASAR ? require('@electron/asar').extractFile(process.env.HARBOR_QA_ASAR, file).toString('utf8') : fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
+const slice = (file, start, end) => { const text = read(file); const a = text.indexOf(start); const b = text.indexOf(end, a); assert(a >= 0 && b > a); return text.slice(a, b); };
+const row = id => ({ tmdb_id: id, title: 'Series ' + id, genre: 'Animation, Comedy', rating: '7.5', poster_url: 'https://image.tmdb.org/t/p/w500/show.jpg' });
+const pageData = page => ({ page, total_pages: 3, total: 50, items: page === 1 ? [row(1), row(1), {}, null] : page === 2 ? [] : [row(3)] });
+(async () => {
+ assert.equal(api.defaultFilterId('TV Shows'), 'latest-library');
+ assert.equal(api.buildTmdbRequest('TV Shows', 'latest-library', 1), null);
+ const normalized = api.normalizeProviderShow(row(1));
+ assert.equal(normalized.type, 'tv', 'Animation without origin evidence must not become Anime');
+ assert.deepEqual(normalized.sections, ['TV Show', 'Series', 'Animation', 'Comedy']);
+ assert.equal(api.normalizeProviderShow({ ...row(1), poster_url: 'javascript:alert(1)' }).artworkUrl, '');
+ assert.equal(api.normalizeProviderShow({ ...row(1), tmdb_id: 'bad' }), null);
+ assert.equal(api.normalizeProviderShow({ ...row(1), title: ' ' }), null);
+ const requests = [];
+ const request = async url => { requests.push(url); return pageData(Number(url.match(/page-(\d+)/)[1])); };
+ assert.equal((await api.loadProviderShows(1, request)).items.length, 1);
+ assert.equal((await api.loadProviderShows(2, request)).canLoadMore, true, 'Empty normalized pages preserve provider pagination');
+ assert.equal((await api.loadProviderShows(3, request)).canLoadMore, false);
+ for (const page of [0,-1,1.5,NaN,Infinity]) await assert.rejects(api.loadProviderShows(page, request));
+ assert.equal(requests.length, 3);
+ for (const data of [{}, { ...pageData(1), total: '50' }, { ...pageData(1), page: 2 }]) assert.throws(() => api.providerShowsPage(data, 1));
+ const main = vm.createContext({ AbortSignal, app: { getVersion: () => 'test' }, net: { fetch: async url => ({ ok: true, json: async () => pageData(Number(url.match(/page-(\d+)/)[1])) }) } });
+ vm.runInContext(slice('electron/main.cjs', 'const fetchVidSrcShows =', 'const isAllowedStreamUrl ='), main);
+ assert.equal((await vm.runInContext('fetchVidSrcShows(null, 1)', main)).status, 'ok');
+ assert.equal((await vm.runInContext('fetchVidSrcShows(null, 0)', main)).status, 'error');
+ let fail = false;
+ const desktop = vm.createContext({
+ window: { harbor: { getVidSrcShows: async page => fail ? { status: 'error', message: 'outage' } : { status: 'ok', data: pageData(page) } } },
+ watchBrowseApi: api, TMDB_API_KEY: '', activeCategory: 'Watch', activeSubcategory: 'TV Shows', activeWatchFilter: 'latest-library',
+ loadedProviderShows: new Map(), seriesCatalogError: '', seriesCatalogRetryPage: 1, watchBrowseGeneration: 0, watchBrowseKey: '', watchBrowsePage: 1,
+ watchBrowseItems: [], watchBrowseLoading: false, watchBrowseLoaded: false, watchBrowseCanLoadMore: false, query: '',
+ currentWatchBrowseKey: () => 'shows', renderResources: () => {}, getSectionItems: () => [], itemMatchesWatchFilter: () => true, mediaKey: item => item.tmdbId, showStatusToast: () => {}
+ });
+ vm.runInContext(slice('app/renderer.js', 'const formatVidSrcMovie =', 'const loadMoreWatchBrowse ='), desktop);
+ await vm.runInContext('loadActiveWatchBrowse()', desktop);
+ assert.equal(desktop.watchBrowseItems.length, 1); assert.equal(desktop.watchBrowseCanLoadMore, true);
+ fail = true; await vm.runInContext('loadActiveWatchBrowse({append:true})', desktop);
+ assert.equal(desktop.watchBrowseItems.length, 1, 'Append outage preserves existing rows');
+ assert.match(desktop.seriesCatalogError, /outage/); assert.equal(desktop.watchBrowsePage, 1);
+ fail = false; await vm.runInContext('loadActiveWatchBrowse({append:true})', desktop);
+ assert.equal(desktop.watchBrowsePage, 2); assert.equal(desktop.watchBrowseCanLoadMore, true);
+ await vm.runInContext('loadActiveWatchBrowse({append:true})', desktop);
+ assert.deepEqual(Array.from(desktop.watchBrowseItems, item => item.tmdbId), ['1','3']); assert.equal(desktop.watchBrowseCanLoadMore, false);
+ const tv = vm.createContext({ loadedProviderShows: {}, fallbackWatch: [], liveItems: [], matchesWatchSubcategory: () => true, matchesWatchFilter: () => true, state: { subcategory: 'TV Shows', watchFilter: 'latest-library' }, watchBrowseApi: api, requestJson: request, TMDB_KEY: '' });
+ vm.runInContext(slice('tv/tv.js', ' function fetchWatch(', ' function syncOverlayAccessibility('), tv);
+ const tvItems = await vm.runInContext('fetchWatch("", 1)', tv);
+ assert.equal(tvItems[0].section, 'TV Shows'); assert.equal(tvItems[0].type, 'tv'); assert.equal(tvItems.catalogCanLoadMore, true);
+ const lastItems = await vm.runInContext('fetchWatch("", 3)', tv); assert.equal(lastItems.catalogCanLoadMore, false);
+ const searched = await vm.runInContext('fetchWatch("Series 3", 1)', tv); assert.equal(searched.length, 1); assert.equal(searched[0].tmdbId, '3'); assert.equal(searched.catalogCanLoadMore, false, 'Local cache search cannot fetch another upstream page');
+ // Run the real search entry points, including a stale cached miss followed by
+ // loading another provider page. Local provider search must never contact TMDB.
+ const searchRequests = [];
+ const searchScope = { category: 'Watch', subcategory: 'TV Shows' };
+ const search = vm.createContext({
+ loadedProviderShows: new Map(), WATCH_CATALOG: [], TMDB_API_KEY: '', TMDB_BASE: 'https://api.themoviedb.org/3',
+ activeCategory: 'Watch', activeSubcategory: 'TV Shows', watchBrowseLoaded: false, discoveryMediaList: [],
+ query: '', searchTimeout: null, searchController: null, searchSequence: 0, currentMediaList: [], searchState: {},
+ searchCache: new Map(), SEARCH_CACHE_TTL_MS: 60000, SEARCH_DEBOUNCE_MS: 0, AbortController,
+ normalizeSearchText: text => text.toLowerCase(), getSearchScope: () => searchScope, getSearchCacheKey: term => term,
+ itemMatchesSearchScope: item => item.type === 'tv', rankSearchResults: (items, term) => items.filter(item => item.name.toLowerCase().includes(term.toLowerCase())),
+ isCurrentSearchRequest: () => true, isLiveDirectory: () => false, renderResources: () => {},
+ clearTimeout: () => {}, setTimeout: () => 1,
+ fetchSearchJson: async url => { searchRequests.push(url); throw new Error('No-key search must stay local'); },
+ formatTmdbItem: item => item, formatItunesItem: item => item, formatItunesPodcastItem: item => item,
+ formatItunesAudiobookItem: item => item, formatOpenLibraryItem: item => item
+ });
+ vm.runInContext(slice('app/renderer.js', 'const localSearchResults =', 'const fetchSearchJson ='), search);
+ vm.runInContext(slice('app/renderer.js', 'const searchGlobalMedia =', 'const loadMoreSearchResults ='), search);
+ vm.runInContext('beginSearch("Series 3")', search);
+ assert.equal(search.currentMediaList.length, 0);
+ search.searchCache.set('Series 3', { createdAt: Date.now(), items: [], total: 0, partial: true });
+ vm.runInContext('beginSearch("")', search);
+ search.loadedProviderShows.set('3', api.normalizeProviderShow(row(3)));
+ vm.runInContext('beginSearch("Series 3")', search);
+ assert.equal(search.currentMediaList.length, 1, 'Repeated local search must recompute after a new provider page, even with a cached miss');
+ assert.equal(search.searchState.loading, false); assert.equal(search.searchState.partial, false);
+ await vm.runInContext('searchGlobalMedia("Series 3", searchSequence, getSearchScope())', search);
+ assert.equal(search.currentMediaList.length, 1); assert.equal(searchRequests.length, 0, 'Shows with no key must never request TMDB');
+
+ let tvFailure = false;
+ const tvLoad = vm.createContext({
+ itemLoadGeneration: 0, watchBrowseApi: api, document: {},
+ state: { section: 'Watch', subcategory: 'TV Shows', watchFilter: 'latest-library', query: '', page: 1, items: [], canLoadMore: false },
+ moreButton: {}, cardGrid: {}, rowKicker: {}, rowTitle: {},
+ renderSubcategories: () => {}, renderHero: () => {}, renderCards: () => {}, notify: () => {},
+ fetchCurrent: async (_query, page) => { if (tvFailure) throw new Error('outage'); const result = api.providerShowsPage(pageData(page), page); const items = result.items; items.catalogCanLoadMore = result.canLoadMore; return items; }
+ });
+ vm.runInContext(slice('tv/tv.js', ' function loadItems(', ' function setSection('), tvLoad);
+ await vm.runInContext('loadItems(false)', tvLoad); assert.equal(tvLoad.state.canLoadMore, true, 'Short provider pages still permit Show more');
+ tvLoad.state.page = 2; tvFailure = true; await vm.runInContext('loadItems(true)', tvLoad);
+ assert.equal(tvLoad.state.page, 1, 'Append outage must retry the same page'); assert.equal(tvLoad.state.items.length, 1); assert.equal(tvLoad.state.canLoadMore, true);
+ tvFailure = false; tvLoad.state.page = 2; await vm.runInContext('loadItems(true)', tvLoad); assert.equal(tvLoad.state.canLoadMore, true);
+ tvLoad.state.page = 3; await vm.runInContext('loadItems(true)', tvLoad); assert.equal(tvLoad.state.items.length, 2); assert.equal(tvLoad.state.canLoadMore, false);
+ console.log('Provider Shows: normalization, invalid pages/payloads, duplicates, empty-page continuation, outage preservation, IPC and both browse surfaces verified.');
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/release-config.test.cjs b/tests/release-config.test.cjs
index 748b419..dbe6786 100644
--- a/tests/release-config.test.cjs
+++ b/tests/release-config.test.cjs
@@ -12,6 +12,7 @@ const androidGradle = read('tv/android/app/build.gradle.kts');
const androidActivity = read('tv/android/app/src/main/java/com/harbor/tv/MainActivity.java');
const stageTv = read('scripts/stage-tv.cjs');
const sourcePackage = read('scripts/package-current-release.cjs');
+const samsungManifest = read('tv/samsung/config.xml');
const renderer = read('app/renderer.js');
const main = read('electron/main.cjs');
const landing = read('index.html');
@@ -31,6 +32,7 @@ assert(/\n build:\r?\n needs: verify/.test(workflow) && /\n tv:\r?\n nee
assert((workflow.match(/GITHUB_EVENT_NAME[^\n]*pull_request/g) || []).length >= 2, 'Pull-request catalog fallback is missing from a build job.');
assert(!workflow.includes('com.harbor.tv_2.0.0_all.ipk'), 'TV artifact naming is hardcoded to one version.');
assert(androidActivity.includes('https://appassets.androidplatform.net/assets/index.html') && androidActivity.includes('WebViewAssetLoader'), 'Android TV does not load its bundled interface from a secure app-assets origin.');
+assert(samsungManifest.includes('
Save a title or start watching to build your personal home.
Try a different search or category.