Dispatcharr/frontend/src/api.js
FiveBoroughs ef1518a8dd
Fix: Auto-parse EPG programs for unlinked channels on preview
When selecting an EPG channel in the channel form, if that EPG entry's
programs had never been parsed, the preview showed "No current program".
This was because parse_programs_for_tvg_id requires a linked channel.

- Add force parameter to parse_programs_for_tvg_id to bypass channel check
- Auto-trigger parsing from current-programs API when no programs exist
- Check Redis task lock to prevent duplicate parse tasks from concurrent requests
- Frontend retries up to 3 times with 10s delay to pick up parsed results
- Skip parsing for dummy EPG sources
2026-05-31 17:13:11 +02:00

3846 lines
100 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 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 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 [];
}
}
static async getCurrentProgramForEpg(epgId) {
try {
const response = await request(
`${host}/api/epg/current-programs/`,
{
method: 'POST',
body: { epg_data_ids: [epgId] },
}
);
// The endpoint returns an array, get the first item
if (response && response.length > 0) {
if (response[0].parsing) {
return { parsing: true };
}
return response[0];
}
return null;
} catch (e) {
console.error('Failed to retrieve current program for EPG', e);
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) {
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 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 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);
}
}
}