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(''), 'Samsung must allow the exact provider Shows catalog origin.'); assert(stageTv.includes("buildRoot, 'android-assets'") && stageTv.includes("buildRoot, 'samsung'"), 'Self-contained TV assets are not staged.'); assert(stageTv.includes('lgManifest.version = version') && stageTv.includes('stagedSamsungManifest'), 'TV manifest versions are not derived during staging.'); assert(stageTv.includes("replaceAll('src=\"../assets/harbor-mark.svg\"'"), 'LG staging does not inline every Harbor logo.'); diff --git a/tests/run-desktop-smoke.cjs b/tests/run-desktop-smoke.cjs index 6910fcf..8f221a6 100644 --- a/tests/run-desktop-smoke.cjs +++ b/tests/run-desktop-smoke.cjs @@ -12,6 +12,7 @@ const packagedExecutable = process.env.HARBOR_QA_EXECUTABLE const defaultSmokes = [ 'search-smoke.cjs', 'watch-filters-smoke.cjs', + 'provider-shows-smoke.cjs', 'smoke-library.cjs', 'card-artwork-smoke.cjs' ]; @@ -111,6 +112,7 @@ const runSmoke = async (smokeName) => { } const port = await reservePort(); + const mainPort = smokeName === 'provider-shows-smoke.cjs' ? await reservePort() : 0; const profileDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-desktop-smoke-')); const output = []; if (packagedExecutable && !fs.existsSync(packagedExecutable)) { @@ -124,6 +126,7 @@ const runSmoke = async (smokeName) => { `--remote-debugging-port=${port}`, `--user-data-dir=${profileDirectory}` ]; + if (mainPort) harborArgs.push(`--inspect=${mainPort}`); if (!packagedExecutable) { const entry = process.env.HARBOR_QA_ENTRYPOINT ? path.resolve(__dirname, process.env.HARBOR_QA_ENTRYPOINT) : projectRoot; @@ -149,7 +152,7 @@ const runSmoke = async (smokeName) => { await waitForDebugTarget(port, harbor, output); await runCommand(process.execPath, [smokePath, String(port)], { cwd: projectRoot, - env: process.env, + env: { ...process.env, HARBOR_QA_MAIN_PORT: String(mainPort) }, stdio: 'inherit', windowsHide: true }); diff --git a/tv/samsung/config.xml b/tv/samsung/config.xml index be691be..408992c 100644 --- a/tv/samsung/config.xml +++ b/tv/samsung/config.xml @@ -12,6 +12,7 @@ + diff --git a/tv/tv.js b/tv/tv.js index 357a7d3..aca913f 100644 --- a/tv/tv.js +++ b/tv/tv.js @@ -314,6 +314,8 @@ }; } + var loadedProviderShows = {}; + function fetchWatch(query, page) { if (state.subcategory === 'Sports' || state.subcategory === 'Live TV') { var liveFilter = watchBrowseApi.getFilter(state.subcategory, state.watchFilter); @@ -332,12 +334,31 @@ return directory.channels.map(function (entry, index) { return normalizeLiveChannel(entry, index, liveFilter); }); }); } + var seriesFilter = watchBrowseApi.getFilter(state.subcategory, state.watchFilter); + if (state.subcategory === 'TV Shows' && seriesFilter && seriesFilter.source === 'provider-shows' && !query) { + return watchBrowseApi.loadProviderShows(page, requestJson).then(function (result) { + var items = result.items.map(function (item) { + return Object.assign({}, item, { section: 'TV Shows', tags: item.sections, meta: item.year + ' · Series', summary: item.overview, image: item.artworkUrl }); + }); + items.forEach(function (item) { loadedProviderShows[item.id] = item; }); + items.catalogCanLoadMore = result.canLoadMore; + return items; + }); + } if (!TMDB_KEY) { var normalizedQuery = String(query || '').trim().toLowerCase(); - return Promise.resolve(fallbackWatch.concat(liveItems).filter(function (item) { + var localItems = fallbackWatch.concat(liveItems); + if (state.subcategory === 'TV Shows') localItems = Object.keys(loadedProviderShows).map(function (id) { return loadedProviderShows[id]; }).concat(localItems); + var seenLocal = {}; + var matches = localItems.filter(function (item) { var matchesQuery = !normalizedQuery || item.name.toLowerCase().includes(normalizedQuery); - return matchesQuery && matchesWatchSubcategory(item) && matchesWatchFilter(item); - })); + if (!matchesQuery || !matchesWatchSubcategory(item) || !matchesWatchFilter(item)) return false; + var key = item.type + '-' + (item.tmdbId || item.id); + if (seenLocal[key]) return false; + seenLocal[key] = true; return true; + }); + if (state.subcategory === 'TV Shows') matches.catalogCanLoadMore = false; + return Promise.resolve(matches); } var base = 'https://api.themoviedb.org/3/'; var suffix = '?api_key=' + TMDB_KEY + '&include_adult=false&page=' + page; @@ -571,7 +592,8 @@ ? '

Your Harbor is ready

Save a title or start watching to build your personal home.

' : '

No titles found

Try a different search or category.

'; var isLive = state.subcategory === 'Sports' || state.subcategory === 'Live TV'; - resultCount.textContent = isLive + resultCount.textContent = !TMDB_KEY && state.subcategory === 'TV Shows' && state.query + ? state.items.length + ' matches in loaded TV shows' : isLive ? state.liveWindow === 'soon' ? state.items.length + ' upcoming listings' : state.items.length + ' of ' + Math.max(state.liveTotal, state.items.length) + ' channels live now' @@ -592,10 +614,11 @@ if (generation !== itemLoadGeneration) return; var next = items.filter(function (item) { return item && item.name; }); state.items = append ? state.items.concat(next.filter(function (item) { return !state.items.some(function (existing) { return existing.id === item.id; }); })) : next; - state.canLoadMore = next.length >= 20 && !['Play', 'Sports', 'Live TV', 'My List', 'Continue'].includes(state.subcategory); + state.canLoadMore = typeof items.catalogCanLoadMore === 'boolean' ? items.catalogCanLoadMore : next.length >= 20 && !['Play', 'Sports', 'Live TV', 'My List', 'Continue'].includes(state.subcategory); state.loading = false; moreButton.disabled = false; renderHero(); renderCards(); }).catch(function () { if (generation !== itemLoadGeneration) return; + if (append) { state.page = Math.max(1, state.page - 1); state.loading = false; moreButton.disabled = false; renderCards(); notify('Could not load more. Try Show more again.'); return; } if (state.query) state.items = []; else if (state.section === 'Home' && state.subcategory === 'My List') state.items = state.saved; else if (state.section === 'Home' && state.subcategory === 'Continue') state.items = state.history; @@ -1038,6 +1061,7 @@ return watchBrowseApi.matchesLocalFilter({ name: item.name, summary: item.summary, + rating: item.rating, meta: item.meta, section: item.section, sections: [item.section].concat(item.tags || [])