mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 09:09:22 +00:00
Add updateSDSettings method to API client for SD logo style and settings management. Add fetchEPGData and requeryChannels calls after EPG match completion to ensure UI refreshes immediately. Add SD logo style picker and cache-busting for channel logos.
3915 lines
102 KiB
JavaScript
3915 lines
102 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) {
|
|
const response = await request(
|
|
`${host}/api/epg/current-programs/`,
|
|
{
|
|
method: 'POST',
|
|
body: { epg_data_ids: [epgId] },
|
|
}
|
|
);
|
|
|
|
if (response && response.length > 0) {
|
|
if (response[0].parsing) {
|
|
return { parsing: true };
|
|
}
|
|
return response[0];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Notice there's a duplicated "refreshPlaylist" method above;
|
|
// you might want to rename or remove one if it's not needed.
|
|
|
|
static async addEPG(values) {
|
|
try {
|
|
let body = null;
|
|
if (values.files) {
|
|
body = new FormData();
|
|
for (const prop in values) {
|
|
body.append(prop, values[prop]);
|
|
}
|
|
} else {
|
|
body = { ...values };
|
|
delete body.file;
|
|
}
|
|
|
|
const response = await request(`${host}/api/epg/sources/`, {
|
|
method: 'POST',
|
|
body,
|
|
});
|
|
|
|
useEPGsStore.getState().addEPG(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create EPG', e);
|
|
}
|
|
}
|
|
|
|
static async updateEPG(values, isToggle = false) {
|
|
// Validate that values is an object
|
|
if (!values || typeof values !== 'object') {
|
|
console.error('updateEPG called with invalid values:', values);
|
|
return;
|
|
}
|
|
|
|
const { id, ...payload } = values;
|
|
|
|
// Validate that we have an ID and payload is an object
|
|
if (!id || typeof payload !== 'object') {
|
|
console.error('updateEPG: invalid id or payload', { id, payload });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// If this is just toggling the active state, make a simpler request
|
|
if (
|
|
isToggle &&
|
|
'is_active' in payload &&
|
|
Object.keys(payload).length === 1
|
|
) {
|
|
const response = await request(`${host}/api/epg/sources/${id}/`, {
|
|
method: 'PATCH',
|
|
body: { is_active: payload.is_active },
|
|
});
|
|
|
|
useEPGsStore.getState().updateEPG(response);
|
|
return response;
|
|
}
|
|
|
|
// Original implementation for full updates
|
|
let body = null;
|
|
if (payload.files) {
|
|
body = new FormData();
|
|
for (const prop in payload) {
|
|
if (prop == 'url') {
|
|
continue;
|
|
}
|
|
body.append(prop, payload[prop]);
|
|
}
|
|
} else {
|
|
delete payload.file;
|
|
if (!payload.url) {
|
|
delete payload.url;
|
|
}
|
|
body = payload;
|
|
}
|
|
|
|
const response = await request(`${host}/api/epg/sources/${id}/`, {
|
|
method: 'PATCH',
|
|
body,
|
|
});
|
|
|
|
useEPGsStore.getState().updateEPG(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to update EPG ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteEPG(id) {
|
|
try {
|
|
await request(`${host}/api/epg/sources/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
useEPGsStore.getState().removeEPGs([id]);
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete EPG ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async refreshEPG(id, force = false) {
|
|
try {
|
|
const response = await request(`${host}/api/epg/import/`, {
|
|
method: 'POST',
|
|
body: { id, force },
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to refresh EPG ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async getTimezones() {
|
|
try {
|
|
const response = await request(`${host}/api/core/timezones/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve timezones', e);
|
|
// Return fallback data instead of throwing
|
|
return {
|
|
timezones: [
|
|
'UTC',
|
|
'US/Eastern',
|
|
'US/Central',
|
|
'US/Mountain',
|
|
'US/Pacific',
|
|
],
|
|
grouped: {},
|
|
count: 5,
|
|
};
|
|
}
|
|
}
|
|
|
|
static async getStreamProfiles() {
|
|
try {
|
|
const response = await request(`${host}/api/core/streamprofiles/`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve sream profiles', e);
|
|
}
|
|
}
|
|
|
|
static async addStreamProfile(values) {
|
|
try {
|
|
const response = await request(`${host}/api/core/streamprofiles/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
|
|
useStreamProfilesStore.getState().addStreamProfile(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create stream profile', e);
|
|
}
|
|
}
|
|
|
|
static async updateStreamProfile(values) {
|
|
const { id, ...payload } = values;
|
|
|
|
try {
|
|
const response = await request(`${host}/api/core/streamprofiles/${id}/`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
});
|
|
|
|
useStreamProfilesStore.getState().updateStreamProfile(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to update stream profile ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteStreamProfile(id) {
|
|
try {
|
|
await request(`${host}/api/core/streamprofiles/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
useStreamProfilesStore.getState().removeStreamProfiles([id]);
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete stream propfile ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async getOutputProfiles() {
|
|
try {
|
|
const response = await request(`${host}/api/core/outputprofiles/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve output profiles', e);
|
|
}
|
|
}
|
|
|
|
static async addOutputProfile(values) {
|
|
try {
|
|
const response = await request(`${host}/api/core/outputprofiles/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
useOutputProfilesStore.getState().addOutputProfile(response);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create output profile', e);
|
|
}
|
|
}
|
|
|
|
static async updateOutputProfile(values) {
|
|
const { id, ...payload } = values;
|
|
try {
|
|
const response = await request(`${host}/api/core/outputprofiles/${id}/`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
});
|
|
useOutputProfilesStore.getState().updateOutputProfile(response);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to update output profile ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteOutputProfile(id) {
|
|
try {
|
|
await request(`${host}/api/core/outputprofiles/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
useOutputProfilesStore.getState().removeOutputProfiles([id]);
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete output profile ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async getGrid() {
|
|
try {
|
|
const response = await request(`${host}/api/epg/grid/`);
|
|
|
|
return response.data;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve program grid', e);
|
|
}
|
|
}
|
|
|
|
static async getProgramDetail(programId) {
|
|
try {
|
|
const response = await request(`${host}/api/epg/programs/${programId}/`);
|
|
return response;
|
|
} catch (e) {
|
|
console.warn('Failed to retrieve program detail', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async addM3UProfile(accountId, values) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/m3u/accounts/${accountId}/profiles/`,
|
|
{
|
|
method: 'POST',
|
|
body: values,
|
|
}
|
|
);
|
|
|
|
// Refresh the playlist
|
|
const playlist = await API.getPlaylist(accountId);
|
|
usePlaylistsStore
|
|
.getState()
|
|
.updateProfiles(playlist.id, playlist.profiles);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to add profile to account ${accountId}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteM3UProfile(accountId, id) {
|
|
try {
|
|
await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
const playlist = await API.getPlaylist(accountId);
|
|
usePlaylistsStore.getState().updatePlaylist(playlist);
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete profile for account ${accountId}`, e);
|
|
}
|
|
}
|
|
|
|
static async updateM3UProfile(accountId, values) {
|
|
const { id, ...payload } = values;
|
|
|
|
try {
|
|
await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
});
|
|
|
|
const playlist = await API.getPlaylist(accountId);
|
|
usePlaylistsStore.getState().updatePlaylist(playlist);
|
|
} catch (e) {
|
|
errorNotification(`Failed to update profile for account ${accountId}`, e);
|
|
}
|
|
}
|
|
|
|
static async refreshAccountInfo(profileId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/m3u/refresh-account-info/${profileId}/`,
|
|
{
|
|
method: 'POST',
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
// If it's a structured error response, return it instead of throwing
|
|
if (e.body && typeof e.body === 'object') {
|
|
return e.body;
|
|
}
|
|
errorNotification(
|
|
`Failed to refresh account info for profile ${profileId}`,
|
|
e
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async addM3UFilter(accountId, values) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/m3u/accounts/${accountId}/filters/`,
|
|
{
|
|
method: 'POST',
|
|
body: values,
|
|
}
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to add profile to account ${accountId}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteM3UFilter(accountId, id) {
|
|
try {
|
|
await request(`${host}/api/m3u/accounts/${accountId}/filters/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete profile for account ${accountId}`, e);
|
|
}
|
|
}
|
|
|
|
static async updateM3UFilter(accountId, filterId, values) {
|
|
const { id, ...payload } = values;
|
|
|
|
try {
|
|
await request(
|
|
`${host}/api/m3u/accounts/${accountId}/filters/${filterId}/`,
|
|
{
|
|
method: 'PUT',
|
|
body: payload,
|
|
}
|
|
);
|
|
} catch (e) {
|
|
errorNotification(`Failed to update profile for account ${accountId}`, e);
|
|
}
|
|
}
|
|
|
|
static async getSettings() {
|
|
try {
|
|
const response = await request(`${host}/api/core/settings/`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve settings', e);
|
|
}
|
|
}
|
|
|
|
static async getEnvironmentSettings() {
|
|
try {
|
|
const response = await request(`${host}/api/core/settings/env/`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve environment settings', e);
|
|
}
|
|
}
|
|
|
|
// Backup API (async with Celery task polling)
|
|
static async listBackups() {
|
|
try {
|
|
const response = await request(`${host}/api/backups/`);
|
|
return response || [];
|
|
} catch (e) {
|
|
errorNotification('Failed to load backups', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getBackupStatus(taskId, token = null) {
|
|
try {
|
|
let url = `${host}/api/backups/status/${taskId}/`;
|
|
if (token) {
|
|
url += `?token=${encodeURIComponent(token)}`;
|
|
}
|
|
const response = await request(url, { auth: !token });
|
|
return response;
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async waitForBackupTask(taskId, onProgress, token = null) {
|
|
const pollInterval = 2000; // Poll every 2 seconds
|
|
const maxAttempts = 300; // Max 10 minutes (300 * 2s)
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
const status = await API.getBackupStatus(taskId, token);
|
|
|
|
if (onProgress) {
|
|
onProgress(status);
|
|
}
|
|
|
|
if (status.state === 'completed') {
|
|
return status.result;
|
|
} else if (status.state === 'failed') {
|
|
throw new Error(status.error || 'Task failed');
|
|
}
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
|
|
// Wait before next poll
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
}
|
|
|
|
throw new Error('Task timed out');
|
|
}
|
|
|
|
static async createBackup(onProgress) {
|
|
try {
|
|
// Start the backup task
|
|
const response = await request(`${host}/api/backups/create/`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
// Wait for the task to complete using token for auth
|
|
const result = await API.waitForBackupTask(
|
|
response.task_id,
|
|
onProgress,
|
|
response.task_token
|
|
);
|
|
return result;
|
|
} catch (e) {
|
|
errorNotification('Failed to create backup', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async uploadBackup(file) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const response = await request(`${host}/api/backups/upload/`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to upload backup', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deleteBackup(filename) {
|
|
try {
|
|
const encodedFilename = encodeURIComponent(filename);
|
|
await request(`${host}/api/backups/${encodedFilename}/delete/`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to delete backup', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getDownloadToken(filename) {
|
|
// Get a download token from the server
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`
|
|
);
|
|
return response.token;
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async downloadBackup(filename) {
|
|
try {
|
|
// Get a download token first (requires auth)
|
|
const token = await API.getDownloadToken(filename);
|
|
const encodedFilename = encodeURIComponent(filename);
|
|
|
|
// Build the download URL with token
|
|
const downloadUrl = `${host}/api/backups/${encodedFilename}/download/?token=${encodeURIComponent(token)}`;
|
|
|
|
// Use direct browser navigation instead of fetch to avoid CORS issues
|
|
const link = document.createElement('a');
|
|
link.href = downloadUrl;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
return { filename };
|
|
} catch (e) {
|
|
errorNotification('Failed to download backup', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async restoreBackup(filename, onProgress) {
|
|
try {
|
|
// Start the restore task
|
|
const encodedFilename = encodeURIComponent(filename);
|
|
const response = await request(
|
|
`${host}/api/backups/${encodedFilename}/restore/`,
|
|
{
|
|
method: 'POST',
|
|
}
|
|
);
|
|
|
|
// Wait for the task to complete using token for auth
|
|
// Token-based auth allows status polling even after DB restore invalidates user sessions
|
|
const result = await API.waitForBackupTask(
|
|
response.task_id,
|
|
onProgress,
|
|
response.task_token
|
|
);
|
|
return result;
|
|
} catch (e) {
|
|
errorNotification('Failed to restore backup', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getBackupSchedule() {
|
|
try {
|
|
const response = await request(`${host}/api/backups/schedule/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to get backup schedule', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async updateBackupSchedule(settings) {
|
|
try {
|
|
const response = await request(`${host}/api/backups/schedule/update/`, {
|
|
method: 'PUT',
|
|
body: settings,
|
|
});
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update backup schedule', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getVersion() {
|
|
try {
|
|
const response = await request(`${host}/api/core/version/`, {
|
|
auth: false,
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve version', e);
|
|
}
|
|
}
|
|
|
|
// Plugins API
|
|
static async getPlugins() {
|
|
try {
|
|
const response = await request(`${host}/api/plugins/plugins/`);
|
|
return response.plugins || [];
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve plugins', e);
|
|
}
|
|
}
|
|
|
|
static async reloadPlugins() {
|
|
try {
|
|
const response = await request(`${host}/api/plugins/plugins/reload/`, {
|
|
method: 'POST',
|
|
});
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to reload plugins', e);
|
|
}
|
|
}
|
|
|
|
static async importPlugin(file, overwrite = false, silent = false) {
|
|
try {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
if (overwrite) form.append('overwrite', 'true');
|
|
const response = await request(`${host}/api/plugins/plugins/import/`, {
|
|
method: 'POST',
|
|
body: form,
|
|
});
|
|
return response;
|
|
} catch (e) {
|
|
if (!silent) {
|
|
const msg =
|
|
(e?.body && (e.body.error || e.body.detail)) ||
|
|
e?.message ||
|
|
'Failed to import plugin';
|
|
notifications.show({
|
|
title: 'Import failed',
|
|
message: msg,
|
|
color: 'red',
|
|
});
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deletePlugin(key) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/plugins/plugins/${key}/delete/`,
|
|
{
|
|
method: 'DELETE',
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete plugin', e);
|
|
}
|
|
}
|
|
|
|
static async updatePluginSettings(key, settings) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/plugins/plugins/${key}/settings/`,
|
|
{
|
|
method: 'POST',
|
|
body: { settings },
|
|
}
|
|
);
|
|
return response?.settings || {};
|
|
} catch (e) {
|
|
errorNotification('Failed to update plugin settings', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async runPluginAction(key, action, params = {}) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/plugins/plugins/${key}/run/`,
|
|
{
|
|
method: 'POST',
|
|
body: { action, params },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to run plugin action', e);
|
|
}
|
|
}
|
|
|
|
static async setPluginEnabled(key, enabled) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/plugins/plugins/${key}/enabled/`,
|
|
{
|
|
method: 'POST',
|
|
body: { enabled },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update plugin enabled state', e);
|
|
}
|
|
}
|
|
|
|
// Plugin Repos API
|
|
static async getPluginRepos() {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/`);
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve plugin repos', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
static async addPluginRepo(data) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/`, {
|
|
method: 'POST',
|
|
body: data,
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to add plugin repo', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deletePluginRepo(id) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to delete plugin repo', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async updatePluginRepo(id, data) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/${id}/`, {
|
|
method: 'PUT',
|
|
body: data,
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to update plugin repo', e);
|
|
}
|
|
}
|
|
|
|
static async refreshPluginRepo(id) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/${id}/refresh/`, {
|
|
method: 'POST',
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to refresh plugin repo', e);
|
|
}
|
|
}
|
|
|
|
static async getAvailablePlugins() {
|
|
try {
|
|
const response = await request(`${host}/api/plugins/repos/available/`);
|
|
return response.plugins || [];
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve available plugins', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
static async getPluginDetailManifest(repoId, manifestUrl) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/plugins/repos/plugin-detail/`,
|
|
{
|
|
method: 'POST',
|
|
body: { repo_id: repoId, manifest_url: manifestUrl },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve plugin details', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async getPluginRepoSettings() {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/settings/`);
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve repo settings', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async updatePluginRepoSettings(data) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/settings/`, {
|
|
method: 'PUT',
|
|
body: data,
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to update repo settings', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async installPluginFromRepo(data) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/install/`, {
|
|
method: 'POST',
|
|
body: data,
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to install plugin', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async previewPluginRepo(url, publicKey) {
|
|
try {
|
|
return await request(`${host}/api/plugins/repos/preview/`, {
|
|
method: 'POST',
|
|
body: { url, public_key: publicKey || '' },
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async checkSetting(values) {
|
|
const { id, ...payload } = values;
|
|
|
|
try {
|
|
const response = await request(`${host}/api/core/settings/check/`, {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update settings', e);
|
|
}
|
|
}
|
|
|
|
static async updateSetting(values) {
|
|
const { id, ...payload } = values;
|
|
|
|
try {
|
|
const response = await request(`${host}/api/core/settings/${id}/`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
});
|
|
|
|
useSettingsStore.getState().updateSetting(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update settings', e);
|
|
}
|
|
}
|
|
|
|
static async createSetting(values) {
|
|
try {
|
|
const response = await request(`${host}/api/core/settings/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
useSettingsStore.getState().updateSetting(response);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create setting', e);
|
|
}
|
|
}
|
|
|
|
static async getChannelStats(uuid = null) {
|
|
try {
|
|
const response = await request(`${host}/proxy/ts/status`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve channel stats', e);
|
|
}
|
|
}
|
|
|
|
static async getVODStats() {
|
|
try {
|
|
const response = await request(`${host}/proxy/vod/stats/`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve VOD stats', e);
|
|
}
|
|
}
|
|
|
|
static async stopVODClient(clientId) {
|
|
try {
|
|
const response = await request(`${host}/proxy/vod/stop_client/`, {
|
|
method: 'POST',
|
|
body: { client_id: clientId },
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to stop VOD client', e);
|
|
}
|
|
}
|
|
|
|
static async stopChannel(id) {
|
|
try {
|
|
const response = await request(`${host}/proxy/ts/stop/${id}`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to stop channel', e);
|
|
}
|
|
}
|
|
|
|
static async stopClient(channelId, clientId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/proxy/ts/stop_client/${channelId}`,
|
|
{
|
|
method: 'POST',
|
|
body: { client_id: clientId },
|
|
}
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to stop client', e);
|
|
}
|
|
}
|
|
|
|
static async matchEpg(channelIds = null) {
|
|
try {
|
|
const requestBody = channelIds ? { channel_ids: channelIds } : {};
|
|
|
|
const response = await request(
|
|
`${host}/api/channels/channels/match-epg/`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
}
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to run EPG auto-match', e);
|
|
}
|
|
}
|
|
|
|
static async matchChannelEpg(channelId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/channels/channels/${channelId}/match-epg/`,
|
|
{
|
|
method: 'POST',
|
|
}
|
|
);
|
|
|
|
// Update the channel in the store with the refreshed data if provided
|
|
if (response.channel) {
|
|
useChannelsStore.getState().updateChannel(response.channel);
|
|
}
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to run EPG auto-match for channel', e);
|
|
}
|
|
}
|
|
|
|
static async fetchActiveChannelStats() {
|
|
try {
|
|
const response = await request(`${host}/proxy/ts/status`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch active channel stats', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getLogos(params = {}) {
|
|
try {
|
|
const queryParams = new URLSearchParams(params);
|
|
const response = await request(
|
|
`${host}/api/channels/logos/?${queryParams.toString()}`
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve logos', e);
|
|
}
|
|
}
|
|
|
|
static async getLogosByIds(logoIds) {
|
|
try {
|
|
if (!logoIds || logoIds.length === 0) return [];
|
|
|
|
const params = new URLSearchParams();
|
|
logoIds.forEach((id) => params.append('ids', id));
|
|
// Disable pagination for ID-based queries to get all matching logos
|
|
params.append('no_pagination', 'true');
|
|
|
|
const response = await request(
|
|
`${host}/api/channels/logos/?${params.toString()}`
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve logos by IDs', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
static async fetchLogos() {
|
|
try {
|
|
const response = await this.getLogos();
|
|
useLogosStore.getState().setLogos(response);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch logos', e);
|
|
}
|
|
}
|
|
|
|
static async fetchUsedLogos() {
|
|
try {
|
|
const response = await useLogosStore.getState().fetchUsedLogos();
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch used logos', e);
|
|
}
|
|
}
|
|
|
|
static async fetchLogosByIds(logoIds) {
|
|
try {
|
|
const response = await useLogosStore.getState().fetchLogosByIds(logoIds);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch logos by IDs', e);
|
|
}
|
|
}
|
|
|
|
static async uploadLogo(file, name = null) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
// Add custom name if provided
|
|
if (name && name.trim()) {
|
|
formData.append('name', name.trim());
|
|
}
|
|
|
|
// Add timeout handling for file uploads
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
|
|
|
const response = await fetch(`${host}/api/channels/logos/upload/`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
Authorization: `Bearer ${await API.getAuthToken()}`,
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (!response.ok) {
|
|
const error = new Error(`HTTP error! Status: ${response.status}`);
|
|
let errorBody = await response.text();
|
|
|
|
try {
|
|
errorBody = JSON.parse(errorBody);
|
|
} catch (e) {
|
|
// If parsing fails, leave errorBody as the raw text
|
|
}
|
|
|
|
error.status = response.status;
|
|
error.response = response;
|
|
error.body = errorBody;
|
|
throw error;
|
|
}
|
|
|
|
const result = await response.json();
|
|
useLogosStore.getState().addLogo(result);
|
|
return result;
|
|
} catch (e) {
|
|
if (e.name === 'AbortError') {
|
|
const timeoutError = new Error('Upload timed out. Please try again.');
|
|
timeoutError.code = 'NETWORK_ERROR';
|
|
throw timeoutError;
|
|
}
|
|
errorNotification('Failed to upload logo', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async createLogo(values) {
|
|
try {
|
|
// Use FormData for logo creation to match backend expectations
|
|
const formData = new FormData();
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (value !== null && value !== undefined) {
|
|
formData.append(key, value);
|
|
}
|
|
}
|
|
|
|
const response = await request(`${host}/api/channels/logos/`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
useLogosStore.getState().addLogo(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create logo', e);
|
|
}
|
|
}
|
|
|
|
static async updateLogo(id, values) {
|
|
try {
|
|
const response = await request(`${host}/api/channels/logos/${id}/`, {
|
|
method: 'PUT',
|
|
body: values, // This will be converted to JSON in the request function
|
|
});
|
|
|
|
useLogosStore.getState().updateLogo(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update logo', e);
|
|
}
|
|
}
|
|
|
|
static async deleteLogo(id, deleteFile = false) {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (deleteFile) {
|
|
params.append('delete_file', 'true');
|
|
}
|
|
|
|
const url = `${host}/api/channels/logos/${id}/?${params.toString()}`;
|
|
await request(url, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
useLogosStore.getState().removeLogo(id);
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete logo', e);
|
|
}
|
|
}
|
|
|
|
static async deleteLogos(ids, deleteFiles = false) {
|
|
try {
|
|
const body = { logo_ids: ids };
|
|
if (deleteFiles) {
|
|
body.delete_files = true;
|
|
}
|
|
|
|
await request(`${host}/api/channels/logos/bulk-delete/`, {
|
|
method: 'DELETE',
|
|
body: body,
|
|
});
|
|
|
|
// Remove multiple logos from store
|
|
ids.forEach((id) => {
|
|
useLogosStore.getState().removeLogo(id);
|
|
});
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete logos', e);
|
|
}
|
|
}
|
|
|
|
static async cleanupUnusedLogos(deleteFiles = false) {
|
|
try {
|
|
const body = {};
|
|
if (deleteFiles) {
|
|
body.delete_files = true;
|
|
}
|
|
|
|
const response = await request(`${host}/api/channels/logos/cleanup/`, {
|
|
method: 'POST',
|
|
body: body,
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to cleanup unused logos', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// VOD Logo Methods
|
|
static async getVODLogos(params = {}) {
|
|
try {
|
|
// Transform usage filter to match backend expectations
|
|
const apiParams = { ...params };
|
|
if (apiParams.usage === 'used') {
|
|
apiParams.used = 'true';
|
|
delete apiParams.usage;
|
|
} else if (apiParams.usage === 'unused') {
|
|
apiParams.used = 'false';
|
|
delete apiParams.usage;
|
|
} else if (apiParams.usage === 'movies') {
|
|
apiParams.used = 'movies';
|
|
delete apiParams.usage;
|
|
} else if (apiParams.usage === 'series') {
|
|
apiParams.used = 'series';
|
|
delete apiParams.usage;
|
|
}
|
|
|
|
const queryParams = new URLSearchParams(apiParams);
|
|
const response = await request(
|
|
`${host}/api/vod/vodlogos/?${queryParams.toString()}`
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve VOD logos', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deleteVODLogo(id) {
|
|
try {
|
|
await request(`${host}/api/vod/vodlogos/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete VOD logo', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deleteVODLogos(ids) {
|
|
try {
|
|
await request(`${host}/api/vod/vodlogos/bulk-delete/`, {
|
|
method: 'DELETE',
|
|
body: { logo_ids: ids },
|
|
});
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete VOD logos', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async cleanupUnusedVODLogos() {
|
|
try {
|
|
const response = await request(`${host}/api/vod/vodlogos/cleanup/`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to cleanup unused VOD logos', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getChannelProfiles() {
|
|
try {
|
|
const response = await request(`${host}/api/channels/profiles/`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to get channel profiles', e);
|
|
}
|
|
}
|
|
|
|
static async addChannelProfile(values) {
|
|
try {
|
|
const response = await request(`${host}/api/channels/profiles/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
|
|
useChannelsStore.getState().addProfile(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create channel profile', e);
|
|
}
|
|
}
|
|
|
|
static async updateChannelProfile(values) {
|
|
const { id, ...payload } = values;
|
|
|
|
try {
|
|
const response = await request(`${host}/api/channels/profiles/${id}/`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
});
|
|
|
|
useChannelsStore.getState().updateProfile(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update channel profile', e);
|
|
}
|
|
}
|
|
|
|
static async duplicateChannelProfile(id, name) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/channels/profiles/${id}/duplicate/`,
|
|
{
|
|
method: 'POST',
|
|
body: { name },
|
|
}
|
|
);
|
|
|
|
useChannelsStore.getState().addProfile(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to duplicate channel profile ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteChannelProfile(id) {
|
|
try {
|
|
await request(`${host}/api/channels/profiles/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
useChannelsStore.getState().removeProfiles([id]);
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete channel profile ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async updateProfileChannel(channelId, profileId, enabled) {
|
|
try {
|
|
await request(
|
|
`${host}/api/channels/profiles/${profileId}/channels/${channelId}/`,
|
|
{
|
|
method: 'PATCH',
|
|
body: { enabled },
|
|
}
|
|
);
|
|
|
|
useChannelsStore
|
|
.getState()
|
|
.updateProfileChannels([channelId], profileId, enabled);
|
|
} catch (e) {
|
|
errorNotification(`Failed to update channel for profile ${profileId}`, e);
|
|
}
|
|
}
|
|
|
|
static async updateProfileChannels(channelIds, profileId, enabled) {
|
|
try {
|
|
await request(
|
|
`${host}/api/channels/profiles/${profileId}/channels/bulk-update/`,
|
|
{
|
|
method: 'PATCH',
|
|
body: {
|
|
channels: channelIds.map((id) => ({
|
|
channel_id: id,
|
|
enabled,
|
|
})),
|
|
},
|
|
}
|
|
);
|
|
|
|
useChannelsStore
|
|
.getState()
|
|
.updateProfileChannels(channelIds, profileId, enabled);
|
|
} catch (e) {
|
|
errorNotification(
|
|
`Failed to bulk update channels for profile ${profileId}`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
static async getRecordings() {
|
|
try {
|
|
const response = await request(`${host}/api/channels/recordings/`);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve recordings', e);
|
|
}
|
|
}
|
|
|
|
static async createRecording(values) {
|
|
try {
|
|
const response = await request(`${host}/api/channels/recordings/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
|
|
useChannelsStore.getState().fetchRecordings();
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create recording', e);
|
|
}
|
|
}
|
|
|
|
static async updateRecording(id, values) {
|
|
try {
|
|
const response = await request(`${host}/api/channels/recordings/${id}/`, {
|
|
method: 'PATCH',
|
|
body: values,
|
|
});
|
|
useChannelsStore.getState().fetchRecordings();
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to update recording ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async getComskipConfig() {
|
|
try {
|
|
return await request(`${host}/api/channels/dvr/comskip-config/`);
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve comskip configuration', e);
|
|
}
|
|
}
|
|
|
|
static async uploadComskipIni(file) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
return await request(`${host}/api/channels/dvr/comskip-config/`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to upload comskip.ini', e);
|
|
}
|
|
}
|
|
|
|
static async listRecurringRules() {
|
|
try {
|
|
const response = await request(`${host}/api/channels/recurring-rules/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve recurring DVR rules', e);
|
|
}
|
|
}
|
|
|
|
static async createRecurringRule(payload) {
|
|
try {
|
|
const response = await request(`${host}/api/channels/recurring-rules/`, {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create recurring DVR rule', e);
|
|
}
|
|
}
|
|
|
|
static async updateRecurringRule(ruleId, payload) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/channels/recurring-rules/${ruleId}/`,
|
|
{
|
|
method: 'PATCH',
|
|
body: payload,
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to update recurring rule ${ruleId}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteRecurringRule(ruleId) {
|
|
try {
|
|
await request(`${host}/api/channels/recurring-rules/${ruleId}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete recurring rule ${ruleId}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteRecording(id) {
|
|
try {
|
|
await request(`${host}/api/channels/recordings/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
// Optimistically remove locally for instant UI update
|
|
try {
|
|
useChannelsStore.getState().removeRecording(id);
|
|
} catch {}
|
|
} catch (e) {
|
|
errorNotification(`Failed to delete recording ${id}`, e);
|
|
}
|
|
}
|
|
|
|
static async stopRecording(id) {
|
|
try {
|
|
await request(`${host}/api/channels/recordings/${id}/stop/`, {
|
|
method: 'POST',
|
|
});
|
|
} catch (e) {
|
|
errorNotification(`Failed to stop recording ${id}`, e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async extendRecording(id, extraMinutes) {
|
|
try {
|
|
const resp = await request(
|
|
`${host}/api/channels/recordings/${id}/extend/`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ extra_minutes: extraMinutes }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
return resp;
|
|
} catch (e) {
|
|
errorNotification(`Failed to extend recording ${id}`, e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async refreshArtwork(id) {
|
|
try {
|
|
await request(`${host}/api/channels/recordings/${id}/refresh-artwork/`, {
|
|
method: 'POST',
|
|
});
|
|
} catch (e) {
|
|
errorNotification(`Failed to refresh artwork for recording ${id}`, e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async updateRecordingMetadata(id, { title, description }) {
|
|
try {
|
|
await request(`${host}/api/channels/recordings/${id}/update-metadata/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, description }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
} catch (e) {
|
|
errorNotification(`Failed to update recording metadata`, e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async runComskip(recordingId) {
|
|
try {
|
|
const resp = await request(
|
|
`${host}/api/channels/recordings/${recordingId}/comskip/`,
|
|
{
|
|
method: 'POST',
|
|
}
|
|
);
|
|
// Refresh recordings list to reflect comskip status when done later
|
|
// This endpoint just queues the task; the websocket/refresh will update eventually
|
|
return resp;
|
|
} catch (e) {
|
|
errorNotification('Failed to run comskip', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// DVR Series Rules
|
|
static async listSeriesRules() {
|
|
try {
|
|
const resp = await request(`${host}/api/channels/series-rules/`);
|
|
return resp?.rules || [];
|
|
} catch (e) {
|
|
errorNotification('Failed to load series rules', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
static async createSeriesRule(values) {
|
|
try {
|
|
const resp = await request(`${host}/api/channels/series-rules/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
notifications.show({ title: 'Series rule saved' });
|
|
return resp;
|
|
} catch (e) {
|
|
errorNotification('Failed to save series rule', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deleteSeriesRule(tvgId, title) {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (tvgId) params.set('tvg_id', tvgId);
|
|
if (title) params.set('title', title);
|
|
await request(`${host}/api/channels/series-rules/?${params}`, {
|
|
method: 'DELETE',
|
|
});
|
|
notifications.show({ title: 'Series rule removed' });
|
|
} catch (e) {
|
|
errorNotification('Failed to remove series rule', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async deleteAllUpcomingRecordings() {
|
|
try {
|
|
const resp = await request(
|
|
`${host}/api/channels/recordings/bulk-delete-upcoming/`,
|
|
{
|
|
method: 'POST',
|
|
}
|
|
);
|
|
notifications.show({ title: `Removed ${resp.removed || 0} upcoming` });
|
|
useChannelsStore.getState().fetchRecordings();
|
|
return resp;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete upcoming recordings', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async evaluateSeriesRules(tvgId = null) {
|
|
try {
|
|
await request(`${host}/api/channels/series-rules/evaluate/`, {
|
|
method: 'POST',
|
|
body: tvgId ? { tvg_id: tvgId } : {},
|
|
});
|
|
} catch (e) {
|
|
errorNotification('Failed to evaluate series rules', e);
|
|
}
|
|
}
|
|
|
|
static async previewSeriesRule(values, { signal } = {}) {
|
|
// Throws on error so callers can ignore aborted requests vs real failures.
|
|
return request(`${host}/api/channels/series-rules/preview/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
signal,
|
|
});
|
|
}
|
|
|
|
static async bulkRemoveSeriesRecordings({
|
|
tvg_id,
|
|
title = null,
|
|
scope = 'title',
|
|
}) {
|
|
try {
|
|
const resp = await request(
|
|
`${host}/api/channels/series-rules/bulk-remove/`,
|
|
{
|
|
method: 'POST',
|
|
body: { tvg_id, title, scope },
|
|
}
|
|
);
|
|
notifications.show({ title: `Removed ${resp.removed || 0} scheduled` });
|
|
return resp;
|
|
} catch (e) {
|
|
errorNotification('Failed to bulk-remove scheduled recordings', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async switchStream(channelId, streamId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/proxy/ts/change_stream/${channelId}`,
|
|
{
|
|
method: 'POST',
|
|
body: { stream_id: streamId },
|
|
}
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to switch stream', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async nextStream(channelId, streamId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/proxy/ts/next_stream/${channelId}`,
|
|
{
|
|
method: 'POST',
|
|
body: { stream_id: streamId },
|
|
}
|
|
);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to switch stream', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async batchSetEPG(associations) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/channels/channels/batch-set-epg/`,
|
|
{
|
|
method: 'POST',
|
|
body: { associations },
|
|
}
|
|
);
|
|
|
|
// If successful, requery channels to update UI
|
|
if (response.success) {
|
|
// Build message based on whether EPG sources need refreshing
|
|
let message = `Updated ${response.channels_updated} channel${response.channels_updated !== 1 ? 's' : ''}`;
|
|
if (response.programs_refreshed > 0) {
|
|
message += `, refreshing ${response.programs_refreshed} EPG source${response.programs_refreshed !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
notifications.show({
|
|
title: 'EPG Association',
|
|
message: message,
|
|
color: 'blue',
|
|
});
|
|
|
|
// Then refresh the current table view
|
|
this.requeryChannels();
|
|
}
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update channel EPGs', e);
|
|
}
|
|
}
|
|
|
|
static async getChannel(id) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/channels/channels/${id}/?include_streams=true`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch channel details', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async me() {
|
|
return await request(`${host}/api/accounts/users/me/`);
|
|
}
|
|
|
|
static async updateMe(data) {
|
|
return await request(`${host}/api/accounts/users/me/`, {
|
|
method: 'PATCH',
|
|
body: data,
|
|
});
|
|
}
|
|
|
|
static async getUsers() {
|
|
try {
|
|
const response = await request(`${host}/api/accounts/users/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch users', e);
|
|
}
|
|
}
|
|
|
|
static async createUser(body) {
|
|
try {
|
|
const response = await request(`${host}/api/accounts/users/`, {
|
|
method: 'POST',
|
|
body,
|
|
});
|
|
|
|
useUsersStore.getState().addUser(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch users', e);
|
|
}
|
|
}
|
|
|
|
static async generateApiKey({ user_id = null, name = '' } = {}) {
|
|
try {
|
|
const body = {};
|
|
if (user_id) body.user_id = user_id;
|
|
if (name) body.name = name;
|
|
|
|
const response = await request(
|
|
`${host}/api/accounts/api-keys/generate/`,
|
|
{
|
|
method: 'POST',
|
|
body,
|
|
}
|
|
);
|
|
|
|
// If the backend returned an updated user, refresh the users store
|
|
try {
|
|
if (response && response.user) {
|
|
useUsersStore.getState().updateUser(response.user);
|
|
}
|
|
} catch (e) {
|
|
// ignore store update errors
|
|
}
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to generate API key', e);
|
|
}
|
|
}
|
|
|
|
static async revokeApiKey({ user_id = null } = {}) {
|
|
try {
|
|
const body = {};
|
|
if (user_id) {
|
|
body.user_id = user_id;
|
|
}
|
|
|
|
const response = await request(`${host}/api/accounts/api-keys/revoke/`, {
|
|
method: 'POST',
|
|
body,
|
|
});
|
|
|
|
// If the backend returned an updated user, refresh the users store
|
|
try {
|
|
if (response && response.user) {
|
|
useUsersStore.getState().updateUser(response.user);
|
|
}
|
|
} catch (e) {
|
|
// ignore store update errors
|
|
}
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to revoke API key', e);
|
|
}
|
|
}
|
|
|
|
static async updateUser(id, body, self = false) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/accounts/users/${self ? 'me' : id}/`,
|
|
{
|
|
method: 'PATCH',
|
|
body,
|
|
}
|
|
);
|
|
|
|
useUsersStore.getState().updateUser(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch users', e);
|
|
}
|
|
}
|
|
|
|
static async deleteUser(id) {
|
|
try {
|
|
await request(`${host}/api/accounts/users/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
useUsersStore.getState().removeUser(id);
|
|
} catch (e) {
|
|
errorNotification('Failed to delete user', e);
|
|
}
|
|
}
|
|
|
|
static async rehashStreams() {
|
|
try {
|
|
const response = await request(`${host}/api/core/rehash-streams/`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to trigger stream rehash', e);
|
|
}
|
|
}
|
|
|
|
static async getStreamsByIds(ids) {
|
|
try {
|
|
// Use POST for large ID lists to avoid URL length limitations
|
|
if (ids.length > 50) {
|
|
const response = await request(`${host}/api/channels/streams/by-ids/`, {
|
|
method: 'POST',
|
|
body: { ids },
|
|
});
|
|
return response;
|
|
} else {
|
|
// Use GET for small ID lists for backward compatibility
|
|
const params = new URLSearchParams();
|
|
params.append('ids', ids.join(','));
|
|
const response = await request(
|
|
`${host}/api/channels/streams/?${params.toString()}`
|
|
);
|
|
return response.results || response;
|
|
}
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve streams by IDs', e);
|
|
throw e; // Re-throw to allow proper error handling in calling code
|
|
}
|
|
}
|
|
|
|
static async getChannelsByUUIDs(uuids) {
|
|
try {
|
|
// Use POST for large lists
|
|
const response = await request(
|
|
`${host}/api/channels/channels/by-uuids/`,
|
|
{
|
|
method: 'POST',
|
|
body: { uuids },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve channels by UUIDs', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// VOD Methods
|
|
static async getMovies(params = new URLSearchParams()) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/vod/movies/?${params.toString()}`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
// Don't show error notification for "Invalid page" errors as they're handled gracefully
|
|
const isInvalidPage =
|
|
e.body?.detail?.includes('Invalid page') ||
|
|
e.message?.includes('Invalid page');
|
|
|
|
if (!isInvalidPage) {
|
|
errorNotification('Failed to retrieve movies', e);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getSeries(params = new URLSearchParams()) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/vod/series/?${params.toString()}`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
// Don't show error notification for "Invalid page" errors as they're handled gracefully
|
|
const isInvalidPage =
|
|
e.body?.detail?.includes('Invalid page') ||
|
|
e.message?.includes('Invalid page');
|
|
|
|
if (!isInvalidPage) {
|
|
errorNotification('Failed to retrieve series', e);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getAllContent(params = new URLSearchParams()) {
|
|
try {
|
|
console.log(
|
|
'Calling getAllContent with URL:',
|
|
`${host}/api/vod/all/?${params.toString()}`
|
|
);
|
|
const response = await request(
|
|
`${host}/api/vod/all/?${params.toString()}`
|
|
);
|
|
console.log('getAllContent raw response:', response);
|
|
return response;
|
|
} catch (e) {
|
|
console.error('getAllContent error:', e);
|
|
console.error('Error status:', e.status);
|
|
console.error('Error body:', e.body);
|
|
console.error('Error message:', e.message);
|
|
|
|
// Don't show error notification for "Invalid page" errors as they're handled gracefully
|
|
const isInvalidPage =
|
|
e.body?.detail?.includes('Invalid page') ||
|
|
e.message?.includes('Invalid page');
|
|
|
|
if (!isInvalidPage) {
|
|
errorNotification('Failed to retrieve content', e);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getMovieDetails(movieId) {
|
|
try {
|
|
const response = await request(`${host}/api/vod/movies/${movieId}/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve movie details', e);
|
|
}
|
|
}
|
|
|
|
static async getMovieProviderInfo(movieId, relationId = null) {
|
|
try {
|
|
const params = relationId ? `?relation_id=${relationId}` : '';
|
|
const response = await request(
|
|
`${host}/api/vod/movies/${movieId}/provider-info/${params}`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve movie provider info', e);
|
|
}
|
|
}
|
|
|
|
static async getMovieProviders(movieId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/vod/movies/${movieId}/providers/`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve movie providers', e);
|
|
}
|
|
}
|
|
|
|
static async getSeriesProviders(seriesId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/vod/series/${seriesId}/providers/`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve series providers', e);
|
|
}
|
|
}
|
|
|
|
static async getVODCategories() {
|
|
try {
|
|
const response = await request(`${host}/api/vod/categories/`);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve VOD categories', e);
|
|
}
|
|
}
|
|
|
|
static async getSeriesInfo(seriesId, relationId = null) {
|
|
try {
|
|
const params = new URLSearchParams({ include_episodes: 'true' });
|
|
if (relationId) params.set('relation_id', relationId);
|
|
const response = await request(
|
|
`${host}/api/vod/series/${seriesId}/provider-info/?${params}`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve series info', e);
|
|
}
|
|
}
|
|
|
|
static async getSystemEvents(limit = 100, offset = 0, eventType = null) {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('limit', limit);
|
|
params.append('offset', offset);
|
|
if (eventType) {
|
|
params.append('event_type', eventType);
|
|
}
|
|
const response = await request(
|
|
`${host}/api/core/system-events/?${params.toString()}`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve system events', e);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────
|
|
// System Notifications
|
|
// ─────────────────────────────
|
|
|
|
/**
|
|
* Get all active notifications for the current user
|
|
* @param {boolean} includeDismissed - Whether to include already dismissed notifications
|
|
*/
|
|
static async getNotifications(includeDismissed = false) {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (includeDismissed) {
|
|
params.append('include_dismissed', 'true');
|
|
}
|
|
const response = await request(
|
|
`${host}/api/core/notifications/?${params.toString()}`
|
|
);
|
|
|
|
// Update the store with fetched notifications
|
|
const { default: useNotificationsStore } =
|
|
await import('./store/notifications');
|
|
useNotificationsStore.getState().setNotifications(response.notifications);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve notifications', e);
|
|
}
|
|
}
|
|
|
|
// Get unread notification count
|
|
static async getNotificationCount() {
|
|
try {
|
|
const response = await request(`${host}/api/core/notifications/count/`);
|
|
|
|
// Update the store with the count
|
|
const { default: useNotificationsStore } =
|
|
await import('./store/notifications');
|
|
useNotificationsStore.getState().setUnreadCount(response.unread_count);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
// Silent fail for count - not critical
|
|
console.error('Failed to get notification count:', e);
|
|
return { unread_count: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dismiss a specific notification
|
|
* @param {number} notificationId - The notification ID to dismiss
|
|
* @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored')
|
|
*/
|
|
static async dismissNotification(notificationId, actionTaken = null) {
|
|
try {
|
|
const body = {};
|
|
if (actionTaken) {
|
|
body.action_taken = actionTaken;
|
|
}
|
|
|
|
const response = await request(
|
|
`${host}/api/core/notifications/${notificationId}/dismiss/`,
|
|
{
|
|
method: 'POST',
|
|
body,
|
|
}
|
|
);
|
|
|
|
// Update the store
|
|
const { default: useNotificationsStore } =
|
|
await import('./store/notifications');
|
|
useNotificationsStore
|
|
.getState()
|
|
.dismissNotification(response.notification_key);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to dismiss notification', e);
|
|
}
|
|
}
|
|
|
|
// Dismiss all notifications
|
|
static async dismissAllNotifications() {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/core/notifications/dismiss-all/`,
|
|
{
|
|
method: 'POST',
|
|
}
|
|
);
|
|
|
|
// Update the store
|
|
const { default: useNotificationsStore } =
|
|
await import('./store/notifications');
|
|
useNotificationsStore.getState().dismissAllNotifications();
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to dismiss all notifications', e);
|
|
}
|
|
}
|
|
|
|
static async getConnectIntegrations() {
|
|
try {
|
|
return await request(`${host}/api/connect/integrations/`);
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch connect integrations', e);
|
|
}
|
|
}
|
|
|
|
static async createConnectIntegration(values) {
|
|
try {
|
|
const response = await request(`${host}/api/connect/integrations/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
|
|
useConnectStore.getState().addIntegration(response);
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to create integration', e);
|
|
}
|
|
}
|
|
|
|
static async updateConnectIntegration(id, values) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/connect/integrations/${id}/`,
|
|
{
|
|
method: 'PUT',
|
|
body: values,
|
|
}
|
|
);
|
|
|
|
if (response.id) {
|
|
useConnectStore.getState().updateIntegration(response);
|
|
}
|
|
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update integration', e);
|
|
}
|
|
}
|
|
|
|
static async deleteConnectIntegration(id) {
|
|
try {
|
|
await request(`${host}/api/connect/integrations/${id}/`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
useConnectStore.getState().removeIntegration(id);
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to delete integration', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async createConnectSubscription(values) {
|
|
try {
|
|
await request(`${host}/api/connect/subscriptions/`, {
|
|
method: 'POST',
|
|
body: values,
|
|
});
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to create subscription', e);
|
|
}
|
|
}
|
|
|
|
static async listConnectSubscriptions(integrationId) {
|
|
try {
|
|
return await request(
|
|
`${host}/api/connect/integrations/${integrationId}/subscriptions/`
|
|
);
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch subscriptions', e);
|
|
}
|
|
}
|
|
|
|
static async setConnectSubscriptions(integrationId, subscriptions) {
|
|
// subscriptions: [{ event, enabled, payload_template }]
|
|
console.log(subscriptions);
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/connect/integrations/${integrationId}/subscriptions/set/`,
|
|
{
|
|
method: 'PUT',
|
|
body: subscriptions,
|
|
}
|
|
);
|
|
|
|
useConnectStore
|
|
.getState()
|
|
.updateIntegrationSubscriptions(integrationId, response);
|
|
|
|
return true;
|
|
} catch (e) {
|
|
errorNotification('Failed to set subscriptions', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
static async getConnectLogs(params = {}) {
|
|
try {
|
|
const search = new URLSearchParams();
|
|
if (params.page) search.set('page', params.page);
|
|
if (params.page_size) search.set('page_size', params.page_size);
|
|
if (params.type) search.set('type', params.type);
|
|
if (params.integration) search.set('integration', params.integration);
|
|
|
|
return await request(
|
|
`${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}`
|
|
);
|
|
} catch (e) {
|
|
errorNotification('Failed to fetch connect logs', e);
|
|
}
|
|
}
|
|
|
|
static async getSDLineups(sourceId) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/epg/sources/${sourceId}/sd-lineups/`
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to retrieve Schedules Direct lineups', e);
|
|
}
|
|
}
|
|
|
|
static async addSDLineup(sourceId, lineup) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
|
|
{
|
|
method: 'POST',
|
|
body: { lineup },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to add lineup ${lineup}`, e);
|
|
}
|
|
}
|
|
|
|
static async deleteSDLineup(sourceId, lineup) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
|
|
{
|
|
method: 'DELETE',
|
|
body: { lineup },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification(`Failed to remove lineup ${lineup}`, e);
|
|
}
|
|
}
|
|
|
|
static async updateSDSettings(sourceId, settings) {
|
|
try {
|
|
// Read current custom_properties from the store to merge, not replace
|
|
const epgs = useEPGsStore.getState().epgs;
|
|
const source = epgs[sourceId];
|
|
const cp = { ...(source?.custom_properties || {}), ...settings };
|
|
|
|
const response = await request(`${host}/api/epg/sources/${sourceId}/`, {
|
|
method: 'PATCH',
|
|
body: { custom_properties: cp },
|
|
});
|
|
|
|
useEPGsStore.getState().updateEPG(response);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to update Schedules Direct settings', e);
|
|
}
|
|
}
|
|
|
|
static async searchSDLineups(sourceId, country, postalcode) {
|
|
try {
|
|
const response = await request(
|
|
`${host}/api/epg/sources/${sourceId}/sd-lineups/search/`,
|
|
{
|
|
method: 'POST',
|
|
body: { country, postalcode },
|
|
}
|
|
);
|
|
return response;
|
|
} catch (e) {
|
|
errorNotification('Failed to search Schedules Direct lineups', e);
|
|
}
|
|
}
|
|
}
|