Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions app/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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()) {
Expand All @@ -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;
Expand All @@ -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.');
}
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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' });
Expand All @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions shared/watch-browse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -545,6 +587,9 @@
}

return {
normalizeProviderShow: normalizeProviderShow,
providerShowsPage: providerShowsPage,
loadProviderShows: loadProviderShows,
taxonomy: taxonomy,
getFilters: getFilters,
getFilter: getFilter,
Expand Down
Loading
Loading