Dispatcharr/frontend/src/api.js
nagelm ed1c449234 fix(ui): stop rendering raw HTML error pages in toast notifications (#1261)
Failed API responses whose body is not JSON - Django's HTML "Server
Error (500)" page, nginx's HTML 502/504 pages when the backend is down
or timing out - were interpolated verbatim into the error toast,
showing users a wall of markup.

request() deliberately keeps the raw text when JSON.parse fails, and
errorNotification() rendered `${status} - ${body}` unconditionally.
Route the body through a new formatApiError() helper (in utils.js so it
is unit-testable without importing the store-heavy api module):

- JSON object bodies keep their existing pretty-printed formatting
- markup and empty bodies collapse to the response's own status line:
  the declared Content-Type decides what counts as markup (body
  sniffing only as fallback when the header is missing), and the label
  comes from the fetch Response's statusText - the protocol's reason
  phrase, defined for every status code, rather than a hardcoded status
  map. HTTP/2+ transmits no reason phrase, so an empty statusText falls
  back to a generic label. request() already attaches the Response to
  the error, so no call-site changes are needed.
- the full suppressed body goes to console.debug so the markup stays
  available for troubleshooting (deliberate, not a leftover debug
  statement)
- plain-text bodies are truncated at 200 chars as a backstop
- errors without a status keep the error.message fallback
2026-07-14 20:37:58 +12:00

4005 lines
104 KiB
JavaScript

// 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 useServerGroupsStore from './store/serverGroups';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import useStreamsStore from './store/streams';
import useStreamProfilesStore from './store/streamProfiles';
import useOutputProfilesStore from './store/outputProfiles';
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, { formatApiError } 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) => {
const errorMessage = formatApiError(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 getServerGroups() {
try {
const response = await request(`${host}/api/m3u/server-groups/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve server groups', e);
}
}
static async addServerGroup(values) {
try {
const response = await request(`${host}/api/m3u/server-groups/`, {
method: 'POST',
body: values,
});
useServerGroupsStore.getState().addServerGroup(response);
return response;
} catch (e) {
errorNotification('Failed to create server group', e);
}
}
static async updateServerGroup(values) {
try {
const { id, ...payload } = values;
const response = await request(`${host}/api/m3u/server-groups/${id}/`, {
method: 'PUT',
body: payload,
});
useServerGroupsStore.getState().updateServerGroup(response);
return response;
} catch (e) {
errorNotification('Failed to update server group', e);
}
}
static async deleteServerGroup(id) {
try {
await request(`${host}/api/m3u/server-groups/${id}/`, {
method: 'DELETE',
});
useServerGroupsStore.getState().removeServerGroups([id]);
} catch (e) {
errorNotification('Failed to delete server group', 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 [];
}
}
static async getCurrentProgramForEpg(epgId) {
const response = await request(`${host}/api/epg/current-programs/`, {
method: 'POST',
body: { epg_data_ids: [epgId] },
});
if (response && response.length > 0) {
if (response[0].parsing) {
return { parsing: true };
}
return response[0];
}
return null;
}
// 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, force = false) {
try {
const response = await request(`${host}/api/epg/import/`, {
method: 'POST',
body: { id, force },
});
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 getOutputProfiles() {
try {
const response = await request(`${host}/api/core/outputprofiles/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve output profiles', e);
}
}
static async addOutputProfile(values) {
try {
const response = await request(`${host}/api/core/outputprofiles/`, {
method: 'POST',
body: values,
});
useOutputProfilesStore.getState().addOutputProfile(response);
return response;
} catch (e) {
errorNotification('Failed to create output profile', e);
}
}
static async updateOutputProfile(values) {
const { id, ...payload } = values;
try {
const response = await request(`${host}/api/core/outputprofiles/${id}/`, {
method: 'PUT',
body: payload,
});
useOutputProfilesStore.getState().updateOutputProfile(response);
return response;
} catch (e) {
errorNotification(`Failed to update output profile ${id}`, e);
}
}
static async deleteOutputProfile(id) {
try {
await request(`${host}/api/core/outputprofiles/${id}/`, {
method: 'DELETE',
});
useOutputProfilesStore.getState().removeOutputProfiles([id]);
} catch (e) {
errorNotification(`Failed to delete output profile ${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 getTimeshiftStats() {
try {
const response = await request(`${host}/proxy/catchup/stats/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve catch-up stats', e);
}
}
static async getAllConnectionStats() {
try {
const response = await request(`${host}/proxy/stats/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve connection stats', e);
throw e;
}
}
static async getCatchupPrograms(sessions) {
try {
const response = await request(`${host}/proxy/catchup/programs/`, {
method: 'POST',
body: { sessions },
});
return response;
} catch (e) {
console.error('Failed to retrieve catch-up programmes', e);
return { sessions: [] };
}
}
static async stopTimeshiftSession(sessionId) {
try {
const response = await request(`${host}/proxy/catchup/stop_client/`, {
method: 'POST',
body: {
session_id: sessionId,
},
});
return response;
} catch (e) {
errorNotification('Failed to stop catch-up session', 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, relationId = null) {
try {
const params = relationId ? `?relation_id=${relationId}` : '';
const response = await request(
`${host}/api/vod/movies/${movieId}/provider-info/${params}`
);
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, relationId = null) {
try {
const params = new URLSearchParams({ include_episodes: 'true' });
if (relationId) params.set('relation_id', relationId);
const response = await request(
`${host}/api/vod/series/${seriesId}/provider-info/?${params}`
);
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);
}
}
static async getSDLineups(sourceId) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve Schedules Direct lineups', e);
}
}
static async addSDLineup(sourceId, lineup) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
{
method: 'POST',
body: { lineup },
}
);
return response;
} catch (e) {
errorNotification(`Failed to add lineup ${lineup}`, e);
}
}
static async deleteSDLineup(sourceId, lineup) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
{
method: 'DELETE',
body: { lineup },
}
);
return response;
} catch (e) {
errorNotification(`Failed to remove lineup ${lineup}`, e);
}
}
static async updateEpgSourceSettings(sourceId, settings) {
try {
// Read current custom_properties from the store to merge, not replace
const epgs = useEPGsStore.getState().epgs;
const source = epgs[sourceId];
const cp = { ...(source?.custom_properties || {}), ...settings };
const response = await request(`${host}/api/epg/sources/${sourceId}/`, {
method: 'PATCH',
body: { custom_properties: cp },
});
useEPGsStore.getState().updateEPG(response);
return response;
} catch (e) {
errorNotification('Failed to update EPG source settings', e);
}
}
static async updateSDSettings(sourceId, settings) {
return API.updateEpgSourceSettings(sourceId, settings);
}
static async searchSDLineups(sourceId, country, postalcode) {
try {
const response = await request(
`${host}/api/epg/sources/${sourceId}/sd-lineups/search/`,
{
method: 'POST',
body: { country, postalcode },
}
);
return response;
} catch (e) {
errorNotification('Failed to search Schedules Direct lineups', e);
}
}
}