Keep live stream variant expansion scoped to the selected language while preserving the legacy all-language variants response when no language is provided. Display projected current EPG programme data in live rows and distinguish selected streams from actively playing streams in the collapsed variant UI. Refs #43 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
10 KiB
JavaScript
220 lines
10 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
|
|
updateEpgOrder: (orderedIds) => API.request('PUT', '/sources/epg/order', { orderedIds }),
|
|
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)
|
|
},
|
|
|
|
cast: {
|
|
getDevices: () => API.request('GET', '/cast/devices'),
|
|
devices: () => API.request('GET', '/cast/devices'),
|
|
refreshDevices: () => API.request('POST', '/cast/devices/refresh'),
|
|
startSession: (payload) => API.request('POST', '/cast/sessions', payload),
|
|
createSession: (payload) => API.request('POST', '/cast/sessions', payload),
|
|
getSessions: () => API.request('GET', '/cast/sessions'),
|
|
getSession: (id) => API.request('GET', `/cast/sessions/${encodeURIComponent(id)}`),
|
|
stopSession: (id) => API.request('DELETE', `/cast/sessions/${encodeURIComponent(id)}`)
|
|
},
|
|
|
|
// 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, q = '', languages = [], limit = 100, offset = 0 } = {}) => {
|
|
const params = new URLSearchParams();
|
|
if (sourceId) params.set('sourceId', sourceId);
|
|
params.set('includeHidden', String(Boolean(includeHidden)));
|
|
if (q) params.set('q', q);
|
|
if (languages.length) params.set('languages', languages.join(','));
|
|
params.set('limit', String(limit));
|
|
params.set('offset', String(offset));
|
|
params.set('page', String(Math.floor(offset / limit) + 1));
|
|
return API.request('GET', `/live/channels?${params.toString()}`);
|
|
},
|
|
getChannelVariants: (logicalChannelId, { includeHidden = false, language = '', limit = 100, offset = 0 } = {}) => {
|
|
const params = new URLSearchParams({
|
|
includeHidden: String(Boolean(includeHidden)),
|
|
limit: String(limit),
|
|
offset: String(offset),
|
|
page: String(Math.floor(offset / limit) + 1)
|
|
});
|
|
if (language) params.set('language', language);
|
|
return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`);
|
|
},
|
|
checkNewStreams: () => API.request('POST', '/live/projection/check-new'),
|
|
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;
|