Dispatcharr/frontend/src/api.js
SergeantPanda f0267508ff
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Enhancement: Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647)
2026-01-15 16:26:06 -06:00

2743 lines
69 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 useSettingsStore from './store/settings';
import { notifications } from '@mantine/notifications';
import useChannelsTableStore from './store/channelsTable';
import useUsersStore from './store/users';
// 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();
}
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 {
const response = await request(`${host}/api/channels/channels/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve channels', e);
}
}
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) {
errorNotification('Failed to fetch channels', 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) {
errorNotification('Failed to fetch channels', e);
}
}
static async getAllChannelIds(params) {
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]);
} 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);
} 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);
return response;
} catch (e) {
errorNotification('Failed to update channel', e);
}
}
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);
}
}
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);
}
}
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 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);
}
}
static async addStream(values) {
try {
const response = await request(`${host}/api/channels/streams/`, {
method: 'POST',
body: values,
});
if (response.id) {
useStreamsStore.getState().addStream(response);
}
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);
}
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]);
} 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);
} 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) {
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) {
try {
await request(`${host}/api/m3u/accounts/${id}/`, {
method: 'DELETE',
});
usePlaylistsStore.getState().removePlaylists([id]);
// @TODO: MIGHT need to optimize this later if someone has thousands of channels
// but I'm feeling laze right now
// useChannelsStore.getState().fetchChannels();
} catch (e) {
errorNotification(`Failed to delete playlist ${id}`, e);
}
}
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) {
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);
}
}
// Notice there's a duplicated "refreshPlaylist" method above;
// you might want to rename or remove one if it's not needed.
static async addEPG(values) {
try {
let body = null;
if (values.files) {
body = new FormData();
for (const prop in values) {
body.append(prop, values[prop]);
}
} else {
body = { ...values };
delete body.file;
}
const response = await request(`${host}/api/epg/sources/`, {
method: 'POST',
body,
});
useEPGsStore.getState().addEPG(response);
return response;
} catch (e) {
errorNotification('Failed to create EPG', e);
}
}
static async updateEPG(values, isToggle = false) {
// Validate that values is an object
if (!values || typeof values !== 'object') {
console.error('updateEPG called with invalid values:', values);
return;
}
const { id, ...payload } = values;
// Validate that we have an ID and payload is an object
if (!id || typeof payload !== 'object') {
console.error('updateEPG: invalid id or payload', { id, payload });
return;
}
try {
// If this is just toggling the active state, make a simpler request
if (
isToggle &&
'is_active' in payload &&
Object.keys(payload).length === 1
) {
const response = await request(`${host}/api/epg/sources/${id}/`, {
method: 'PATCH',
body: { is_active: payload.is_active },
});
useEPGsStore.getState().updateEPG(response);
return response;
}
// Original implementation for full updates
let body = null;
if (payload.files) {
body = new FormData();
for (const prop in payload) {
if (prop == 'url') {
continue;
}
body.append(prop, payload[prop]);
}
} else {
delete payload.file;
if (!payload.url) {
delete payload.url;
}
body = payload;
}
const response = await request(`${host}/api/epg/sources/${id}/`, {
method: 'PATCH',
body,
});
useEPGsStore.getState().updateEPG(response);
return response;
} catch (e) {
errorNotification(`Failed to update EPG ${id}`, e);
}
}
static async deleteEPG(id) {
try {
await request(`${host}/api/epg/sources/${id}/`, {
method: 'DELETE',
});
useEPGsStore.getState().removeEPGs([id]);
} catch (e) {
errorNotification(`Failed to delete EPG ${id}`, e);
}
}
static async refreshEPG(id) {
try {
const response = await request(`${host}/api/epg/import/`, {
method: 'POST',
body: { id },
});
return response;
} catch (e) {
errorNotification(`Failed to refresh EPG ${id}`, e);
}
}
static async getTimezones() {
try {
const response = await request(`${host}/api/core/timezones/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve timezones', e);
// Return fallback data instead of throwing
return {
timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'],
grouped: {},
count: 5
};
}
}
static async getStreamProfiles() {
try {
const response = await request(`${host}/api/core/streamprofiles/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve sream profiles', e);
}
}
static async addStreamProfile(values) {
try {
const response = await request(`${host}/api/core/streamprofiles/`, {
method: 'POST',
body: values,
});
useStreamProfilesStore.getState().addStreamProfile(response);
return response;
} catch (e) {
errorNotification('Failed to create stream profile', e);
}
}
static async updateStreamProfile(values) {
const { id, ...payload } = values;
try {
const response = await request(`${host}/api/core/streamprofiles/${id}/`, {
method: 'PUT',
body: payload,
});
useStreamProfilesStore.getState().updateStreamProfile(response);
return response;
} catch (e) {
errorNotification(`Failed to update stream profile ${id}`, e);
}
}
static async deleteStreamProfile(id) {
try {
await request(`${host}/api/core/streamprofiles/${id}/`, {
method: 'DELETE',
});
useStreamProfilesStore.getState().removeStreamProfiles([id]);
} catch (e) {
errorNotification(`Failed to delete stream propfile ${id}`, e);
}
}
static async getGrid() {
try {
const response = await request(`${host}/api/epg/grid/`);
return response.data;
} catch (e) {
errorNotification('Failed to retrieve program grid', e);
}
}
static async 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()
.updateProfiles(playlist.id, playlist.profiles);
} 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) {
try {
const form = new FormData();
form.append('file', file);
const response = await request(`${host}/api/plugins/plugins/import/`, {
method: 'POST',
body: form,
});
return response;
} catch (e) {
// Show only the concise error message for plugin import
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);
}
}
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);
}
}
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 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) {
try {
const encodedTvgId = encodeURIComponent(tvgId);
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { 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 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',
});
// First fetch the complete channel data
await useChannelsStore.getState().fetchChannels();
// 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 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 updateUser(id, body) {
try {
const response = await request(`${host}/api/accounts/users/${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
}
}
// VOD Methods
static async getMovies(params = new URLSearchParams()) {
try {
const response = await request(
`${host}/api/vod/movies/?${params.toString()}`
);
return response;
} catch (e) {
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve movies', e);
}
throw e;
}
}
static async getSeries(params = new URLSearchParams()) {
try {
const response = await request(
`${host}/api/vod/series/?${params.toString()}`
);
return response;
} catch (e) {
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve series', e);
}
throw e;
}
}
static async getAllContent(params = new URLSearchParams()) {
try {
console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`);
const response = await request(
`${host}/api/vod/all/?${params.toString()}`
);
console.log('getAllContent raw response:', response);
return response;
} catch (e) {
console.error('getAllContent error:', e);
console.error('Error status:', e.status);
console.error('Error body:', e.body);
console.error('Error message:', e.message);
// Don't show error notification for "Invalid page" errors as they're handled gracefully
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
e.message?.includes('Invalid page');
if (!isInvalidPage) {
errorNotification('Failed to retrieve content', e);
}
throw e;
}
}
static async getMovieDetails(movieId) {
try {
const response = await request(`${host}/api/vod/movies/${movieId}/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve movie details', e);
}
}
static async getMovieProviderInfo(movieId) {
try {
const response = await request(
`${host}/api/vod/movies/${movieId}/provider-info/`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve movie provider info', e);
}
}
static async getMovieProviders(movieId) {
try {
const response = await request(
`${host}/api/vod/movies/${movieId}/providers/`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve movie providers', e);
}
}
static async getSeriesProviders(seriesId) {
try {
const response = await request(
`${host}/api/vod/series/${seriesId}/providers/`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve series providers', e);
}
}
static async getVODCategories() {
try {
const response = await request(`${host}/api/vod/categories/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve VOD categories', e);
}
}
static async getSeriesInfo(seriesId) {
try {
// Call the provider-info endpoint that includes episodes
const response = await request(
`${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true`
);
return response;
} catch (e) {
errorNotification('Failed to retrieve series info', e);
}
}
static async updateVODPosition(vodUuid, clientId, position) {
try {
const response = await request(
`${host}/proxy/vod/stream/${vodUuid}/position/`,
{
method: 'POST',
body: { client_id: clientId, position },
}
);
return response;
} catch (e) {
errorNotification('Failed to update playback position', 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);
}
}
}