// src/api.js (updated) import useAuthStore from './store/auth'; import useChannelsStore from './store/channels'; import useLogosStore from './store/logos'; import useUserAgentsStore from './store/userAgents'; import usePlaylistsStore from './store/playlists'; import useEPGsStore from './store/epgs'; import useStreamsStore from './store/streams'; import useStreamProfilesStore from './store/streamProfiles'; import useSettingsStore from './store/settings'; import { notifications } from '@mantine/notifications'; import useChannelsTableStore from './store/channelsTable'; import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; import useConnectStore from './store/connect'; import Limiter from './utils'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV ? `http://${window.location.hostname}:5656` : ''; const errorNotification = (message, error) => { let errorMessage = ''; if (error.status) { try { // Try to format the error body if it's an object if (typeof error.body === 'object') { errorMessage = JSON.stringify(error.body, null, 2); } else { errorMessage = `${error.status} - ${error.body}`; } } catch (e) { errorMessage = `${error.status} - ${String(error.body)}`; } } else { errorMessage = error.message || 'Unknown error'; } notifications.show({ title: 'Error', message: `${message}: ${errorMessage}`, autoClose: 10000, color: 'red', }); throw error; }; const request = async (url, options = {}) => { if ( options.body && !(options.body instanceof FormData) && typeof options.body === 'object' ) { options.body = JSON.stringify(options.body); options.headers = { ...options.headers, 'Content-Type': 'application/json', }; } if (options.auth !== false) { options.headers = { ...options.headers, Authorization: `Bearer ${await API.getAuthToken()}`, }; } const response = await fetch(url, options); if (!response.ok) { const error = new Error(`HTTP error! Status: ${response.status}`); let errorBody = await response.text(); try { errorBody = JSON.parse(errorBody); } catch (e) { // If parsing fails, leave errorBody as the raw text } error.status = response.status; error.response = response; error.body = errorBody; throw error; } try { const retval = await response.json(); return retval; } catch (e) { return ''; } }; export default class API { static lastQueryParams = new URLSearchParams(); /** * A static method so we can do: await API.getAuthToken() */ static async getAuthToken() { return await useAuthStore.getState().getToken(); } /** * Fetch all pages for a paginated endpoint when you already know totalCount. * Builds page calls from totalCount and pageSize and aggregates all results. * - endpoint: path like "/api/channels/channels/" * - params: URLSearchParams for filters (will not be mutated) * - totalCount: total number of matching items * - pageSize: items per page * Returns a flat array of results. Supports both array and {results, next} responses. */ static async fetchAllByCount(endpoint, params, totalCount, pageSize = 200) { const total = Number(totalCount) || 0; const size = Number(pageSize) || 200; const totalPages = Math.max(1, Math.ceil(total / size)); const requests = []; for (let page = 1; page <= totalPages; page++) { const q = new URLSearchParams(params || new URLSearchParams()); q.set('page', String(page)); q.set('page_size', String(size)); const url = `${host}${endpoint}?${q.toString()}`; requests.push(request(url)); } const responses = await Promise.all(requests); const all = []; for (const data of responses) { if (Array.isArray(data)) { all.push(...data); } else if (Array.isArray(data?.results)) { all.push(...data.results); } } return all; } static async fetchSuperUser() { try { return await request(`${host}/api/accounts/initialize-superuser/`, { auth: false, method: 'GET', }); } catch (error) { console.error('Error checking superuser status:', error); throw error; } } static async createSuperUser({ username, email, password }) { try { const response = await request( `${host}/api/accounts/initialize-superuser/`, { auth: false, method: 'POST', body: { username, password, email, }, } ); return response; } catch (e) { errorNotification('Failed to create superuser', e); } } static async login(username, password) { try { const response = await request(`${host}/api/accounts/token/`, { auth: false, method: 'POST', body: { username, password }, }); return response; } catch (e) { errorNotification('Login failed', e); } } static async refreshToken(refresh) { try { return await request(`${host}/api/accounts/token/refresh/`, { auth: false, method: 'POST', body: { refresh }, }); } catch (error) { // If user does not exist or token is invalid, clear tokens if (error.status === 401 || error.message?.includes('does not exist')) { localStorage.removeItem('token'); localStorage.removeItem('refreshToken'); window.location.href = '/login'; // Redirect to login } throw error; } } static async logout() { return await request(`${host}/api/accounts/auth/logout/`, { auth: true, // Send JWT token so backend can identify the user method: 'POST', }); } static async getChannels() { try { // Paginate through channels to avoid heavy single response const pageSize = 200; const allChannels = []; // Get first page to get total results count const data = await request( `${host}/api/channels/channels/?page=1&page_size=${pageSize}` ); // Backward compatibility: if endpoint returns an array (legacy), just return it if (Array.isArray(data)) { return data; } allChannels.concat(Array.isArray(data?.results) ? data.results : []); const totalPages = Math.max(1, Math.ceil(data.count / pageSize)) - 1; const apiCalls = []; for (let page = 2; page <= totalPages; page++) { apiCalls.push( new Promise(async (resolve) => { const response = await request( `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` ); return resolve( Array.isArray(response?.results) ? response.results : [] ); }) ); } const allResults = await Limiter.all(5, apiCalls); return allResults; } catch (e) { errorNotification('Failed to retrieve channels', e); } } /** * Retrieve a lightweight summary of channels (id, name, logo_id, * channel_number, uuid, epg_data_id, channel_group_id). * Designed for the TV Guide where full channel data is not needed. */ static async getChannelsSummary(params = new URLSearchParams()) { try { const url = `${host}/api/channels/channels/summary/?${params.toString()}`; const data = await request(url); return Array.isArray(data) ? data : []; } catch (e) { errorNotification('Failed to retrieve channel summary', e); return []; } } // Repack visible auto-created channels into [start, end]; override // pins are reservations and hidden non-pinned channels release their // number. static async repackGroupChannels(accountId, channelGroupId) { try { const params = new URLSearchParams({ channel_group_id: String(channelGroupId), }); const url = `${host}/api/m3u/accounts/${accountId}/repack-group/?${params.toString()}`; return await request(url, { method: 'POST' }); } catch (e) { errorNotification('Failed to re-pack group channels', e); return null; } } // Returns occupants whose effective channel_number falls in [start, end]. // Pass `signal` from an AbortController on per-keystroke calls so an // out-of-order response cannot overwrite newer state. static async getChannelsInRange(start, end, { signal } = {}) { try { const params = new URLSearchParams({ start: String(start) }); if (end !== undefined && end !== null && end !== '') { params.set('end', String(end)); } const url = `${host}/api/channels/channels/numbers-in-range/?${params.toString()}`; return await request(url, { signal }); } catch (e) { if (e?.name === 'AbortError') { throw e; } // Silent failure is correct here: the warning is purely advisory and // should not block the user from saving when the server is flaky. return { occupants: [] }; } } // Server-side regex preview for a group's streams. Returns find_matches, // filter_matches, and exclude_matches plus accurate counts across the // whole group (capped at 5000 scanned streams server-side). Used by the // auto-sync gear modal so the user sees real matches and totals rather // than a small client-side sample. The three patterns are independent; // any combination can be supplied per call. static async getStreamsRegexPreview( channelGroupName, { find, replace, match, exclude, limit = 10, signal, m3uAccountId } = {} ) { try { const params = new URLSearchParams({ channel_group: channelGroupName, }); if (m3uAccountId !== undefined && m3uAccountId !== null) { params.set('m3u_account_id', String(m3uAccountId)); } if (find) params.set('find', find); if (replace !== undefined && replace !== null) { params.set('replace', replace); } if (match) params.set('match', match); if (exclude) params.set('exclude', exclude); params.set('limit', String(limit)); const url = `${host}/api/channels/streams/regex-preview/?${params.toString()}`; return await request(url, { signal }); } catch (e) { if (e?.name === 'AbortError') { throw e; } return null; } } static async queryChannels(params) { try { API.lastQueryParams = params; const response = await request( `${host}/api/channels/channels/?${params.toString()}` ); useChannelsTableStore.getState().queryChannels(response, params); return response; } catch (e) { // Handle invalid page error by resetting to page 1 and retrying if (e.body?.detail === 'Invalid page.') { const currentPagination = useChannelsTableStore.getState().pagination; // Only retry if we're not already on page 1 if (currentPagination.pageIndex > 0) { // Reset to page 1 useChannelsTableStore.getState().setPagination({ ...currentPagination, pageIndex: 0, }); // Update params to page 1 and retry const newParams = new URLSearchParams(params); newParams.set('page', '1'); const response = await request( `${host}/api/channels/channels/?${newParams.toString()}` ); useChannelsTableStore.getState().queryChannels(response, newParams); return response; } } errorNotification('Failed to fetch channels', e); } } /** * Retrieve channels matching the provided query params, paging until complete. * Does NOT touch any table/store state; returns a plain array. */ static async getChannelsForParams(params) { try { const pageSize = 200; const query = new URLSearchParams(params); let page = 1; let all = []; while (true) { query.set('page', String(page)); query.set('page_size', String(pageSize)); const url = `${host}/api/channels/channels/?${query.toString()}`; const data = await request(url); if (Array.isArray(data)) { // Legacy array response all = data; break; } const results = Array.isArray(data?.results) ? data.results : []; all = all.concat(results); const hasMore = Boolean(data?.next); if (!hasMore || results.length === 0) break; page += 1; } return all; } catch (e) { errorNotification('Failed to retrieve channels for query', e); throw e; } } static async requeryChannels() { try { const [response, ids] = await Promise.all([ request( `${host}/api/channels/channels/?${API.lastQueryParams.toString()}` ), API.getAllChannelIds(API.lastQueryParams), ]); useChannelsTableStore .getState() .queryChannels(response, API.lastQueryParams); useChannelsTableStore.getState().setAllQueryIds(ids); return response; } catch (e) { // Handle invalid page error by resetting to page 1 and retrying if (e.body?.detail === 'Invalid page.') { const currentPagination = useChannelsTableStore.getState().pagination; // Only retry if we're not already on page 1 if (currentPagination.pageIndex > 0) { // Reset to page 1 useChannelsTableStore.getState().setPagination({ ...currentPagination, pageIndex: 0, }); // Update params to page 1 and retry const newParams = new URLSearchParams(API.lastQueryParams); newParams.set('page', '1'); API.lastQueryParams = newParams; const [response, ids] = await Promise.all([ request(`${host}/api/channels/channels/?${newParams.toString()}`), API.getAllChannelIds(newParams), ]); useChannelsTableStore.getState().queryChannels(response, newParams); useChannelsTableStore.getState().setAllQueryIds(ids); return response; } } errorNotification('Failed to fetch channels', e); } } static async getAllChannelIds(params = new URLSearchParams()) { try { const response = await request( `${host}/api/channels/channels/ids/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to fetch channel IDs', e); } } static async getChannelGroups() { try { const response = await request(`${host}/api/channels/groups/`); return response; } catch (e) { errorNotification('Failed to retrieve channel groups', e); } } static async addChannelGroup(values) { try { const response = await request(`${host}/api/channels/groups/`, { method: 'POST', body: values, }); if (response.id) { // Add association flags for new groups const processedGroup = { ...response, hasChannels: false, hasM3UAccounts: false, canEdit: true, canDelete: true, }; useChannelsStore.getState().addChannelGroup(processedGroup); // Refresh channel groups to update the UI useChannelsStore.getState().fetchChannelGroups(); } return response; } catch (e) { errorNotification('Failed to create channel group', e); } } static async updateChannelGroup(values) { try { const { id, ...payload } = values; const response = await request(`${host}/api/channels/groups/${id}/`, { method: 'PUT', body: payload, }); if (response.id) { useChannelsStore.getState().updateChannelGroup(response); } return response; } catch (e) { errorNotification('Failed to update channel group', e); } } static async deleteChannelGroup(id) { try { await request(`${host}/api/channels/groups/${id}/`, { method: 'DELETE', }); // Remove from store after successful deletion useChannelsStore.getState().removeChannelGroup(id); return true; } catch (e) { errorNotification('Failed to delete channel group', e); throw e; } } static async cleanupUnusedChannelGroups() { try { const response = await request(`${host}/api/channels/groups/cleanup/`, { method: 'POST', }); // Refresh channel groups to update the UI useChannelsStore.getState().fetchChannelGroups(); return response; } catch (e) { errorNotification('Failed to cleanup unused channel groups', e); throw e; } } static async addChannel(channel) { try { let body = null; // Prepare a copy to safely mutate const channelData = { ...channel }; // Remove channel_number if empty, null, or not a valid number if ( channelData.channel_number === '' || channelData.channel_number === null || channelData.channel_number === undefined || (typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '') ) { delete channelData.channel_number; } // Add channel profile IDs based on current selection const selectedProfileId = useChannelsStore.getState().selectedProfileId; if (selectedProfileId && selectedProfileId !== '0') { // Specific profile selected - add only to that profile channelData.channel_profile_ids = [parseInt(selectedProfileId)]; } // If selectedProfileId is '0' or not set, don't include channel_profile_ids // which will trigger the backend's default behavior of adding to all profiles if (channel.logo_file) { // Must send FormData for file upload body = new FormData(); for (const prop in channelData) { body.append(prop, channelData[prop]); } } else { body = { ...channelData }; delete body.logo_file; } const response = await request(`${host}/api/channels/channels/`, { method: 'POST', body: body, }); API.getLogos(); if (response.id) { useChannelsStore.getState().addChannel(response); } return response; } catch (e) { errorNotification('Failed to create channel', e); } } static async deleteChannel(id) { try { await request(`${host}/api/channels/channels/${id}/`, { method: 'DELETE', }); useChannelsStore.getState().removeChannels([id]); await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete channel', e); } } // @TODO: the bulk delete endpoint is currently broken static async deleteChannels(channel_ids) { try { await request(`${host}/api/channels/channels/bulk-delete/`, { method: 'DELETE', body: { channel_ids }, }); useChannelsStore.getState().removeChannels(channel_ids); await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete channels', e); } } static async updateChannel(values) { try { // Clean up values before sending to API const payload = { ...values }; // Handle special values if ( payload.stream_profile_id === '0' || payload.stream_profile_id === 0 ) { payload.stream_profile_id = null; } // Handle logo_id properly (0 means "no logo") if (payload.logo_id === '0' || payload.logo_id === 0) { payload.logo_id = null; } // Ensure tvg_id is included properly (not as empty string) if (payload.tvg_id === '') { payload.tvg_id = null; } // Ensure tvc_guide_stationid is included properly (not as empty string) if (payload.tvc_guide_stationid === '') { payload.tvc_guide_stationid = null; } // Handle channel_number properly if (payload.channel_number === '') { payload.channel_number = null; } else if ( payload.channel_number !== null && payload.channel_number !== undefined ) { // Ensure channel_number is explicitly treated as a float payload.channel_number = parseFloat(payload.channel_number); } const response = await request( `${host}/api/channels/channels/${payload.id}/`, { method: 'PATCH', body: payload, } ); useChannelsStore.getState().updateChannel(response); if (Object.prototype.hasOwnProperty.call(payload, 'streams')) { await API.requeryStreams(); } return response; } catch (e) { errorNotification('Failed to update channel', e); } } /** * PATCHes only the stream order for a channel * without triggering requeryStreams or requeryChannels. The caller is * responsible for optimistic UI updates. */ static async reorderChannelStreams(channelId, streamIds) { try { await request(`${host}/api/channels/channels/${channelId}/`, { method: 'PATCH', body: { id: channelId, streams: streamIds }, }); // Update the channelsTable store in-place with the new stream order const store = useChannelsTableStore.getState(); const channel = store.channels.find((c) => c.id === channelId); if (channel) { // Reorder the existing stream objects to match streamIds const streamMap = new Map(channel.streams.map((s) => [s.id, s])); const reorderedStreams = streamIds .map((id) => streamMap.get(id)) .filter(Boolean); store.updateChannel({ ...channel, streams: reorderedStreams }); } } catch (e) { errorNotification('Failed to reorder streams', e); // On failure, requery to restore correct state await API.requeryChannels(); } } /** * PATCHes the channel with the * combined stream list and updates the channelsTable store in-place * using the stream objects the caller already has. Skips requeryStreams * (stream data doesn't change) and requeryChannels (we build the * result locally). * * @param {number} channelId * @param {Array} existingStreams - current channel.streams (full objects) * @param {Array} newStreams - stream objects to append */ static async addStreamsToChannel(channelId, existingStreams, newStreams) { try { const existing = existingStreams || []; // Deduplicate by ID, preserving order (existing first, new appended) const seen = new Set(existing.map((s) => s.id)); const merged = [...existing]; for (const s of newStreams) { if (!seen.has(s.id)) { seen.add(s.id); merged.push(s); } } await request(`${host}/api/channels/channels/${channelId}/`, { method: 'PATCH', body: { id: channelId, streams: merged.map((s) => s.id) }, }); // Update the channelsTable store in-place with the merged streams const store = useChannelsTableStore.getState(); const channel = store.channels.find((c) => c.id === channelId); if (channel) { store.updateChannel({ ...channel, streams: merged }); } } catch (e) { errorNotification('Failed to add streams to channel', e); // On failure, requery to restore correct state await API.requeryChannels(); } } static async updateChannels(ids, values) { const body = []; for (const id of ids) { body.push({ id: id, ...values, }); } try { const response = await request( `${host}/api/channels/channels/edit/bulk/`, { method: 'PATCH', body, } ); // Show success notification if (response.message) { notifications.show({ title: 'Channels Updated', message: response.message, color: 'green', autoClose: 4000, }); } return response; } catch (e) { errorNotification('Failed to update channels', e); } } // Bulk update with per-channel payloads (e.g., regex renames) static async bulkUpdateChannels(updates) { try { const response = await request( `${host}/api/channels/channels/edit/bulk/`, { method: 'PATCH', body: updates, } ); return response; } catch (e) { errorNotification('Failed to update channels', e); } } // Server-side regex rename of channel names for selected IDs static async bulkRegexRenameChannels( channelIds, find, replace = '', flags = 'g' ) { try { const response = await request( `${host}/api/channels/channels/edit/bulk-regex/`, { method: 'POST', body: { channel_ids: channelIds, find, replace, flags, }, } ); // Optional success notification if (response?.success) { notifications.show({ title: 'Channel Names Updated', message: `Renamed ${response.updated_count} channel(s) via regex`, color: 'green', autoClose: 4000, }); } return response; } catch (e) { errorNotification('Failed to apply regex renames', e); } } static async reorderChannel(channelId, insertAfterId) { try { const response = await request( `${host}/api/channels/channels/${channelId}/reorder/`, { method: 'POST', body: { insert_after_id: insertAfterId, }, } ); return response; } catch (e) { errorNotification('Failed to reorder channel', e); } } static async setChannelEPG(channelId, epgDataId) { try { const response = await request( `${host}/api/channels/channels/${channelId}/set-epg/`, { method: 'POST', body: { epg_data_id: epgDataId }, } ); // Update the channel in the store with the refreshed data if (response.channel) { useChannelsStore.getState().updateChannel(response.channel); } // Show notification about task status if (response.task_status) { notifications.show({ title: 'EPG Status', message: response.task_status, color: 'blue', }); } return response; } catch (e) { errorNotification('Failed to update channel EPG', e); } } static async setChannelNamesFromEpg(channelIds) { try { const response = await request( `${host}/api/channels/channels/set-names-from-epg/`, { method: 'POST', body: { channel_ids: channelIds }, } ); notifications.show({ title: 'Task Started', message: response.message, color: 'blue', }); return response; } catch (e) { errorNotification('Failed to start EPG name setting task', e); throw e; } } static async setChannelLogosFromEpg(channelIds) { try { const response = await request( `${host}/api/channels/channels/set-logos-from-epg/`, { method: 'POST', body: { channel_ids: channelIds }, } ); notifications.show({ title: 'Task Started', message: response.message, color: 'blue', }); return response; } catch (e) { errorNotification('Failed to start EPG logo setting task', e); throw e; } } static async setChannelTvgIdsFromEpg(channelIds) { try { const response = await request( `${host}/api/channels/channels/set-tvg-ids-from-epg/`, { method: 'POST', body: { channel_ids: channelIds }, } ); notifications.show({ title: 'Task Started', message: response.message, color: 'blue', }); return response; } catch (e) { errorNotification('Failed to start EPG TVG-ID setting task', e); throw e; } } static async assignChannelNumbers(channelIds, startingNum = 1) { try { const response = await request(`${host}/api/channels/channels/assign/`, { method: 'POST', body: { channel_ids: channelIds, starting_number: startingNum }, }); return response; } catch (e) { errorNotification('Failed to assign channel #s', e); } } static async createChannelFromStream(values) { try { const response = await request( `${host}/api/channels/channels/from-stream/`, { method: 'POST', body: values, } ); if (response.id) { useChannelsStore.getState().addChannel(response); } return response; } catch (e) { errorNotification('Failed to create channel', e); } } static async createChannelsFromStreamsAsync( streamIds, channelProfileIds = null, startingChannelNumber = null ) { try { const requestBody = { stream_ids: streamIds, }; if (channelProfileIds !== null) { requestBody.channel_profile_ids = channelProfileIds; } if (startingChannelNumber !== null) { requestBody.starting_channel_number = startingChannelNumber; } const response = await request( `${host}/api/channels/channels/from-stream/bulk/`, { method: 'POST', body: requestBody, } ); return response; } catch (e) { errorNotification('Failed to start bulk channel creation task', e); throw e; } } static async getStreams(ids = null) { try { const params = new URLSearchParams(); if (ids) { params.append('ids', ids.join(',')); } const response = await request( `${host}/api/channels/streams/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to retrieve streams', e); } } static async getChannelStreams(id) { try { const response = await request( `${host}/api/channels/channels/${id}/streams/` ); return response; } catch (e) { errorNotification('Failed to retrieve channel streams', e); } } /** * Fetches a stats delta for a channel's streams. Errors are swallowed * since this is a background refresh. */ static async getChannelStreamStats(channelId, since, ids) { try { const params = new URLSearchParams(); if (since) params.set('since', since); if (Array.isArray(ids) && ids.length > 0) { params.set('ids', ids.join(',')); } const qs = params.toString(); const response = await request( `${host}/api/channels/channels/${channelId}/streams/stats/${qs ? `?${qs}` : ''}` ); return Array.isArray(response) ? response : []; } catch (e) { return []; } } static async queryStreams(params) { try { const response = await request( `${host}/api/channels/streams/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to fetch streams', e); } } static async queryStreamsTable(params) { try { API.lastStreamQueryParams = params; useStreamsTableStore.getState().setLastQueryParams(params); const response = await request( `${host}/api/channels/streams/?${params.toString()}` ); useStreamsTableStore.getState().queryStreams(response, params); return response; } catch (e) { errorNotification('Failed to fetch streams', e); } } static async requeryStreams() { const params = useStreamsTableStore.getState().lastQueryParams || API.lastStreamQueryParams; if (!params) { return null; } try { const [response, ids] = await Promise.all([ request(`${host}/api/channels/streams/?${params.toString()}`), API.getAllStreamIds(params), ]); useStreamsTableStore.getState().queryStreams(response, params); useStreamsTableStore.getState().setAllQueryIds(ids); return response; } catch (e) { errorNotification('Failed to fetch streams', e); } } static async getAllStreamIds(params) { try { const response = await request( `${host}/api/channels/streams/ids/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to fetch stream IDs', e); } } static async getStreamGroups() { try { const response = await request(`${host}/api/channels/streams/groups/`); return response; } catch (e) { errorNotification('Failed to retrieve stream groups', e); } } static async getStreamFilterOptions(params) { try { const response = await request( `${host}/api/channels/streams/filter-options/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to retrieve filter options', e); // Return safe defaults to prevent crashes during container startup return { groups: [], m3u_accounts: [] }; } } static async addStream(values) { try { const response = await request(`${host}/api/channels/streams/`, { method: 'POST', body: values, }); if (response.id) { useStreamsStore.getState().addStream(response); } await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to add stream', e); } } static async updateStream(values) { try { const { id, ...payload } = values; const response = await request(`${host}/api/channels/streams/${id}/`, { method: 'PUT', body: payload, }); if (response.id) { useStreamsStore.getState().updateStream(response); } await API.requeryStreams(); return response; } catch (e) { errorNotification('Failed to update stream', e); } } static async deleteStream(id) { try { await request(`${host}/api/channels/streams/${id}/`, { method: 'DELETE', }); useStreamsStore.getState().removeStreams([id]); await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete stream', e); } } static async deleteStreams(ids) { try { await request(`${host}/api/channels/streams/bulk-delete/`, { method: 'DELETE', body: { stream_ids: ids }, }); useStreamsStore.getState().removeStreams(ids); await API.requeryStreams(); } catch (e) { errorNotification('Failed to delete streams', e); } } static async getUserAgents() { try { const response = await request(`${host}/api/core/useragents/`); return response; } catch (e) { errorNotification('Failed to retrieve user-agents', e); } } static async addUserAgent(values) { try { const response = await request(`${host}/api/core/useragents/`, { method: 'POST', body: values, }); useUserAgentsStore.getState().addUserAgent(response); return response; } catch (e) { errorNotification('Failed to create user-agent', e); } } static async updateUserAgent(values) { try { const { id, ...payload } = values; const response = await request(`${host}/api/core/useragents/${id}/`, { method: 'PUT', body: payload, }); useUserAgentsStore.getState().updateUserAgent(response); return response; } catch (e) { errorNotification('Failed to update user-agent', e); } } static async deleteUserAgent(id) { try { await request(`${host}/api/core/useragents/${id}/`, { method: 'DELETE', }); useUserAgentsStore.getState().removeUserAgents([id]); } catch (e) { errorNotification('Failed to delete user-agent', e); } } static async getPlaylist(id) { try { const response = await request(`${host}/api/m3u/accounts/${id}/`); return response; } catch (e) { errorNotification(`Failed to retrieve M3U account ${id}`, e); } } static async getPlaylists() { try { const response = await request(`${host}/api/m3u/accounts/`); return response; } catch (e) { errorNotification('Failed to retrieve M3U accounts', e); } } static async updateM3UGroupSettings( playlistId, groupSettings = [], categorySettings = [] ) { try { const response = await request( `${host}/api/m3u/accounts/${playlistId}/group-settings/`, { method: 'PATCH', body: { group_settings: groupSettings, category_settings: categorySettings, }, } ); // Fetch the updated playlist and update the store const updatedPlaylist = await API.getPlaylist(playlistId); usePlaylistsStore.getState().updatePlaylist(updatedPlaylist); return response; } catch (e) { errorNotification('Failed to update M3U group settings', e); } } static async addPlaylist(values) { try { let body = null; if (values.file) { body = new FormData(); for (const prop in values) { if (values[prop] === null || values[prop] === undefined) continue; body.append(prop, values[prop]); } } else { body = { ...values }; delete body.file; } const response = await request(`${host}/api/m3u/accounts/`, { method: 'POST', body, }); usePlaylistsStore.getState().addPlaylist(response); return response; } catch (e) { errorNotification('Failed to create M3U account', e); } } static async refreshPlaylist(id) { try { const response = await request(`${host}/api/m3u/refresh/${id}/`, { method: 'POST', }); return response; } catch (e) { errorNotification('Failed to refresh M3U account', e); } } static async refreshAllPlaylist() { try { const response = await request(`${host}/api/m3u/refresh/`, { method: 'POST', }); return response; } catch (e) { errorNotification('Failed to refresh all M3U accounts', e); } } static async refreshVODContent(accountId) { try { const response = await request( `${host}/api/m3u/accounts/${accountId}/refresh-vod/`, { method: 'POST', } ); return response; } catch (e) { errorNotification('Failed to refresh VOD content', e); } } static async deletePlaylist(id) { // Cascade-deletes auto-created channels owned by the account; the // response includes the deleted count for the confirmation toast. try { const response = await request(`${host}/api/m3u/accounts/${id}/`, { method: 'DELETE', }); usePlaylistsStore.getState().removePlaylists([id]); return response || {}; } catch (e) { errorNotification(`Failed to delete playlist ${id}`, e); throw e; } } // Used by the Delete Playlist confirmation dialog to render an accurate // "Also delete N auto-created channels?" option before the user commits. static async getPlaylistAutoCreatedChannelsCount(id) { try { const response = await request( `${host}/api/m3u/accounts/${id}/auto-created-channels-count/` ); return response; } catch (e) { console.error( `Failed to fetch auto-created channel count for playlist ${id}`, e ); return { count: 0, sample_names: [] }; } } static async updatePlaylist(values, isToggle = false) { const { id, ...payload } = values; try { // If this is just toggling the active state, make a simpler request if ( isToggle && 'is_active' in payload && Object.keys(payload).length === 1 ) { const response = await request(`${host}/api/m3u/accounts/${id}/`, { method: 'PATCH', body: { is_active: payload.is_active }, }); usePlaylistsStore.getState().updatePlaylist(response); return response; } // Original implementation for full updates let body = null; if (payload.file) { delete payload.server_url; body = new FormData(); for (const prop in values) { if (values[prop] === null || values[prop] === undefined) continue; body.append(prop, values[prop]); } } else { delete payload.file; if (!payload.server_url) { delete payload.sever_url; } body = { ...payload }; delete body.file; } const response = await request(`${host}/api/m3u/accounts/${id}/`, { method: 'PATCH', body, }); usePlaylistsStore.getState().updatePlaylist(response); return response; } catch (e) { errorNotification(`Failed to update M3U account ${id}`, e); } } static async getEPGs() { try { const response = await request(`${host}/api/epg/sources/`); return response; } catch (e) { errorNotification('Failed to retrieve EPGs', e); } } static async getEPGData() { try { const response = await request(`${host}/api/epg/epgdata/`); return response; } catch (e) { errorNotification('Failed to retrieve EPG data', e); } } static async getCurrentPrograms(channelUUIDs = null) { try { const response = await request(`${host}/api/epg/current-programs/`, { method: 'POST', body: { channel_uuids: channelUUIDs }, }); return response; } catch (e) { console.error('Failed to retrieve current programs', e); return []; } } // Notice there's a duplicated "refreshPlaylist" method above; // you might want to rename or remove one if it's not needed. static async addEPG(values) { try { let body = null; if (values.files) { body = new FormData(); for (const prop in values) { body.append(prop, values[prop]); } } else { body = { ...values }; delete body.file; } const response = await request(`${host}/api/epg/sources/`, { method: 'POST', body, }); useEPGsStore.getState().addEPG(response); return response; } catch (e) { errorNotification('Failed to create EPG', e); } } static async updateEPG(values, isToggle = false) { // Validate that values is an object if (!values || typeof values !== 'object') { console.error('updateEPG called with invalid values:', values); return; } const { id, ...payload } = values; // Validate that we have an ID and payload is an object if (!id || typeof payload !== 'object') { console.error('updateEPG: invalid id or payload', { id, payload }); return; } try { // If this is just toggling the active state, make a simpler request if ( isToggle && 'is_active' in payload && Object.keys(payload).length === 1 ) { const response = await request(`${host}/api/epg/sources/${id}/`, { method: 'PATCH', body: { is_active: payload.is_active }, }); useEPGsStore.getState().updateEPG(response); return response; } // Original implementation for full updates let body = null; if (payload.files) { body = new FormData(); for (const prop in payload) { if (prop == 'url') { continue; } body.append(prop, payload[prop]); } } else { delete payload.file; if (!payload.url) { delete payload.url; } body = payload; } const response = await request(`${host}/api/epg/sources/${id}/`, { method: 'PATCH', body, }); useEPGsStore.getState().updateEPG(response); return response; } catch (e) { errorNotification(`Failed to update EPG ${id}`, e); } } static async deleteEPG(id) { try { await request(`${host}/api/epg/sources/${id}/`, { method: 'DELETE', }); useEPGsStore.getState().removeEPGs([id]); } catch (e) { errorNotification(`Failed to delete EPG ${id}`, e); } } static async refreshEPG(id) { try { const response = await request(`${host}/api/epg/import/`, { method: 'POST', body: { id }, }); return response; } catch (e) { errorNotification(`Failed to refresh EPG ${id}`, e); } } static async getTimezones() { try { const response = await request(`${host}/api/core/timezones/`); return response; } catch (e) { errorNotification('Failed to retrieve timezones', e); // Return fallback data instead of throwing return { timezones: [ 'UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific', ], grouped: {}, count: 5, }; } } static async getStreamProfiles() { try { const response = await request(`${host}/api/core/streamprofiles/`); return response; } catch (e) { errorNotification('Failed to retrieve sream profiles', e); } } static async addStreamProfile(values) { try { const response = await request(`${host}/api/core/streamprofiles/`, { method: 'POST', body: values, }); useStreamProfilesStore.getState().addStreamProfile(response); return response; } catch (e) { errorNotification('Failed to create stream profile', e); } } static async updateStreamProfile(values) { const { id, ...payload } = values; try { const response = await request(`${host}/api/core/streamprofiles/${id}/`, { method: 'PUT', body: payload, }); useStreamProfilesStore.getState().updateStreamProfile(response); return response; } catch (e) { errorNotification(`Failed to update stream profile ${id}`, e); } } static async deleteStreamProfile(id) { try { await request(`${host}/api/core/streamprofiles/${id}/`, { method: 'DELETE', }); useStreamProfilesStore.getState().removeStreamProfiles([id]); } catch (e) { errorNotification(`Failed to delete stream propfile ${id}`, e); } } static async getGrid() { try { const response = await request(`${host}/api/epg/grid/`); return response.data; } catch (e) { errorNotification('Failed to retrieve program grid', e); } } static async getProgramDetail(programId) { try { const response = await request(`${host}/api/epg/programs/${programId}/`); return response; } catch (e) { console.warn('Failed to retrieve program detail', e); return null; } } static async addM3UProfile(accountId, values) { try { const response = await request( `${host}/api/m3u/accounts/${accountId}/profiles/`, { method: 'POST', body: values, } ); // Refresh the playlist const playlist = await API.getPlaylist(accountId); usePlaylistsStore .getState() .updateProfiles(playlist.id, playlist.profiles); return response; } catch (e) { errorNotification(`Failed to add profile to account ${accountId}`, e); } } static async deleteM3UProfile(accountId, id) { try { await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, { method: 'DELETE', }); const playlist = await API.getPlaylist(accountId); usePlaylistsStore.getState().updatePlaylist(playlist); } catch (e) { errorNotification(`Failed to delete profile for account ${accountId}`, e); } } static async updateM3UProfile(accountId, values) { const { id, ...payload } = values; try { await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, { method: 'PUT', body: payload, }); const playlist = await API.getPlaylist(accountId); usePlaylistsStore.getState().updatePlaylist(playlist); } catch (e) { errorNotification(`Failed to update profile for account ${accountId}`, e); } } static async refreshAccountInfo(profileId) { try { const response = await request( `${host}/api/m3u/refresh-account-info/${profileId}/`, { method: 'POST', } ); return response; } catch (e) { // If it's a structured error response, return it instead of throwing if (e.body && typeof e.body === 'object') { return e.body; } errorNotification( `Failed to refresh account info for profile ${profileId}`, e ); throw e; } } static async addM3UFilter(accountId, values) { try { const response = await request( `${host}/api/m3u/accounts/${accountId}/filters/`, { method: 'POST', body: values, } ); return response; } catch (e) { errorNotification(`Failed to add profile to account ${accountId}`, e); } } static async deleteM3UFilter(accountId, id) { try { await request(`${host}/api/m3u/accounts/${accountId}/filters/${id}/`, { method: 'DELETE', }); } catch (e) { errorNotification(`Failed to delete profile for account ${accountId}`, e); } } static async updateM3UFilter(accountId, filterId, values) { const { id, ...payload } = values; try { await request( `${host}/api/m3u/accounts/${accountId}/filters/${filterId}/`, { method: 'PUT', body: payload, } ); } catch (e) { errorNotification(`Failed to update profile for account ${accountId}`, e); } } static async getSettings() { try { const response = await request(`${host}/api/core/settings/`); return response; } catch (e) { errorNotification('Failed to retrieve settings', e); } } static async getEnvironmentSettings() { try { const response = await request(`${host}/api/core/settings/env/`); return response; } catch (e) { errorNotification('Failed to retrieve environment settings', e); } } // Backup API (async with Celery task polling) static async listBackups() { try { const response = await request(`${host}/api/backups/`); return response || []; } catch (e) { errorNotification('Failed to load backups', e); throw e; } } static async getBackupStatus(taskId, token = null) { try { let url = `${host}/api/backups/status/${taskId}/`; if (token) { url += `?token=${encodeURIComponent(token)}`; } const response = await request(url, { auth: !token }); return response; } catch (e) { throw e; } } static async waitForBackupTask(taskId, onProgress, token = null) { const pollInterval = 2000; // Poll every 2 seconds const maxAttempts = 300; // Max 10 minutes (300 * 2s) for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const status = await API.getBackupStatus(taskId, token); if (onProgress) { onProgress(status); } if (status.state === 'completed') { return status.result; } else if (status.state === 'failed') { throw new Error(status.error || 'Task failed'); } } catch (e) { throw e; } // Wait before next poll await new Promise((resolve) => setTimeout(resolve, pollInterval)); } throw new Error('Task timed out'); } static async createBackup(onProgress) { try { // Start the backup task const response = await request(`${host}/api/backups/create/`, { method: 'POST', }); // Wait for the task to complete using token for auth const result = await API.waitForBackupTask( response.task_id, onProgress, response.task_token ); return result; } catch (e) { errorNotification('Failed to create backup', e); throw e; } } static async uploadBackup(file) { try { const formData = new FormData(); formData.append('file', file); const response = await request(`${host}/api/backups/upload/`, { method: 'POST', body: formData, }); return response; } catch (e) { errorNotification('Failed to upload backup', e); throw e; } } static async deleteBackup(filename) { try { const encodedFilename = encodeURIComponent(filename); await request(`${host}/api/backups/${encodedFilename}/delete/`, { method: 'DELETE', }); } catch (e) { errorNotification('Failed to delete backup', e); throw e; } } static async getDownloadToken(filename) { // Get a download token from the server try { const response = await request( `${host}/api/backups/${encodeURIComponent(filename)}/download-token/` ); return response.token; } catch (e) { throw e; } } static async downloadBackup(filename) { try { // Get a download token first (requires auth) const token = await API.getDownloadToken(filename); const encodedFilename = encodeURIComponent(filename); // Build the download URL with token const downloadUrl = `${host}/api/backups/${encodedFilename}/download/?token=${encodeURIComponent(token)}`; // Use direct browser navigation instead of fetch to avoid CORS issues const link = document.createElement('a'); link.href = downloadUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); return { filename }; } catch (e) { errorNotification('Failed to download backup', e); throw e; } } static async restoreBackup(filename, onProgress) { try { // Start the restore task const encodedFilename = encodeURIComponent(filename); const response = await request( `${host}/api/backups/${encodedFilename}/restore/`, { method: 'POST', } ); // Wait for the task to complete using token for auth // Token-based auth allows status polling even after DB restore invalidates user sessions const result = await API.waitForBackupTask( response.task_id, onProgress, response.task_token ); return result; } catch (e) { errorNotification('Failed to restore backup', e); throw e; } } static async getBackupSchedule() { try { const response = await request(`${host}/api/backups/schedule/`); return response; } catch (e) { errorNotification('Failed to get backup schedule', e); throw e; } } static async updateBackupSchedule(settings) { try { const response = await request(`${host}/api/backups/schedule/update/`, { method: 'PUT', body: settings, }); return response; } catch (e) { errorNotification('Failed to update backup schedule', e); throw e; } } static async getVersion() { try { const response = await request(`${host}/api/core/version/`, { auth: false, }); return response; } catch (e) { errorNotification('Failed to retrieve version', e); } } // Plugins API static async getPlugins() { try { const response = await request(`${host}/api/plugins/plugins/`); return response.plugins || []; } catch (e) { errorNotification('Failed to retrieve plugins', e); } } static async reloadPlugins() { try { const response = await request(`${host}/api/plugins/plugins/reload/`, { method: 'POST', }); return response; } catch (e) { errorNotification('Failed to reload plugins', e); } } static async importPlugin(file, overwrite = false, silent = false) { try { const form = new FormData(); form.append('file', file); if (overwrite) form.append('overwrite', 'true'); const response = await request(`${host}/api/plugins/plugins/import/`, { method: 'POST', body: form, }); return response; } catch (e) { if (!silent) { const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin'; notifications.show({ title: 'Import failed', message: msg, color: 'red', }); } throw e; } } static async deletePlugin(key) { try { const response = await request( `${host}/api/plugins/plugins/${key}/delete/`, { method: 'DELETE', } ); return response; } catch (e) { errorNotification('Failed to delete plugin', e); } } static async updatePluginSettings(key, settings) { try { const response = await request( `${host}/api/plugins/plugins/${key}/settings/`, { method: 'POST', body: { settings }, } ); return response?.settings || {}; } catch (e) { errorNotification('Failed to update plugin settings', e); throw e; } } static async runPluginAction(key, action, params = {}) { try { const response = await request( `${host}/api/plugins/plugins/${key}/run/`, { method: 'POST', body: { action, params }, } ); return response; } catch (e) { errorNotification('Failed to run plugin action', e); } } static async setPluginEnabled(key, enabled) { try { const response = await request( `${host}/api/plugins/plugins/${key}/enabled/`, { method: 'POST', body: { enabled }, } ); return response; } catch (e) { errorNotification('Failed to update plugin enabled state', e); } } // Plugin Repos API static async getPluginRepos() { try { return await request(`${host}/api/plugins/repos/`); } catch (e) { errorNotification('Failed to retrieve plugin repos', e); return []; } } static async addPluginRepo(data) { try { return await request(`${host}/api/plugins/repos/`, { method: 'POST', body: data, }); } catch (e) { errorNotification('Failed to add plugin repo', e); throw e; } } static async deletePluginRepo(id) { try { return await request(`${host}/api/plugins/repos/${id}/`, { method: 'DELETE', }); } catch (e) { errorNotification('Failed to delete plugin repo', e); throw e; } } static async updatePluginRepo(id, data) { try { return await request(`${host}/api/plugins/repos/${id}/`, { method: 'PUT', body: data, }); } catch (e) { errorNotification('Failed to update plugin repo', e); } } static async refreshPluginRepo(id) { try { return await request(`${host}/api/plugins/repos/${id}/refresh/`, { method: 'POST', }); } catch (e) { errorNotification('Failed to refresh plugin repo', e); } } static async getAvailablePlugins() { try { const response = await request(`${host}/api/plugins/repos/available/`); return response.plugins || []; } catch (e) { errorNotification('Failed to retrieve available plugins', e); return []; } } static async getPluginDetailManifest(repoId, manifestUrl) { try { const response = await request( `${host}/api/plugins/repos/plugin-detail/`, { method: 'POST', body: { repo_id: repoId, manifest_url: manifestUrl }, } ); return response; } catch (e) { errorNotification('Failed to retrieve plugin details', e); return null; } } static async getPluginRepoSettings() { try { return await request(`${host}/api/plugins/repos/settings/`); } catch (e) { errorNotification('Failed to retrieve repo settings', e); return null; } } static async updatePluginRepoSettings(data) { try { return await request(`${host}/api/plugins/repos/settings/`, { method: 'PUT', body: data, }); } catch (e) { errorNotification('Failed to update repo settings', e); return null; } } static async installPluginFromRepo(data) { try { return await request(`${host}/api/plugins/repos/install/`, { method: 'POST', body: data, }); } catch (e) { errorNotification('Failed to install plugin', e); return null; } } static async previewPluginRepo(url, publicKey) { try { return await request(`${host}/api/plugins/repos/preview/`, { method: 'POST', body: { url, public_key: publicKey || '' }, }); } catch { return null; } } static async checkSetting(values) { const { id, ...payload } = values; try { const response = await request(`${host}/api/core/settings/check/`, { method: 'POST', body: payload, }); return response; } catch (e) { errorNotification('Failed to update settings', e); } } static async updateSetting(values) { const { id, ...payload } = values; try { const response = await request(`${host}/api/core/settings/${id}/`, { method: 'PUT', body: payload, }); useSettingsStore.getState().updateSetting(response); return response; } catch (e) { errorNotification('Failed to update settings', e); } } static async createSetting(values) { try { const response = await request(`${host}/api/core/settings/`, { method: 'POST', body: values, }); useSettingsStore.getState().updateSetting(response); return response; } catch (e) { errorNotification('Failed to create setting', e); } } static async getChannelStats(uuid = null) { try { const response = await request(`${host}/proxy/ts/status`); return response; } catch (e) { errorNotification('Failed to retrieve channel stats', e); } } static async getVODStats() { try { const response = await request(`${host}/proxy/vod/stats/`); return response; } catch (e) { errorNotification('Failed to retrieve VOD stats', e); } } static async stopVODClient(clientId) { try { const response = await request(`${host}/proxy/vod/stop_client/`, { method: 'POST', body: { client_id: clientId }, }); return response; } catch (e) { errorNotification('Failed to stop VOD client', e); } } static async stopChannel(id) { try { const response = await request(`${host}/proxy/ts/stop/${id}`, { method: 'POST', }); return response; } catch (e) { errorNotification('Failed to stop channel', e); } } static async stopClient(channelId, clientId) { try { const response = await request( `${host}/proxy/ts/stop_client/${channelId}`, { method: 'POST', body: { client_id: clientId }, } ); return response; } catch (e) { errorNotification('Failed to stop client', e); } } static async matchEpg(channelIds = null) { try { const requestBody = channelIds ? { channel_ids: channelIds } : {}; const response = await request( `${host}/api/channels/channels/match-epg/`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), } ); return response; } catch (e) { errorNotification('Failed to run EPG auto-match', e); } } static async matchChannelEpg(channelId) { try { const response = await request( `${host}/api/channels/channels/${channelId}/match-epg/`, { method: 'POST', } ); // Update the channel in the store with the refreshed data if provided if (response.channel) { useChannelsStore.getState().updateChannel(response.channel); } return response; } catch (e) { errorNotification('Failed to run EPG auto-match for channel', e); } } static async fetchActiveChannelStats() { try { const response = await request(`${host}/proxy/ts/status`); return response; } catch (e) { errorNotification('Failed to fetch active channel stats', e); throw e; } } static async getLogos(params = {}) { try { const queryParams = new URLSearchParams(params); const response = await request( `${host}/api/channels/logos/?${queryParams.toString()}` ); return response; } catch (e) { errorNotification('Failed to retrieve logos', e); } } static async getLogosByIds(logoIds) { try { if (!logoIds || logoIds.length === 0) return []; const params = new URLSearchParams(); logoIds.forEach((id) => params.append('ids', id)); // Disable pagination for ID-based queries to get all matching logos params.append('no_pagination', 'true'); const response = await request( `${host}/api/channels/logos/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to retrieve logos by IDs', e); return []; } } static async fetchLogos() { try { const response = await this.getLogos(); useLogosStore.getState().setLogos(response); return response; } catch (e) { errorNotification('Failed to fetch logos', e); } } static async fetchUsedLogos() { try { const response = await useLogosStore.getState().fetchUsedLogos(); return response; } catch (e) { errorNotification('Failed to fetch used logos', e); } } static async fetchLogosByIds(logoIds) { try { const response = await useLogosStore.getState().fetchLogosByIds(logoIds); return response; } catch (e) { errorNotification('Failed to fetch logos by IDs', e); } } static async uploadLogo(file, name = null) { try { const formData = new FormData(); formData.append('file', file); // Add custom name if provided if (name && name.trim()) { formData.append('name', name.trim()); } // Add timeout handling for file uploads const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout const response = await fetch(`${host}/api/channels/logos/upload/`, { method: 'POST', body: formData, headers: { Authorization: `Bearer ${await API.getAuthToken()}`, }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const error = new Error(`HTTP error! Status: ${response.status}`); let errorBody = await response.text(); try { errorBody = JSON.parse(errorBody); } catch (e) { // If parsing fails, leave errorBody as the raw text } error.status = response.status; error.response = response; error.body = errorBody; throw error; } const result = await response.json(); useLogosStore.getState().addLogo(result); return result; } catch (e) { if (e.name === 'AbortError') { const timeoutError = new Error('Upload timed out. Please try again.'); timeoutError.code = 'NETWORK_ERROR'; throw timeoutError; } errorNotification('Failed to upload logo', e); throw e; } } static async createLogo(values) { try { // Use FormData for logo creation to match backend expectations const formData = new FormData(); for (const [key, value] of Object.entries(values)) { if (value !== null && value !== undefined) { formData.append(key, value); } } const response = await request(`${host}/api/channels/logos/`, { method: 'POST', body: formData, }); useLogosStore.getState().addLogo(response); return response; } catch (e) { errorNotification('Failed to create logo', e); } } static async updateLogo(id, values) { try { const response = await request(`${host}/api/channels/logos/${id}/`, { method: 'PUT', body: values, // This will be converted to JSON in the request function }); useLogosStore.getState().updateLogo(response); return response; } catch (e) { errorNotification('Failed to update logo', e); } } static async deleteLogo(id, deleteFile = false) { try { const params = new URLSearchParams(); if (deleteFile) { params.append('delete_file', 'true'); } const url = `${host}/api/channels/logos/${id}/?${params.toString()}`; await request(url, { method: 'DELETE', }); useLogosStore.getState().removeLogo(id); return true; } catch (e) { errorNotification('Failed to delete logo', e); } } static async deleteLogos(ids, deleteFiles = false) { try { const body = { logo_ids: ids }; if (deleteFiles) { body.delete_files = true; } await request(`${host}/api/channels/logos/bulk-delete/`, { method: 'DELETE', body: body, }); // Remove multiple logos from store ids.forEach((id) => { useLogosStore.getState().removeLogo(id); }); return true; } catch (e) { errorNotification('Failed to delete logos', e); } } static async cleanupUnusedLogos(deleteFiles = false) { try { const body = {}; if (deleteFiles) { body.delete_files = true; } const response = await request(`${host}/api/channels/logos/cleanup/`, { method: 'POST', body: body, }); return response; } catch (e) { errorNotification('Failed to cleanup unused logos', e); throw e; } } // VOD Logo Methods static async getVODLogos(params = {}) { try { // Transform usage filter to match backend expectations const apiParams = { ...params }; if (apiParams.usage === 'used') { apiParams.used = 'true'; delete apiParams.usage; } else if (apiParams.usage === 'unused') { apiParams.used = 'false'; delete apiParams.usage; } else if (apiParams.usage === 'movies') { apiParams.used = 'movies'; delete apiParams.usage; } else if (apiParams.usage === 'series') { apiParams.used = 'series'; delete apiParams.usage; } const queryParams = new URLSearchParams(apiParams); const response = await request( `${host}/api/vod/vodlogos/?${queryParams.toString()}` ); return response; } catch (e) { errorNotification('Failed to retrieve VOD logos', e); throw e; } } static async deleteVODLogo(id) { try { await request(`${host}/api/vod/vodlogos/${id}/`, { method: 'DELETE', }); return true; } catch (e) { errorNotification('Failed to delete VOD logo', e); throw e; } } static async deleteVODLogos(ids) { try { await request(`${host}/api/vod/vodlogos/bulk-delete/`, { method: 'DELETE', body: { logo_ids: ids }, }); return true; } catch (e) { errorNotification('Failed to delete VOD logos', e); throw e; } } static async cleanupUnusedVODLogos() { try { const response = await request(`${host}/api/vod/vodlogos/cleanup/`, { method: 'POST', }); return response; } catch (e) { errorNotification('Failed to cleanup unused VOD logos', e); throw e; } } static async getChannelProfiles() { try { const response = await request(`${host}/api/channels/profiles/`); return response; } catch (e) { errorNotification('Failed to get channel profiles', e); } } static async addChannelProfile(values) { try { const response = await request(`${host}/api/channels/profiles/`, { method: 'POST', body: values, }); useChannelsStore.getState().addProfile(response); return response; } catch (e) { errorNotification('Failed to create channel profile', e); } } static async updateChannelProfile(values) { const { id, ...payload } = values; try { const response = await request(`${host}/api/channels/profiles/${id}/`, { method: 'PUT', body: payload, }); useChannelsStore.getState().updateProfile(response); return response; } catch (e) { errorNotification('Failed to update channel profile', e); } } static async duplicateChannelProfile(id, name) { try { const response = await request( `${host}/api/channels/profiles/${id}/duplicate/`, { method: 'POST', body: { name }, } ); useChannelsStore.getState().addProfile(response); return response; } catch (e) { errorNotification(`Failed to duplicate channel profile ${id}`, e); } } static async deleteChannelProfile(id) { try { await request(`${host}/api/channels/profiles/${id}/`, { method: 'DELETE', }); useChannelsStore.getState().removeProfiles([id]); } catch (e) { errorNotification(`Failed to delete channel profile ${id}`, e); } } static async updateProfileChannel(channelId, profileId, enabled) { try { await request( `${host}/api/channels/profiles/${profileId}/channels/${channelId}/`, { method: 'PATCH', body: { enabled }, } ); useChannelsStore .getState() .updateProfileChannels([channelId], profileId, enabled); } catch (e) { errorNotification(`Failed to update channel for profile ${profileId}`, e); } } static async updateProfileChannels(channelIds, profileId, enabled) { try { await request( `${host}/api/channels/profiles/${profileId}/channels/bulk-update/`, { method: 'PATCH', body: { channels: channelIds.map((id) => ({ channel_id: id, enabled, })), }, } ); useChannelsStore .getState() .updateProfileChannels(channelIds, profileId, enabled); } catch (e) { errorNotification( `Failed to bulk update channels for profile ${profileId}`, e ); } } static async getRecordings() { try { const response = await request(`${host}/api/channels/recordings/`); return response; } catch (e) { errorNotification('Failed to retrieve recordings', e); } } static async createRecording(values) { try { const response = await request(`${host}/api/channels/recordings/`, { method: 'POST', body: values, }); useChannelsStore.getState().fetchRecordings(); return response; } catch (e) { errorNotification('Failed to create recording', e); } } static async updateRecording(id, values) { try { const response = await request(`${host}/api/channels/recordings/${id}/`, { method: 'PATCH', body: values, }); useChannelsStore.getState().fetchRecordings(); return response; } catch (e) { errorNotification(`Failed to update recording ${id}`, e); } } static async getComskipConfig() { try { return await request(`${host}/api/channels/dvr/comskip-config/`); } catch (e) { errorNotification('Failed to retrieve comskip configuration', e); } } static async uploadComskipIni(file) { try { const formData = new FormData(); formData.append('file', file); return await request(`${host}/api/channels/dvr/comskip-config/`, { method: 'POST', body: formData, }); } catch (e) { errorNotification('Failed to upload comskip.ini', e); } } static async listRecurringRules() { try { const response = await request(`${host}/api/channels/recurring-rules/`); return response; } catch (e) { errorNotification('Failed to retrieve recurring DVR rules', e); } } static async createRecurringRule(payload) { try { const response = await request(`${host}/api/channels/recurring-rules/`, { method: 'POST', body: payload, }); return response; } catch (e) { errorNotification('Failed to create recurring DVR rule', e); } } static async updateRecurringRule(ruleId, payload) { try { const response = await request( `${host}/api/channels/recurring-rules/${ruleId}/`, { method: 'PATCH', body: payload, } ); return response; } catch (e) { errorNotification(`Failed to update recurring rule ${ruleId}`, e); } } static async deleteRecurringRule(ruleId) { try { await request(`${host}/api/channels/recurring-rules/${ruleId}/`, { method: 'DELETE', }); } catch (e) { errorNotification(`Failed to delete recurring rule ${ruleId}`, e); } } static async deleteRecording(id) { try { await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE', }); // Optimistically remove locally for instant UI update try { useChannelsStore.getState().removeRecording(id); } catch {} } catch (e) { errorNotification(`Failed to delete recording ${id}`, e); } } static async stopRecording(id) { try { await request(`${host}/api/channels/recordings/${id}/stop/`, { method: 'POST', }); } catch (e) { errorNotification(`Failed to stop recording ${id}`, e); throw e; } } static async extendRecording(id, extraMinutes) { try { const resp = await request( `${host}/api/channels/recordings/${id}/extend/`, { method: 'POST', body: JSON.stringify({ extra_minutes: extraMinutes }), headers: { 'Content-Type': 'application/json' }, } ); return resp; } catch (e) { errorNotification(`Failed to extend recording ${id}`, e); throw e; } } static async refreshArtwork(id) { try { await request(`${host}/api/channels/recordings/${id}/refresh-artwork/`, { method: 'POST', }); } catch (e) { errorNotification(`Failed to refresh artwork for recording ${id}`, e); throw e; } } static async updateRecordingMetadata(id, { title, description }) { try { await request(`${host}/api/channels/recordings/${id}/update-metadata/`, { method: 'POST', body: JSON.stringify({ title, description }), headers: { 'Content-Type': 'application/json' }, }); } catch (e) { errorNotification(`Failed to update recording metadata`, e); throw e; } } static async runComskip(recordingId) { try { const resp = await request( `${host}/api/channels/recordings/${recordingId}/comskip/`, { method: 'POST', } ); // Refresh recordings list to reflect comskip status when done later // This endpoint just queues the task; the websocket/refresh will update eventually return resp; } catch (e) { errorNotification('Failed to run comskip', e); throw e; } } // DVR Series Rules static async listSeriesRules() { try { const resp = await request(`${host}/api/channels/series-rules/`); return resp?.rules || []; } catch (e) { errorNotification('Failed to load series rules', e); return []; } } static async createSeriesRule(values) { try { const resp = await request(`${host}/api/channels/series-rules/`, { method: 'POST', body: values, }); notifications.show({ title: 'Series rule saved' }); return resp; } catch (e) { errorNotification('Failed to save series rule', e); throw e; } } static async deleteSeriesRule(tvgId, title) { try { const params = new URLSearchParams(); if (tvgId) params.set('tvg_id', tvgId); if (title) params.set('title', title); await request(`${host}/api/channels/series-rules/?${params}`, { method: 'DELETE', }); notifications.show({ title: 'Series rule removed' }); } catch (e) { errorNotification('Failed to remove series rule', e); throw e; } } static async deleteAllUpcomingRecordings() { try { const resp = await request( `${host}/api/channels/recordings/bulk-delete-upcoming/`, { method: 'POST', } ); notifications.show({ title: `Removed ${resp.removed || 0} upcoming` }); useChannelsStore.getState().fetchRecordings(); return resp; } catch (e) { errorNotification('Failed to delete upcoming recordings', e); throw e; } } static async evaluateSeriesRules(tvgId = null) { try { await request(`${host}/api/channels/series-rules/evaluate/`, { method: 'POST', body: tvgId ? { tvg_id: tvgId } : {}, }); } catch (e) { errorNotification('Failed to evaluate series rules', e); } } static async previewSeriesRule(values, { signal } = {}) { // Throws on error so callers can ignore aborted requests vs real failures. return request(`${host}/api/channels/series-rules/preview/`, { method: 'POST', body: values, signal, }); } static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title', }) { try { const resp = await request( `${host}/api/channels/series-rules/bulk-remove/`, { method: 'POST', body: { tvg_id, title, scope }, } ); notifications.show({ title: `Removed ${resp.removed || 0} scheduled` }); return resp; } catch (e) { errorNotification('Failed to bulk-remove scheduled recordings', e); throw e; } } static async switchStream(channelId, streamId) { try { const response = await request( `${host}/proxy/ts/change_stream/${channelId}`, { method: 'POST', body: { stream_id: streamId }, } ); return response; } catch (e) { errorNotification('Failed to switch stream', e); throw e; } } static async nextStream(channelId, streamId) { try { const response = await request( `${host}/proxy/ts/next_stream/${channelId}`, { method: 'POST', body: { stream_id: streamId }, } ); return response; } catch (e) { errorNotification('Failed to switch stream', e); throw e; } } static async batchSetEPG(associations) { try { const response = await request( `${host}/api/channels/channels/batch-set-epg/`, { method: 'POST', body: { associations }, } ); // If successful, requery channels to update UI if (response.success) { // Build message based on whether EPG sources need refreshing let message = `Updated ${response.channels_updated} channel${response.channels_updated !== 1 ? 's' : ''}`; if (response.programs_refreshed > 0) { message += `, refreshing ${response.programs_refreshed} EPG source${response.programs_refreshed !== 1 ? 's' : ''}`; } notifications.show({ title: 'EPG Association', message: message, color: 'blue', }); // Then refresh the current table view this.requeryChannels(); } return response; } catch (e) { errorNotification('Failed to update channel EPGs', e); } } static async getChannel(id) { try { const response = await request( `${host}/api/channels/channels/${id}/?include_streams=true` ); return response; } catch (e) { errorNotification('Failed to fetch channel details', e); return null; } } static async me() { return await request(`${host}/api/accounts/users/me/`); } static async updateMe(data) { return await request(`${host}/api/accounts/users/me/`, { method: 'PATCH', body: data, }); } static async getUsers() { try { const response = await request(`${host}/api/accounts/users/`); return response; } catch (e) { errorNotification('Failed to fetch users', e); } } static async createUser(body) { try { const response = await request(`${host}/api/accounts/users/`, { method: 'POST', body, }); useUsersStore.getState().addUser(response); return response; } catch (e) { errorNotification('Failed to fetch users', e); } } static async generateApiKey({ user_id = null, name = '' } = {}) { try { const body = {}; if (user_id) body.user_id = user_id; if (name) body.name = name; const response = await request( `${host}/api/accounts/api-keys/generate/`, { method: 'POST', body, } ); // If the backend returned an updated user, refresh the users store try { if (response && response.user) { useUsersStore.getState().updateUser(response.user); } } catch (e) { // ignore store update errors } return response; } catch (e) { errorNotification('Failed to generate API key', e); } } static async revokeApiKey({ user_id = null } = {}) { try { const body = {}; if (user_id) { body.user_id = user_id; } const response = await request(`${host}/api/accounts/api-keys/revoke/`, { method: 'POST', body, }); // If the backend returned an updated user, refresh the users store try { if (response && response.user) { useUsersStore.getState().updateUser(response.user); } } catch (e) { // ignore store update errors } return response; } catch (e) { errorNotification('Failed to revoke API key', e); } } static async updateUser(id, body, self = false) { try { const response = await request( `${host}/api/accounts/users/${self ? 'me' : id}/`, { method: 'PATCH', body, } ); useUsersStore.getState().updateUser(response); return response; } catch (e) { errorNotification('Failed to fetch users', e); } } static async deleteUser(id) { try { await request(`${host}/api/accounts/users/${id}/`, { method: 'DELETE', }); useUsersStore.getState().removeUser(id); } catch (e) { errorNotification('Failed to delete user', e); } } static async rehashStreams() { try { const response = await request(`${host}/api/core/rehash-streams/`, { method: 'POST', }); return response; } catch (e) { errorNotification('Failed to trigger stream rehash', e); } } static async getStreamsByIds(ids) { try { // Use POST for large ID lists to avoid URL length limitations if (ids.length > 50) { const response = await request(`${host}/api/channels/streams/by-ids/`, { method: 'POST', body: { ids }, }); return response; } else { // Use GET for small ID lists for backward compatibility const params = new URLSearchParams(); params.append('ids', ids.join(',')); const response = await request( `${host}/api/channels/streams/?${params.toString()}` ); return response.results || response; } } catch (e) { errorNotification('Failed to retrieve streams by IDs', e); throw e; // Re-throw to allow proper error handling in calling code } } static async getChannelsByUUIDs(uuids) { try { // Use POST for large lists const response = await request( `${host}/api/channels/channels/by-uuids/`, { method: 'POST', body: { uuids }, } ); return response; } catch (e) { errorNotification('Failed to retrieve channels by UUIDs', e); throw e; } } // VOD Methods static async getMovies(params = new URLSearchParams()) { try { const response = await request( `${host}/api/vod/movies/?${params.toString()}` ); return response; } catch (e) { // Don't show error notification for "Invalid page" errors as they're handled gracefully const isInvalidPage = e.body?.detail?.includes('Invalid page') || e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve movies', e); } throw e; } } static async getSeries(params = new URLSearchParams()) { try { const response = await request( `${host}/api/vod/series/?${params.toString()}` ); return response; } catch (e) { // Don't show error notification for "Invalid page" errors as they're handled gracefully const isInvalidPage = e.body?.detail?.includes('Invalid page') || e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve series', e); } throw e; } } static async getAllContent(params = new URLSearchParams()) { try { console.log( 'Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}` ); const response = await request( `${host}/api/vod/all/?${params.toString()}` ); console.log('getAllContent raw response:', response); return response; } catch (e) { console.error('getAllContent error:', e); console.error('Error status:', e.status); console.error('Error body:', e.body); console.error('Error message:', e.message); // Don't show error notification for "Invalid page" errors as they're handled gracefully const isInvalidPage = e.body?.detail?.includes('Invalid page') || e.message?.includes('Invalid page'); if (!isInvalidPage) { errorNotification('Failed to retrieve content', e); } throw e; } } static async getMovieDetails(movieId) { try { const response = await request(`${host}/api/vod/movies/${movieId}/`); return response; } catch (e) { errorNotification('Failed to retrieve movie details', e); } } static async getMovieProviderInfo(movieId) { try { const response = await request( `${host}/api/vod/movies/${movieId}/provider-info/` ); return response; } catch (e) { errorNotification('Failed to retrieve movie provider info', e); } } static async getMovieProviders(movieId) { try { const response = await request( `${host}/api/vod/movies/${movieId}/providers/` ); return response; } catch (e) { errorNotification('Failed to retrieve movie providers', e); } } static async getSeriesProviders(seriesId) { try { const response = await request( `${host}/api/vod/series/${seriesId}/providers/` ); return response; } catch (e) { errorNotification('Failed to retrieve series providers', e); } } static async getVODCategories() { try { const response = await request(`${host}/api/vod/categories/`); return response; } catch (e) { errorNotification('Failed to retrieve VOD categories', e); } } static async getSeriesInfo(seriesId) { try { // Call the provider-info endpoint that includes episodes const response = await request( `${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true` ); return response; } catch (e) { errorNotification('Failed to retrieve series info', e); } } static async getSystemEvents(limit = 100, offset = 0, eventType = null) { try { const params = new URLSearchParams(); params.append('limit', limit); params.append('offset', offset); if (eventType) { params.append('event_type', eventType); } const response = await request( `${host}/api/core/system-events/?${params.toString()}` ); return response; } catch (e) { errorNotification('Failed to retrieve system events', e); } } // ───────────────────────────── // System Notifications // ───────────────────────────── /** * Get all active notifications for the current user * @param {boolean} includeDismissed - Whether to include already dismissed notifications */ static async getNotifications(includeDismissed = false) { try { const params = new URLSearchParams(); if (includeDismissed) { params.append('include_dismissed', 'true'); } const response = await request( `${host}/api/core/notifications/?${params.toString()}` ); // Update the store with fetched notifications const { default: useNotificationsStore } = await import('./store/notifications'); useNotificationsStore.getState().setNotifications(response.notifications); return response; } catch (e) { errorNotification('Failed to retrieve notifications', e); } } // Get unread notification count static async getNotificationCount() { try { const response = await request(`${host}/api/core/notifications/count/`); // Update the store with the count const { default: useNotificationsStore } = await import('./store/notifications'); useNotificationsStore.getState().setUnreadCount(response.unread_count); return response; } catch (e) { // Silent fail for count - not critical console.error('Failed to get notification count:', e); return { unread_count: 0 }; } } /** * Dismiss a specific notification * @param {number} notificationId - The notification ID to dismiss * @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored') */ static async dismissNotification(notificationId, actionTaken = null) { try { const body = {}; if (actionTaken) { body.action_taken = actionTaken; } const response = await request( `${host}/api/core/notifications/${notificationId}/dismiss/`, { method: 'POST', body, } ); // Update the store const { default: useNotificationsStore } = await import('./store/notifications'); useNotificationsStore .getState() .dismissNotification(response.notification_key); return response; } catch (e) { errorNotification('Failed to dismiss notification', e); } } // Dismiss all notifications static async dismissAllNotifications() { try { const response = await request( `${host}/api/core/notifications/dismiss-all/`, { method: 'POST', } ); // Update the store const { default: useNotificationsStore } = await import('./store/notifications'); useNotificationsStore.getState().dismissAllNotifications(); return response; } catch (e) { errorNotification('Failed to dismiss all notifications', e); } } static async getConnectIntegrations() { try { return await request(`${host}/api/connect/integrations/`); } catch (e) { errorNotification('Failed to fetch connect integrations', e); } } static async createConnectIntegration(values) { try { const response = await request(`${host}/api/connect/integrations/`, { method: 'POST', body: values, }); useConnectStore.getState().addIntegration(response); return response; } catch (e) { errorNotification('Failed to create integration', e); } } static async updateConnectIntegration(id, values) { try { const response = await request( `${host}/api/connect/integrations/${id}/`, { method: 'PUT', body: values, } ); if (response.id) { useConnectStore.getState().updateIntegration(response); } return response; } catch (e) { errorNotification('Failed to update integration', e); } } static async deleteConnectIntegration(id) { try { await request(`${host}/api/connect/integrations/${id}/`, { method: 'DELETE', }); useConnectStore.getState().removeIntegration(id); return true; } catch (e) { errorNotification('Failed to delete integration', e); throw e; } } static async createConnectSubscription(values) { try { await request(`${host}/api/connect/subscriptions/`, { method: 'POST', body: values, }); return true; } catch (e) { errorNotification('Failed to create subscription', e); } } static async listConnectSubscriptions(integrationId) { try { return await request( `${host}/api/connect/integrations/${integrationId}/subscriptions/` ); } catch (e) { errorNotification('Failed to fetch subscriptions', e); } } static async setConnectSubscriptions(integrationId, subscriptions) { // subscriptions: [{ event, enabled, payload_template }] console.log(subscriptions); try { const response = await request( `${host}/api/connect/integrations/${integrationId}/subscriptions/set/`, { method: 'PUT', body: subscriptions, } ); useConnectStore .getState() .updateIntegrationSubscriptions(integrationId, response); return true; } catch (e) { errorNotification('Failed to set subscriptions', e); throw e; } } static async getConnectLogs(params = {}) { try { const search = new URLSearchParams(); if (params.page) search.set('page', params.page); if (params.page_size) search.set('page_size', params.page_size); if (params.type) search.set('type', params.type); if (params.integration) search.set('integration', params.integration); return await request( `${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}` ); } catch (e) { errorNotification('Failed to fetch connect logs', e); } } }