Add a Settings action to manually rerun Live TV stream analysis: it triggers an admin-guarded projection rebuild endpoint that groups streams into logical channels (covering channels present before the projection existed) and reruns language/Laya detection, with progress presented via liveAnalysisPresentation. Harden the Laya batch path that failed with 'Local AI language matching failed' by surfacing the underlying cause and falling back to standard matching cleanly. Guard admin-only rebuild through a new authz helper and cover routes, service and presentation with tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.7 KiB
JavaScript
192 lines
8.7 KiB
JavaScript
/**
|
|
* API Client - Frontend API wrapper for NodeCast TV
|
|
*/
|
|
|
|
const API = {
|
|
/**
|
|
* Make API request
|
|
*/
|
|
async request(method, endpoint, data = null) {
|
|
const options = {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
// Add authentication token if available
|
|
const token = localStorage.getItem('authToken');
|
|
if (token) {
|
|
options.headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
if (data) {
|
|
options.body = JSON.stringify(data);
|
|
}
|
|
|
|
const response = await fetch(`/api${endpoint}`, options);
|
|
|
|
let result;
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('application/json')) {
|
|
result = await response.json();
|
|
} else {
|
|
const text = await response.text();
|
|
result = { error: text || 'API request failed' };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
// If unauthorized, redirect to login
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('authToken');
|
|
window.location.href = '/login.html';
|
|
return;
|
|
}
|
|
throw new Error(result.error || `Server responded with ${response.status}`);
|
|
}
|
|
|
|
return result;
|
|
},
|
|
|
|
// Sources
|
|
sources: {
|
|
getAll: () => API.request('GET', '/sources'),
|
|
getByType: (type) => API.request('GET', `/sources/type/${type}`),
|
|
getById: (id) => API.request('GET', `/sources/${id}`),
|
|
create: (data) => API.request('POST', '/sources', data),
|
|
update: (id, data) => API.request('PUT', `/sources/${id}`, data),
|
|
delete: (id) => API.request('DELETE', `/sources/${id}`),
|
|
toggle: (id) => API.request('POST', `/sources/${id}/toggle`),
|
|
test: (id) => API.request('POST', `/sources/${id}/test`),
|
|
sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync
|
|
getStatus: () => API.request('GET', '/sources/status'), // Get all statuses
|
|
estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size
|
|
estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
|
|
},
|
|
|
|
// Channels (hidden items)
|
|
channels: {
|
|
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
|
hide: (sourceId, itemType, itemId) => API.request('POST', '/channels/hide', { sourceId, itemType, itemId }),
|
|
show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }),
|
|
isHidden: (sourceId, itemType, itemId) => API.request('GET', `/channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`),
|
|
bulkHide: (items) => API.request('POST', '/channels/hide/bulk', { items }),
|
|
bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }),
|
|
// Fast bulk operations - single SQL statement
|
|
showAll: (sourceId, contentType) => API.request('POST', '/channels/show/all', { sourceId, contentType }),
|
|
hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType })
|
|
},
|
|
|
|
live: {
|
|
getChannels: ({ sourceId = null, includeHidden = false } = {}) => {
|
|
const params = new URLSearchParams();
|
|
if (sourceId) params.set('sourceId', sourceId);
|
|
params.set('includeHidden', String(Boolean(includeHidden)));
|
|
return API.request('GET', `/live/channels?${params.toString()}`);
|
|
},
|
|
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
|
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
|
},
|
|
|
|
// Favorites
|
|
favorites: {
|
|
getAll: (sourceId = null, itemType = null) => {
|
|
let url = '/favorites';
|
|
const params = [];
|
|
if (sourceId) params.push(`sourceId=${sourceId}`);
|
|
if (itemType) params.push(`itemType=${itemType}`);
|
|
if (params.length) url += '?' + params.join('&');
|
|
return API.request('GET', url);
|
|
},
|
|
add: (sourceId, itemId, itemType = 'channel') =>
|
|
API.request('POST', '/favorites', { sourceId, itemId, itemType }),
|
|
remove: (sourceId, itemId, itemType = 'channel') =>
|
|
API.request('DELETE', '/favorites', { sourceId, itemId, itemType }),
|
|
check: (sourceId, itemId, itemType = 'channel') =>
|
|
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
|
|
},
|
|
|
|
laya: {
|
|
getStatus: () => API.request('GET', '/laya/status'),
|
|
downloadModel: (model = 'multilingual') => API.request('POST', '/laya/model/download', { model }),
|
|
removeModel: () => API.request('DELETE', '/laya/model'),
|
|
detectLanguages: (items, model = 'multilingual') => API.request('POST', '/laya/language/batch', { items, model })
|
|
},
|
|
|
|
// Proxy
|
|
proxy: {
|
|
// Xtream
|
|
xtream: {
|
|
auth: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/auth`),
|
|
liveCategories: (sourceId, options = {}) => {
|
|
const params = options.includeHidden ? '?includeHidden=true' : '';
|
|
return API.request('GET', `/proxy/xtream/${sourceId}/live_categories${params}`);
|
|
},
|
|
liveStreams: (sourceId, categoryId = null, options = {}) => {
|
|
const params = [];
|
|
if (categoryId) params.push(`category_id=${categoryId}`);
|
|
if (options.includeHidden) params.push('includeHidden=true');
|
|
const query = params.length ? `?${params.join('&')}` : '';
|
|
return API.request('GET', `/proxy/xtream/${sourceId}/live_streams${query}`);
|
|
},
|
|
vodCategories: (sourceId, options = {}) => {
|
|
const params = options.includeHidden ? '?includeHidden=true' : '';
|
|
return API.request('GET', `/proxy/xtream/${sourceId}/vod_categories${params}`);
|
|
},
|
|
vodStreams: (sourceId, categoryId = null, options = {}) => {
|
|
const params = [];
|
|
if (categoryId) params.push(`category_id=${categoryId}`);
|
|
if (options.includeHidden) params.push('includeHidden=true');
|
|
const query = params.length ? `?${params.join('&')}` : '';
|
|
return API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${query}`);
|
|
},
|
|
seriesCategories: (sourceId, options = {}) => {
|
|
const params = options.includeHidden ? '?includeHidden=true' : '';
|
|
return API.request('GET', `/proxy/xtream/${sourceId}/series_categories${params}`);
|
|
},
|
|
series: (sourceId, categoryId = null, options = {}) => {
|
|
const params = [];
|
|
if (categoryId) params.push(`category_id=${categoryId}`);
|
|
if (options.includeHidden) params.push('includeHidden=true');
|
|
const query = params.length ? `?${params.join('&')}` : '';
|
|
return API.request('GET', `/proxy/xtream/${sourceId}/series${query}`);
|
|
},
|
|
seriesInfo: (sourceId, seriesId) =>
|
|
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
|
|
shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
|
|
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
|
|
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
|
|
},
|
|
|
|
// EPG
|
|
epg: {
|
|
get: (sourceId) => API.request('GET', `/proxy/epg/${sourceId}`),
|
|
getForChannels: (sourceId, channelIds) => API.request('POST', `/proxy/epg/${sourceId}/channels`, { channelIds })
|
|
},
|
|
|
|
// Cache management
|
|
cache: {
|
|
clear: (sourceId) => API.request('DELETE', `/proxy/cache/${sourceId}`)
|
|
}
|
|
},
|
|
|
|
// Settings
|
|
settings: {
|
|
get: () => API.request('GET', '/settings'),
|
|
update: (data) => API.request('PUT', '/settings', data),
|
|
reset: () => API.request('DELETE', '/settings'),
|
|
getDefaults: () => API.request('GET', '/settings/defaults')
|
|
},
|
|
|
|
// Users (admin only)
|
|
users: {
|
|
getAll: () => API.request('GET', '/auth/users'),
|
|
create: (data) => API.request('POST', '/auth/users', data),
|
|
update: (id, data) => API.request('PUT', `/auth/users/${id}`, data),
|
|
delete: (id) => API.request('DELETE', `/auth/users/${id}`)
|
|
}
|
|
};
|
|
|
|
// Make API available globally
|
|
window.API = API;
|