From 663ea2c5b9de0aac2a92f4af2bdd0f077a9505ff Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 13:22:08 -0400 Subject: [PATCH 1/6] soooo many changes.... --- apps/channels/api_views.py | 44 + apps/m3u/tasks.py | 51 +- core/api_views.py | 2 +- dispatcharr/urls.py | 17 +- docker/docker-compose.dev.yml | 23 + docker/uwsgi.ini | 5 +- frontend/src/App.jsx | 126 +- frontend/src/WebSocket.jsx | 41 +- frontend/src/api.js | 1607 ++++++++--------- .../src/components/M3URefreshNotification.jsx | 140 +- frontend/src/components/Sidebar.jsx | 22 +- frontend/src/components/forms/Channel.jsx | 8 +- frontend/src/components/forms/LoginForm.jsx | 12 +- .../src/components/tables/ChannelsTable.jsx | 26 +- frontend/src/components/tables/EPGsTable.jsx | 2 +- frontend/src/components/tables/M3UsTable.jsx | 74 +- .../components/tables/StreamProfilesTable.jsx | 9 +- .../src/components/tables/StreamsTable.jsx | 42 +- .../src/components/tables/UserAgentsTable.jsx | 28 +- .../src/pages/{M3U.jsx => ContentSources.jsx} | 16 +- frontend/src/pages/Guide.jsx | 211 ++- frontend/src/pages/Settings.jsx | 142 +- frontend/src/pages/Stats.jsx | 17 +- frontend/src/pages/guide.css | 16 +- frontend/src/store/playlists.jsx | 4 +- frontend/src/utils.js | 6 + 26 files changed, 1484 insertions(+), 1207 deletions(-) rename frontend/src/pages/{M3U.jsx => ContentSources.jsx} (69%) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 94686634..7ea7e3aa 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -122,10 +122,54 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 3) Channel Management (CRUD) # ───────────────────────────────────────────────────────── +class ChannelPagination(PageNumberPagination): + page_size = 25 # Default page size + page_size_query_param = 'page_size' # Allow clients to specify page size + max_page_size = 10000 # Prevent excessive page sizes + +class ChannelFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr='icontains') + channel_group_name = OrInFilter(field_name="channel_group__name", lookup_expr="icontains") + + class Meta: + model = Channel + fields = ['name', 'channel_group_name',] + class ChannelViewSet(viewsets.ModelViewSet): queryset = Channel.objects.all() serializer_class = ChannelSerializer permission_classes = [IsAuthenticated] + # pagination_class = ChannelPagination + + # filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + # filterset_class = ChannelFilter + # search_fields = ['name', 'channel_group__name'] + # ordering_fields = ['channel_number', 'name', 'channel_group__name'] + # ordering = ['-channel_number'] + + def get_queryset(self): + qs = super().get_queryset() + + channel_group = self.request.query_params.get('channel_group') + if channel_group: + group_names = channel_group.split(',') + qs = qs.filter(channel_group__name__in=group_names) + + return qs + + @action(detail=False, methods=['get'], url_path='ids') + def get_ids(self, request, *args, **kwargs): + # Get the filtered queryset + queryset = self.get_queryset() + + # Apply filtering, search, and ordering + queryset = self.filter_queryset(queryset) + + # Return only the IDs from the queryset + channel_ids = queryset.values_list('id', flat=True) + + # Return the response with the list of IDs + return Response(list(channel_ids)) @swagger_auto_schema( method='post', diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 6f4848fe..3529e1ef 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -40,10 +40,36 @@ def fetch_m3u_lines(account, use_cache=False): try: response = requests.get(account.server_url, headers=headers, stream=True) response.raise_for_status() + + total_size = int(response.headers.get('Content-Length', 0)) + downloaded = 0 + start_time = time.time() + last_update_time = start_time + with open(file_path, 'wb') as file: + send_m3u_update(account.id, "downloading", 0) for chunk in response.iter_content(chunk_size=8192): if chunk: file.write(chunk) + + downloaded += len(chunk) + elapsed_time = time.time() - start_time + + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 # in KB/s + + # Calculate progress percentage + progress = (downloaded / total_size) * 100 + + # Time remaining (in seconds) + time_remaining = (total_size - downloaded) / (speed * 1024) + + current_time = time.time() + if current_time - last_update_time >= 0.5: + last_update_time = current_time + send_m3u_update(account.id, "downloading", progress, speed=speed, elapsed_time=elapsed_time, time_remaining=time_remaining) + + send_m3u_update(account.id, "downloading", 100) except requests.exceptions.RequestException as e: logger.error(f"Error fetching M3U from URL {account.server_url}: {e}") return [] @@ -317,6 +343,8 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): # Associate URL with the last EXTINF line extinf_data[-1]["url"] = line + send_m3u_update(account_id, "processing_groups", 0) + groups = list(groups) cache_path = os.path.join(m3u_dir, f"{account_id}.json") with open(cache_path, 'w', encoding='utf-8') as f: @@ -329,6 +357,8 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): release_task_lock('refresh_m3u_account_groups', account_id) + send_m3u_update(account_id, "processing_groups", 100) + if not full_refresh: channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( @@ -350,7 +380,6 @@ def refresh_single_m3u_account(account_id): # redis_client = RedisClient.get_client() # Record start time start_time = time.time() - send_progress_update(0, account_id) try: account = M3UAccount.objects.get(id=account_id, is_active=True) @@ -415,7 +444,7 @@ def refresh_single_m3u_account(account_id): if progress == 100: progress = 99 - send_progress_update(progress, account_id) + send_m3u_update(account_id, "parsing", progress) # Optionally remove completed task from the group to prevent processing it again result.remove(async_result) @@ -424,7 +453,7 @@ def refresh_single_m3u_account(account_id): # Run cleanup cleanup_streams(account_id) - send_progress_update(100, account_id) + send_m3u_update(account_id, "parsing", 100) end_time = time.time() @@ -454,12 +483,24 @@ def refresh_single_m3u_account(account_id): return f"Dispatched jobs complete." -def send_progress_update(progress, account_id): +def send_m3u_update(account_id, action, progress, **kwargs): + # Start with the base data dictionary + data = { + "progress": progress, + "type": "m3u_refresh", + "account": account_id, + "action": action, + } + + # Add the additional key-value pairs from kwargs + data.update(kwargs) + + # Now, send the updated data dictionary channel_layer = get_channel_layer() async_to_sync(channel_layer.group_send)( 'updates', { 'type': 'update', - "data": {"progress": progress, "type": "m3u_refresh", "account": account_id} + 'data': data } ) diff --git a/core/api_views.py b/core/api_views.py index 1baa3793..d9c0aba4 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -99,4 +99,4 @@ def version(request): return Response({ 'version': __version__, 'build': __build__, - }) \ No newline at end of file + }) diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index f0de138e..4859c904 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.urls import path, include, re_path from django.conf import settings -from django.conf.urls.static import static +from django.conf.urls.static import static, serve from django.views.generic import TemplateView, RedirectView from rest_framework import permissions from drf_yasg.views import get_schema_view @@ -53,15 +53,12 @@ urlpatterns = [ # Optionally, serve the raw Swagger JSON path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'), - # Catch-all routes should always be last + # Catch-all React route (this is for the frontend routing to index.html) path('', TemplateView.as_view(template_name='index.html')), # React entry point - path('', TemplateView.as_view(template_name='index.html')), + # path('', TemplateView.as_view(template_name='index.html')), # Handle all non-API routes -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + # Serve static files dynamically + path('static/', serve, {'document_root': settings.STATIC_ROOT}), # This serves static files -urlpatterns += websocket_urlpatterns - -# Serve static files for development (React's JS, CSS, etc.) -if settings.DEBUG: - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # Serve static files in development +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 89abc535..3b6f53df 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -11,7 +11,30 @@ services: - 8001:8001 volumes: - ../:/app + # - ./data/db:/data environment: - DISPATCHARR_ENV=dev - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 + + pgadmin: + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: admin@admin.com + PGADMIN_DEFAULT_PASSWORD: admin + volumes: + - dispatcharr_dev_pgadmin:/var/lib/pgadmin + ports: + - 8082:80 + + redis-commander: + image: rediscommander/redis-commander:latest + environment: + - REDIS_HOSTS=dispatcharr:dispatcharr:6379:0 + - TRUST_PROXY=true + - ADDRESS=0.0.0.0 + ports: + - 8081:8081 + +volumes: + dispatcharr_dev_pgadmin: diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index e1da85a5..e014e030 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -25,9 +25,8 @@ die-on-term = true static-map = /static=/app/static # Worker management (Optimize for I/O bound tasks) -workers = 4 -threads = 2 -enable-threads = true +workers = 2 +enable-threads = false # Optimize for streaming http = 0.0.0.0:5656 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a641a53b..a57cd1b0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,13 +9,11 @@ import { import Sidebar from './components/Sidebar'; import Login from './pages/Login'; import Channels from './pages/Channels'; -import M3U from './pages/M3U'; -import EPG from './pages/EPG'; +import ContentSources from './pages/ContentSources'; import Guide from './pages/Guide'; import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; -import StreamProfiles from './pages/StreamProfiles'; import useAuthStore from './store/auth'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; @@ -87,73 +85,67 @@ const App = () => { withGlobalStyles withNormalizeCSS > - - - - - + + + - - - - - {isAuthenticated ? ( - <> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - - ) : ( - } /> - )} - - } - /> - - + + + + + {isAuthenticated ? ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + + ) : ( + } /> + )} + + } + /> + - - - - + + + + + + + - - + ); }; diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index afb6ce17..72533910 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -4,12 +4,15 @@ import React, { useRef, createContext, useContext, + useMemo, } from 'react'; import useStreamsStore from './store/streams'; import { notifications } from '@mantine/notifications'; import useChannelsStore from './store/channels'; import usePlaylistsStore from './store/playlists'; import useEPGsStore from './store/epgs'; +import { Box, Button, Stack } from '@mantine/core'; +import API from './api'; export const WebsocketContext = createContext(false, null, () => {}); @@ -23,6 +26,7 @@ export const WebsocketProvider = ({ children }) => { const { fetchPlaylists, setRefreshProgress, setProfilePreview } = usePlaylistsStore(); const { fetchEPGData, fetchEPGs } = useEPGsStore(); + const { playlists } = usePlaylistsStore(); const ws = useRef(null); @@ -79,27 +83,28 @@ export const WebsocketProvider = ({ children }) => { notifications.show({ title: 'Group processing finished!', - message: 'Refresh M3U or filter out groups to pull in streams.', + autoClose: 5000, + message: ( + + Refresh M3U or filter out groups to pull in streams. + + + ), color: 'green.5', }); break; case 'm3u_refresh': - if (event.data.success) { - fetchStreams(); - notifications.show({ - message: event.data.message, - color: 'green.5', - }); - } else if (event.data.progress !== undefined) { - if (event.data.progress == 100) { - fetchStreams(); - fetchChannelGroups(); - fetchEPGData(); - fetchPlaylists(); - } - setRefreshProgress(event.data.account, event.data.progress); - } + setRefreshProgress(event.data); break; case 'channel_stats': @@ -154,7 +159,9 @@ export const WebsocketProvider = ({ children }) => { }; }, []); - const ret = [isReady, ws.current?.send.bind(ws.current), val]; + const ret = useMemo(() => { + return [isReady, ws.current?.send.bind(ws.current), val]; + }, [isReady, val]); return ( diff --git a/frontend/src/api.js b/frontend/src/api.js index 5890d731..94183a10 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -7,12 +7,76 @@ import useEPGsStore from './store/epgs'; import useStreamsStore from './store/streams'; import useStreamProfilesStore from './store/streamProfiles'; import useSettingsStore from './store/settings'; +import { notifications } from '@mantine/notifications'; // 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) => { + message = + `${message}: ` + + (error.status ? `${error.status} - ${error.body}` : error.message); + + notifications.show({ + title: 'Error', + message, + autoClose: false, + 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 { /** * A static method so we can do: await API.getAuthToken() @@ -22,1136 +86,1051 @@ export default class API { } static async fetchSuperUser() { - const response = await fetch(`${host}/api/accounts/initialize-superuser/`); - return await response.json(); + try { + const response = await request( + `${host}/api/accounts/initialize-superuser/`, + { auth: false } + ); + + return response; + } catch (e) { + errorNotification('Failed to fetch superuser', e); + } } static async createSuperUser({ username, email, password }) { - const response = await fetch(`${host}/api/accounts/initialize-superuser/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username, - password, - email, - }), - }); + try { + const response = await request( + `${host}/api/accounts/initialize-superuser/`, + { + auth: false, + method: 'POST', + body: { + username, + password, + email, + }, + } + ); - return await response.json(); + return response; + } catch (e) { + errorNotification('Failed to create superuser', e); + } } static async login(username, password) { - const response = await fetch(`${host}/api/accounts/token/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ username, password }), - }); + try { + const response = await request(`${host}/api/accounts/token/`, { + auth: false, + method: 'POST', + body: { username, password }, + }); - return await response.json(); + return response; + } catch (e) { + errorNotification('Login failed', e); + } } static async refreshToken(refresh) { - const response = await fetch(`${host}/api/accounts/token/refresh/`, { + return await request(`${host}/api/accounts/token/refresh/`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh }), + body: { auth: false, refresh }, }); - - const retval = await response.json(); - return retval; } static async logout() { - const response = await fetch(`${host}/api/accounts/auth/logout/`, { + return await request(`${host}/api/accounts/auth/logout/`, { + auth: false, method: 'POST', }); - - return response.data.data; } static async getChannels() { - const response = await fetch(`${host}/api/channels/channels/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/channels/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve channels', e); + } + } + + static async queryChannels(params) { + try { + const response = await request( + `${host}/api/channels/channels/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to fetch channels', e); + } + } + + static async getAllChannelIds(params) { + try { + const response = await request( + `${host}/api/channels/channels/ids/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to fetch channel IDs', e); + } } static async getChannelGroups() { - const response = await fetch(`${host}/api/channels/groups/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/groups/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve channel groups', e); + } } static async addChannelGroup(values) { - const response = await fetch(`${host}/api/channels/groups/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/groups/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addChannelGroup(retval); + if (response.id) { + useChannelsStore.getState().addChannelGroup(response); + } + + return response; + } catch (e) { + errorNotification('Failed to create channel group', e); } - - return retval; } static async updateChannelGroup(values) { - const { id, ...payload } = values; - const response = await fetch(`${host}/api/channels/groups/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/channels/groups/${id}/`, { + method: 'PUT', + body: payload, + }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().updateChannelGroup(retval); + if (response.id) { + useChannelsStore.getState().updateChannelGroup(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update channel group', e); } - - return retval; } static async addChannel(channel) { - let body = null; - if (channel.logo_file) { - // Must send FormData for file upload - body = new FormData(); - for (const prop in channel) { - body.append(prop, channel[prop]); + try { + let body = null; + if (channel.logo_file) { + // Must send FormData for file upload + body = new FormData(); + for (const prop in channel) { + body.append(prop, channel[prop]); + } + } else { + body = { ...channel }; + delete body.logo_file; } - } else { - body = { ...channel }; - delete body.logo_file; - body = JSON.stringify(body); + + const response = await request(`${host}/api/channels/channels/`, { + method: 'POST', + body: body, + }); + + if (response.id) { + useChannelsStore.getState().addChannel(response); + } + + return response; + } catch (e) { + errorNotification('Failed to create channel', e); } - - const response = await fetch(`${host}/api/channels/channels/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(channel.logo_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body: body, - }); - - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addChannel(retval); - } - - return retval; } static async deleteChannel(id) { - const response = await fetch(`${host}/api/channels/channels/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/channels/${id}/`, { + method: 'DELETE', + }); - useChannelsStore.getState().removeChannels([id]); + useChannelsStore.getState().removeChannels([id]); + } catch (e) { + errorNotification('Failed to delete channel', e); + } } // @TODO: the bulk delete endpoint is currently broken static async deleteChannels(channel_ids) { - const response = await fetch(`${host}/api/channels/channels/bulk-delete/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ channel_ids }), - }); + try { + await request(`${host}/api/channels/channels/bulk-delete/`, { + method: 'DELETE', + body: { channel_ids }, + }); - useChannelsStore.getState().removeChannels(channel_ids); + useChannelsStore.getState().removeChannels(channel_ids); + } catch (e) { + errorNotification('Failed to delete channels', e); + } } static async updateChannel(values) { - const { id, ...payload } = values; + try { + const { id, ...payload } = values; - let body = null; - if (values.logo_file) { - // Must send FormData for file upload - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); + let body = null; + if (payload.logo_file) { + // Must send FormData for file upload + body = new FormData(); + for (const prop in payload) { + body.append(prop, payload[prop]); + } + } else { + body = { ...payload }; + delete body.logo_file; } - } else { - body = { ...values }; - delete body.logo_file; - body = JSON.stringify(body); + + const response = await request(`${host}/api/channels/channels/${id}/`, { + method: 'PUT', + body, + }); + + if (response.id) { + useChannelsStore.getState().updateChannel(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update channel', e); } - - console.log(body); - - const response = await fetch(`${host}/api/channels/channels/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.logo_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body: body, - }); - - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().updateChannel(retval); - } - - return retval; } static async assignChannelNumbers(channelIds) { - // Make the request - const response = await fetch(`${host}/api/channels/channels/assign/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ channel_order: channelIds }), - }); + try { + const response = await request(`${host}/api/channels/channels/assign/`, { + method: 'POST', + body: { channel_order: channelIds }, + }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Assign channels failed: ${response.status} => ${text}`); + // Optionally refesh the channel list in Zustand + await useChannelsStore.getState().fetchChannels(); + + return response; + } catch (e) { + errorNotification('Failed to assign channel #s', e); } - - const retval = await response.json(); - - // Optionally refresh the channel list in Zustand - await useChannelsStore.getState().fetchChannels(); - - return retval; } static async createChannelFromStream(values) { - const response = await fetch(`${host}/api/channels/channels/from-stream/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request( + `${host}/api/channels/channels/from-stream/`, + { + method: 'POST', + body: values, + } + ); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addChannel(retval); + if (response.id) { + useChannelsStore.getState().addChannel(response); + } + + return response; + } catch (e) { + errorNotification('Failed to create channel', e); } - - return retval; } static async createChannelsFromStreams(values) { - const response = await fetch( - `${host}/api/channels/channels/from-stream/bulk/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), + try { + const response = await request( + `${host}/api/channels/channels/from-stream/bulk/`, + { + method: 'POST', + body: values, + } + ); + + if (response.created.length > 0) { + useChannelsStore.getState().addChannels(response.created); } - ); - const retval = await response.json(); - if (retval.created.length > 0) { - useChannelsStore.getState().addChannels(retval.created); + return response; + } catch (e) { + errorNotification('Failed to create channels', e); } - - return retval; } static async getStreams() { - const response = await fetch(`${host}/api/channels/streams/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/streams/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve streams', e); + } } static async queryStreams(params) { - const response = await fetch( - `${host}/api/channels/streams/?${params.toString()}`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - } - ); + try { + const response = await request( + `${host}/api/channels/streams/?${params.toString()}` + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to fetch streams', e); + } } static async getAllStreamIds(params) { - const response = await fetch( - `${host}/api/channels/streams/ids/?${params.toString()}`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - } - ); + try { + const response = await request( + `${host}/api/channels/streams/ids/?${params.toString()}` + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to fetch stream IDs', e); + } } static async getStreamGroups() { - const response = await fetch(`${host}/api/channels/streams/groups/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/streams/groups/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve stream groups', e); + } } static async addStream(values) { - const response = await fetch(`${host}/api/channels/streams/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/streams/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useStreamsStore.getState().addStream(retval); + if (response.id) { + useStreamsStore.getState().addStream(response); + } + + return response; + } catch (e) { + errorNotification('Failed to add stream', e); } - - return retval; } static async updateStream(values) { - const { id, ...payload } = values; - const response = await fetch(`${host}/api/channels/streams/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/channels/streams/${id}/`, { + method: 'PUT', + body: payload, + }); - const retval = await response.json(); - if (retval.id) { - useStreamsStore.getState().updateStream(retval); + if (response.id) { + useStreamsStore.getState().updateStream(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update stream', e); } - - return retval; } static async deleteStream(id) { - const response = await fetch(`${host}/api/channels/streams/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/streams/${id}/`, { + method: 'DELETE', + }); - useStreamsStore.getState().removeStreams([id]); + useStreamsStore.getState().removeStreams([id]); + } catch (e) { + errorNotification('Failed to delete stream', e); + } } static async deleteStreams(ids) { - const response = await fetch(`${host}/api/channels/streams/bulk-delete/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ stream_ids: ids }), - }); + try { + await request(`${host}/api/channels/streams/bulk-delete/`, { + method: 'DELETE', + body: { stream_ids: ids }, + }); - useStreamsStore.getState().removeStreams(ids); + useStreamsStore.getState().removeStreams(ids); + } catch (e) { + errorNotification('Failed to delete streams', e); + } } static async getUserAgents() { - const response = await fetch(`${host}/api/core/useragents/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/core/useragents/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve user-agents', e); + } } static async addUserAgent(values) { - const response = await fetch(`${host}/api/core/useragents/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/core/useragents/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useUserAgentsStore.getState().addUserAgent(retval); + useUserAgentsStore.getState().addUserAgent(response); + + return response; + } catch (e) { + errorNotification('Failed to create user-agent', e); } - - return retval; } static async updateUserAgent(values) { - const { id, ...payload } = values; - const response = await fetch(`${host}/api/core/useragents/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + try { + const { id, ...payload } = values; + const response = await request(`${host}/api/core/useragents/${id}/`, { + method: 'PUT', + body: payload, + }); - const retval = await response.json(); - if (retval.id) { - useUserAgentsStore.getState().updateUserAgent(retval); + useUserAgentsStore.getState().updateUserAgent(response); + + return response; + } catch (e) { + errorNotification('Failed to update user-agent', e); } - - return retval; } static async deleteUserAgent(id) { - const response = await fetch(`${host}/api/core/useragents/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/core/useragents/${id}/`, { + method: 'DELETE', + }); - useUserAgentsStore.getState().removeUserAgents([id]); + useUserAgentsStore.getState().removeUserAgents([id]); + } catch (e) { + errorNotification('Failed to delete user-agent', e); + } } static async getPlaylist(id) { - const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/accounts/${id}/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification(`Failed to retrieve M3U account ${id}`, e); + } } static async getPlaylists() { - const response = await fetch(`${host}/api/m3u/accounts/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/accounts/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve M3U accounts', e); + } } static async addPlaylist(values) { - let body = null; - if (values.file) { - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); + try { + let body = null; + if (values.file) { + body = new FormData(); + for (const prop in values) { + body.append(prop, values[prop]); + } + } else { + body = { ...values }; + delete body.file; } - } else { - body = { ...values }; - delete body.file; - body = JSON.stringify(body); + + 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); } - - const response = await fetch(`${host}/api/m3u/accounts/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - usePlaylistsStore.getState().addPlaylist(retval); - } - - return retval; } static async refreshPlaylist(id) { - const response = await fetch(`${host}/api/m3u/refresh/${id}/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/refresh/${id}/`, { + method: 'POST', + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to refresh M3U account', e); + } } static async refreshAllPlaylist() { - const response = await fetch(`${host}/api/m3u/refresh/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/m3u/refresh/`, { + method: 'POST', + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to refresh all M3U accounts', e); + } } static async deletePlaylist(id) { - const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/m3u/accounts/${id}/`, { + method: 'DELETE', + }); - usePlaylistsStore.getState().removePlaylists([id]); - // @TODO: MIGHT need to optimize this later if someone has thousands of channels - // but I'm feeling laze right now - useChannelsStore.getState().fetchChannels(); + usePlaylistsStore.getState().removePlaylists([id]); + // @TODO: MIGHT need to optimize this later if someone has thousands of channels + // but I'm feeling laze right now + useChannelsStore.getState().fetchChannels(); + } catch (e) { + errorNotification(`Failed to delete playlist ${id}`, e); + } } static async updatePlaylist(values) { const { id, ...payload } = values; - let body = null; - if (payload.file) { - delete payload.server_url; + try { + let body = null; + if (payload.file) { + delete payload.server_url; - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); - } - } else { - delete payload.file; - if (!payload.server_url) { - delete payload.sever_url; + body = new FormData(); + for (const prop in values) { + body.append(prop, values[prop]); + } + } else { + delete payload.file; + if (!payload.server_url) { + delete payload.sever_url; + } + + body = { ...payload }; + delete body.file; } - body = { ...payload }; - delete body.file; - body = JSON.stringify(body); + 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); } - - const response = await fetch(`${host}/api/m3u/accounts/${id}/`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - usePlaylistsStore.getState().updatePlaylist(retval); - } - - return retval; } static async getEPGs() { - const response = await fetch(`${host}/api/epg/sources/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/epg/sources/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve EPGs', e); + } } static async getEPGData() { - const response = await fetch(`${host}/api/epg/epgdata/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/epg/epgdata/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve EPG data', e); + } } // Notice there's a duplicated "refreshPlaylist" method above; // you might want to rename or remove one if it's not needed. static async addEPG(values) { - let body = null; - if (values.files) { - body = new FormData(); - for (const prop in values) { - body.append(prop, values[prop]); + 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; } - } else { - body = { ...values }; - delete body.file; - body = JSON.stringify(body); + + 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); } - - const response = await fetch(`${host}/api/epg/sources/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.epg_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - useEPGsStore.getState().addEPG(retval); - } - - return retval; } static async updateEPG(values) { const { id, ...payload } = values; - let body = null; - if (payload.files) { - body = new FormData(); - for (const prop in payload) { - if (prop == 'url') { - continue; + try { + 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.append(prop, payload[prop]); } - } else { - delete payload.file; - if (!payload.url) { - delete payload.url; - } - body = JSON.stringify(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); } - - const response = await fetch(`${host}/api/epg/sources/${id}/`, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - ...(values.epg_file - ? {} - : { - 'Content-Type': 'application/json', - }), - }, - body, - }); - - const retval = await response.json(); - if (retval.id) { - useEPGsStore.getState().updateEPG(retval); - } - - return retval; } static async deleteEPG(id) { - const response = await fetch(`${host}/api/epg/sources/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/epg/sources/${id}/`, { + method: 'DELETE', + }); - useEPGsStore.getState().removeEPGs([id]); + useEPGsStore.getState().removeEPGs([id]); + } catch (e) { + errorNotification(`Failed to delete EPG ${id}`, e); + } } static async refreshEPG(id) { - const response = await fetch(`${host}/api/epg/import/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id }), - }); + try { + const response = await request(`${host}/api/epg/import/`, { + method: 'POST', + body: { id }, + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification(`Failed to refresh EPG ${id}`, e); + } } static async getStreamProfiles() { - const response = await fetch(`${host}/api/core/streamprofiles/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/core/streamprofiles/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve sream profiles', e); + } } static async addStreamProfile(values) { - const response = await fetch(`${host}/api/core/streamprofiles/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/core/streamprofiles/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useStreamProfilesStore.getState().addStreamProfile(retval); + useStreamProfilesStore.getState().addStreamProfile(response); + + return response; + } catch (e) { + errorNotification('Failed to create stream profile', e); } - return retval; } static async updateStreamProfile(values) { const { id, ...payload } = values; - const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - const retval = await response.json(); - if (retval.id) { - useStreamProfilesStore.getState().updateStreamProfile(retval); + 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); } - - return retval; } static async deleteStreamProfile(id) { - const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/core/streamprofiles/${id}/`, { + method: 'DELETE', + }); - useStreamProfilesStore.getState().removeStreamProfiles([id]); + useStreamProfilesStore.getState().removeStreamProfiles([id]); + } catch (e) { + errorNotification(`Failed to delete stream propfile ${id}`, e); + } } static async getGrid() { - const response = await fetch(`${host}/api/epg/grid/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/epg/grid/`); - const retval = await response.json(); - return retval.data; + return response.data; + } catch (e) { + errorNotification('Failed to retrieve program grid', e); + } } static async addM3UProfile(accountId, values) { - const response = await fetch( - `${host}/api/m3u/accounts/${accountId}/profiles/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - } - ); + try { + const response = await request( + `${host}/api/m3u/accounts/${accountId}/profiles/`, + { + method: 'POST', + body: values, + } + ); - const retval = await response.json(); - if (retval.id) { // Refresh the playlist const playlist = await API.getPlaylist(accountId); usePlaylistsStore .getState() .updateProfiles(playlist.id, playlist.profiles); - } - return retval; + return response; + } catch (e) { + errorNotification(`Failed to add profile to account ${accountId}`, e); + } } static async deleteM3UProfile(accountId, id) { - const response = await fetch( - `${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, - { + try { + await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, { method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - } - ); + }); - const playlist = await API.getPlaylist(accountId); - usePlaylistsStore.getState().updatePlaylist(playlist); + 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; - const response = await fetch( - `${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - } - ); - const playlist = await API.getPlaylist(accountId); - usePlaylistsStore.getState().updateProfiles(playlist.id, playlist.profiles); + try { + await request(`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`, { + method: 'PUT', + body: payload, + }); + + const playlist = await API.getPlaylist(accountId); + usePlaylistsStore + .getState() + .updateProfiles(playlist.id, playlist.profiles); + } catch (e) { + errorNotification(`Failed to update profile for account ${accountId}`, e); + } } static async getSettings() { - const response = await fetch(`${host}/api/core/settings/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/core/settings/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve settings', e); + } } static async getEnvironmentSettings() { - const response = await fetch(`${host}/api/core/settings/env/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/core/settings/env/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve environment settings', e); + } } static async getVersion() { - const response = await fetch(`${host}/api/core/version/`, { - headers: { - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/core/version/`, { + auth: false, + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve version', e); + } } static async updateSetting(values) { const { id, ...payload } = values; - const response = await fetch(`${host}/api/core/settings/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - const retval = await response.json(); - if (retval.id) { - useSettingsStore.getState().updateSetting(retval); + 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); } - - return retval; } static async getChannelStats(uuid = null) { - const response = await fetch(`${host}/proxy/ts/status`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/proxy/ts/status`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve channel stats', e); + } } static async stopChannel(id) { - const response = await fetch(`${host}/proxy/ts/stop/${id}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/proxy/ts/stop/${id}`, { + method: 'POST', + }); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to stop channel', e); + } } static async stopClient(channelId, clientId) { - const response = await fetch(`${host}/proxy/ts/stop_client/${channelId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - body: JSON.stringify({ client_id: clientId }), - }); + try { + const response = await request( + `${host}/proxy/ts/stop_client/${channelId}`, + { + method: 'POST', + body: { client_id: clientId }, + } + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to stop client', e); + } } static async matchEpg() { - const response = await fetch(`${host}/api/channels/channels/match-epg/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request( + `${host}/api/channels/channels/match-epg/`, + { + method: 'POST', + } + ); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to run EPG auto-match', e); + } } static async getLogos() { - const response = await fetch(`${host}/api/channels/logos/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/logos/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve logos', e); + } } static async uploadLogo(file) { - const formData = new FormData(); - formData.append('file', file); + try { + const formData = new FormData(); + formData.append('file', file); - const response = await fetch(`${host}/api/channels/logos/upload/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - body: formData, - }); + const response = await request(`${host}/api/channels/logos/upload/`, { + method: 'POST', + body: formData, + }); - const retval = await response.json(); + useChannelsStore.getState().addLogo(response); - useChannelsStore.getState().addLogo(retval); - - return retval; + return response; + } catch (e) { + errorNotification('Failed to upload logo', e); + } } static async getChannelProfiles() { - const response = await fetch(`${host}/api/channels/profiles/`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); + try { + const response = await request(`${host}/api/channels/profiles/`); - const retval = await response.json(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to get channel profiles', e); + } } static async addChannelProfile(values) { - const response = await fetch(`${host}/api/channels/profiles/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/profiles/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().addProfile(retval); + useChannelsStore.getState().addProfile(response); + + return response; + } catch (e) { + errorNotification('Failed to create channle profile', e); } - - return retval; } static async updateChannelProfile(values) { const { id, ...payload } = values; - const response = await fetch(`${host}/api/channels/profiles/${id}/`, { - method: 'PUT', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - const retval = await response.json(); - if (retval.id) { - useChannelsStore.getState().updateProfile(retval); + 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); } - - return retval; } static async deleteChannelProfile(id) { - const response = await fetch(`${host}/api/channels/profiles/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/profiles/${id}/`, { + method: 'DELETE', + }); - useChannelsStore.getState().removeProfiles([id]); + useChannelsStore.getState().removeProfiles([id]); + } catch (e) { + errorNotification(`Failed to delete channel profile ${id}`, e); + } } static async updateProfileChannel(channelId, profileId, enabled) { - const response = await fetch( - `${host}/api/channels/profiles/${profileId}/channels/${channelId}/`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ enabled }), - } - ); + try { + await request( + `${host}/api/channels/profiles/${profileId}/channels/${channelId}/`, + { + method: 'PATCH', + body: { enabled }, + } + ); - useChannelsStore - .getState() - .updateProfileChannels([channelId], profileId, enabled); + useChannelsStore + .getState() + .updateProfileChannels([channelId], profileId, enabled); + } catch (e) { + errorNotification(`Failed to update channel for profile ${profileId}`, e); + } } static async updateProfileChannels(channelIds, profileId, enabled) { - const response = await fetch( - `${host}/api/channels/profiles/${profileId}/channels/bulk-update/`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channels: channelIds.map((id) => ({ - channel_id: id, - 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); + useChannelsStore + .getState() + .updateProfileChannels(channelIds, profileId, enabled); + } catch (e) { + errorNotification( + `Failed to bulk update channels for profile ${profileId}`, + e + ); + } } static async getRecordings() { - const response = await fetch(`${host}/api/channels/recordings/`, { - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + const response = await request(`${host}/api/channels/recordings/`); - const retval = await response.json(); - - return retval; + return response; + } catch (e) { + errorNotification('Failed to retrieve recordings', e); + } } static async createRecording(values) { - const response = await fetch(`${host}/api/channels/recordings/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(values), - }); + try { + const response = await request(`${host}/api/channels/recordings/`, { + method: 'POST', + body: values, + }); - const retval = await response.json(); - useChannelsStore.getState().fetchRecordings(); + useChannelsStore.getState().fetchRecordings(); - return retval; + return response; + } catch (e) { + errorNotification('Failed to create recording', e); + } } static async deleteRecording(id) { - const response = await fetch(`${host}/api/channels/recordings/${id}/`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${await API.getAuthToken()}`, - 'Content-Type': 'application/json', - }, - }); + try { + await request(`${host}/api/channels/recordings/${id}/`, { + method: 'DELETE', + }); - useChannelsStore.getState().fetchRecordings(); + useChannelsStore.getState().fetchRecordings(); + } catch (e) { + errorNotification(`Failed to delete recording ${id}`, e); + } } } diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 71894c3a..90123dc4 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -1,81 +1,85 @@ // frontend/src/components/FloatingVideo.js -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import usePlaylistsStore from '../store/playlists'; import { notifications } from '@mantine/notifications'; import { IconCheck } from '@tabler/icons-react'; +import useStreamsStore from '../store/streams'; +import useChannelsStore from '../store/channels'; +import useEPGsStore from '../store/epgs'; export default function M3URefreshNotification() { - const { playlists, refreshProgress, removeRefreshProgress } = - usePlaylistsStore(); - const [progress, setProgress] = useState({}); + const { playlists, refreshProgress } = usePlaylistsStore(); + const { fetchStreams } = useStreamsStore(); + const { fetchChannelGroups } = useChannelsStore(); + const { fetchPlaylists } = usePlaylistsStore(); + const { fetchEPGData } = useEPGsStore(); - const clearAccountNotification = (id) => { - removeRefreshProgress(id); - setProgress({ - ...progress, - [id]: null, + const [notificationStatus, setNotificationStatus] = useState({}); + + const handleM3UUpdate = (data) => { + if ( + JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data) + ) { + return; + } + + console.log(data); + const playlist = playlists.find((pl) => pl.id == data.account); + + setNotificationStatus({ + ...notificationStatus, + [data.account]: data, + }); + + const taskProgress = data.progress; + + if (data.progress != 0 && data.progress != 100) { + console.log('not 0 or 100'); + return; + } + + let message = ''; + switch (data.action) { + case 'downloading': + message = 'Downloading'; + break; + + case 'parsing': + message = 'Stream parsing'; + break; + + case 'processing_groups': + message = 'Group parsing'; + break; + } + + if (taskProgress == 0) { + message = `${message} starting...`; + } else if (taskProgress == 100) { + message = `${message} complete!`; + + if (data.action == 'parsing') { + fetchStreams(); + } else if (data.action == 'processing_groups') { + fetchStreams(); + fetchChannelGroups(); + fetchEPGData(); + fetchPlaylists(); + } + } + + notifications.show({ + title: `M3U Processing: ${playlist.name}`, + message, + loading: taskProgress == 0, + autoClose: 2000, + icon: taskProgress == 100 ? : null, }); }; - for (const id in refreshProgress) { - const playlist = playlists.find((pl) => pl.id == id); - if (!progress[id]) { - if (refreshProgress[id] == 100) { - // This situation is if it refreshes so fast we only get the 100% complete notification - const notificationId = notifications.show({ - loading: false, - title: `M3U Refresh: ${playlist.name}`, - message: `Refresh complete!`, - icon: , - }); - setProgress({ - ...progress, - [id]: notificationId, - }); - setTimeout(() => clearAccountNotification(id), 2000); - - return; - } - - const notificationId = notifications.show({ - loading: true, - title: `M3U Refresh`, - message: `Starting...`, - autoClose: false, - withCloseButton: false, - }); - - setProgress({ - ...progress, - ...(playlist && { - title: `M3U Refresh: ${playlist.name}`, - }), - [id]: notificationId, - }); - } else { - if (refreshProgress[id] == 0) { - notifications.update({ - id: progress[id], - message: `Starting...`, - }); - } else if (refreshProgress[id] == 100) { - notifications.update({ - id: progress[id], - message: `Refresh complete!`, - loading: false, - autoClose: 2000, - icon: , - }); - - setTimeout(() => clearAccountNotification(id), 2000); - } else { - notifications.update({ - id: progress[id], - message: `Updating M3U: ${refreshProgress[id]}%`, - }); - } - } - } + useEffect(() => { + Object.values(refreshProgress).map((data) => handleM3UUpdate(data)); + }, [playlists, refreshProgress]); return <>; } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index a1ca5601..ea33f8b8 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -71,16 +71,17 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { // Fetch environment settings including version on component mount useEffect(() => { + if (!isAuthenticated) { + return; + } + const fetchEnvironment = async () => { - try { - const envData = await API.getEnvironmentSettings(); - } catch (error) { - console.error('Failed to fetch environment settings:', error); - } + API.getEnvironmentSettings(); }; fetchEnvironment(); - }, []); + }, [isAuthenticated]); + // Fetch version information on component mount (regardless of authentication) useEffect(() => { const fetchVersion = async () => { @@ -88,7 +89,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const versionData = await API.getVersion(); setAppVersion({ version: versionData.version || '', - build: versionData.build || '' + build: versionData.build || '', }); } catch (error) { console.error('Failed to fetch version information:', error); @@ -223,7 +224,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {environment.country_name ) } @@ -263,7 +266,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {/* Version is always shown when sidebar is expanded, regardless of auth status */} {!collapsed && ( - v{appVersion?.version || '0.0.0'}{appVersion?.build !== '0' ? `-${appVersion?.build}` : ''} + v{appVersion?.version || '0.0.0'} + {appVersion?.build !== '0' ? `-${appVersion?.build}` : ''} )} diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index c063360b..a3cfe992 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -487,7 +487,13 @@ const Channel = ({ channel = null, isOpen, onClose }) => { label={ EPG - diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index 8eb8c183..2cc40988 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -1,17 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; -import { - Paper, - Title, - TextInput, - Button, - Checkbox, - Modal, - Box, - Center, - Stack, -} from '@mantine/core'; +import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core'; const LoginForm = () => { const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index d2520cf7..508ecea2 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -399,6 +399,12 @@ const ChannelsTable = ({}) => { size: 50, maxSize: 50, accessorKey: 'channel_number', + sortingFn: (a, b, columnId) => { + return ( + parseInt(a.original.channel_number) - + parseInt(b.original.channel_number) + ); + }, mantineTableHeadCellProps: { align: 'right', // // style: { @@ -469,11 +475,11 @@ const ChannelsTable = ({}) => { placeholder="Group" searchable size="xs" - nothingFound="No options" - // onChange={(e, value) => { - // e.stopPropagation(); - // handleGroupChange(value); - // }} + nothingFoundMessage="No options" + onChange={(value) => { + // e.stopPropagation(); + handleFilterChange(column.id, value); + }} data={channelGroupOptions} variant="unstyled" className="table-input-header" @@ -485,16 +491,15 @@ const ChannelsTable = ({}) => { header: '', accessorKey: 'logo', enableSorting: false, - size: 55, + size: 75, mantineTableBodyCellProps: { align: 'center', style: { - maxWidth: '55px', + maxWidth: '75px', }, }, Cell: ({ cell }) => ( { }; const deleteChannel = async (id) => { + setRowSelection([]); if (channelsPageSelection.length > 0) { return deleteChannels(); } @@ -747,10 +753,6 @@ const ChannelsTable = ({}) => { id: 'channel_number', desc: false, }, - { - id: 'name', - desc: false, - }, ], }, enableRowActions: true, diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 546d6a02..31b71077 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -149,7 +149,7 @@ const EPGsTable = () => { ), mantineTableContainerProps: { style: { - height: 'calc(40vh - 0px)', + height: 'calc(40vh - 10px)', }, }, displayColumnDefOptions: { diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 95ba9e93..c4bd273a 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -26,10 +26,64 @@ const M3UTable = () => { const [activeFilterValue, setActiveFilterValue] = useState('all'); const [playlistCreated, setPlaylistCreated] = useState(false); - const { playlists, setRefreshProgress } = usePlaylistsStore(); + const { playlists, refreshProgress, setRefreshProgress } = + usePlaylistsStore(); const theme = useMantineTheme(); + const generateStatusString = (data) => { + if (data.progress == 100) { + return 'Idle'; + } + + switch (data.action) { + case 'downloading': + return buildDownloadingStats(data); + + case 'processing_groups': + return 'Processing groups...'; + + default: + return buildParsingStats(data); + } + }; + + const buildDownloadingStats = (data) => { + if (data.progress == 100) { + // fetchChannelGroups(); + // fetchPlaylists(); + return 'Download complete!'; + } + + if (data.progress == 0) { + return 'Downloading...'; + } + + return ( + + Downloading: {parseInt(data.progress)}% + {/* Speed: {parseInt(data.speed)} KB/s + Time Remaining: {parseInt(data.time_remaining)} */} + + ); + }; + + const buildParsingStats = (data) => { + if (data.progress == 100) { + // fetchStreams(); + // fetchChannelGroups(); + // fetchEPGData(); + // fetchPlaylists(); + return 'Parsing complete!'; + } + + if (data.progress == 0) { + return 'Parsing...'; + } + + return `Parsing: ${data.progress}%`; + }; + const columns = useMemo( //column definitions... () => [ @@ -57,6 +111,20 @@ const M3UTable = () => { accessorKey: 'max_streams', size: 200, }, + { + header: 'Status', + accessorFn: (row) => { + if (!row.id) { + return ''; + } + if (!refreshProgress[row.id]) { + return 'Idle'; + } + + return generateStatusString(refreshProgress[row.id]); + }, + size: 200, + }, { header: 'Active', accessorKey: 'is_active', @@ -77,7 +145,7 @@ const M3UTable = () => { enableSorting: false, }, ], - [] + [refreshProgress] ); //optionally access the underlying virtualizer instance @@ -190,7 +258,7 @@ const M3UTable = () => { ), mantineTableContainerProps: { style: { - height: 'calc(40vh - 0px)', + height: 'calc(40vh - 10px)', }, }, }); diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index d6706239..2f5a6f9b 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -17,6 +17,7 @@ import { useMantineTheme, Center, Switch, + Stack, } from '@mantine/core'; import { IconSquarePlus } from '@tabler/icons-react'; import { SquareMinus, SquarePen, Check, X, Eye, EyeOff } from 'lucide-react'; @@ -212,19 +213,19 @@ const StreamProfiles = () => { ), mantineTableContainerProps: { style: { - height: 'calc(100vh - 120px)', + height: 'calc(60vh - 100px)', overflowY: 'auto', }, }, }); return ( - + @@ -305,7 +306,7 @@ const StreamProfiles = () => { isOpen={profileModalOpen} onClose={closeStreamProfileForm} /> - + ); }; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 4cfecab0..ab0f4c2e 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -140,16 +140,13 @@ const StreamsTable = ({}) => { placeholder="Group" searchable size="xs" - nothingFound="No options" + nothingFoundMessage="No options" onClick={handleSelectClick} onChange={handleGroupChange} data={groupOptions} variant="unstyled" className="table-input-header custom-multiselect" clearable - valueComponent={({ value }) => { - return
foo
; // Override to display custom text - }} /> ), @@ -176,7 +173,7 @@ const StreamsTable = ({}) => { placeholder="M3U" searchable size="xs" - nothingFound="No options" + nothingFoundMessage="No options" onClick={handleSelectClick} onChange={handleM3UChange} data={playlists.map((playlist) => ({ @@ -568,24 +565,47 @@ const StreamsTable = ({}) => { }, displayColumnDefOptions: { 'mrt-row-actions': { - size: 30, mantineTableHeadCellProps: { + align: 'left', style: { - paddingLeft: 4, - backgroundColor: '#3F3F46', + minWidth: '65px', + maxWidth: '65px', + paddingLeft: 10, fontWeight: 'normal', color: 'rgb(207,207,207)', + backgroundColor: '#3F3F46', }, }, mantineTableBodyCellProps: { style: { - paddingLeft: 0, - paddingRight: 0, + minWidth: '65px', + maxWidth: '65px', + // paddingLeft: 0, + // paddingRight: 10, }, }, }, 'mrt-row-select': { - size: 20, + size: 10, + maxSize: 10, + mantineTableHeadCellProps: { + align: 'right', + style: { + paddding: 0, + // paddingLeft: 7, + width: '20px', + minWidth: '20px', + backgroundColor: '#3F3F46', + }, + }, + mantineTableBodyCellProps: { + align: 'right', + style: { + paddingLeft: 0, + width: '20px', + minWidth: '20px', + }, + }, }, }, }); diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index b4e55426..2ea2d3b7 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -16,6 +16,7 @@ import { Paper, Box, Button, + Stack, } from '@mantine/core'; import { IconSquarePlus } from '@tabler/icons-react'; import { SquareMinus, SquarePen, Check, X } from 'lucide-react'; @@ -35,6 +36,7 @@ const UserAgentsTable = () => { { header: 'Name', accessorKey: 'name', + size: 100, }, { header: 'User-Agent', @@ -219,7 +221,9 @@ const UserAgentsTable = () => { ), mantineTableContainerProps: { style: { - height: 'calc(43vh - 55px)', + height: 'calc(60vh - 100px)', + overflowY: 'auto', + // margin: 5, }, }, displayColumnDefOptions: { @@ -230,15 +234,17 @@ const UserAgentsTable = () => { }); return ( - <> + { lineHeight: 1, letterSpacing: '-0.3px', color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary - marginBottom: 0, + // marginBottom: 0, }} > User-Agents @@ -307,7 +313,7 @@ const UserAgentsTable = () => { isOpen={userAgentModalOpen} onClose={closeUserAgentForm} /> - + ); }; diff --git a/frontend/src/pages/M3U.jsx b/frontend/src/pages/ContentSources.jsx similarity index 69% rename from frontend/src/pages/M3U.jsx rename to frontend/src/pages/ContentSources.jsx index acc883e8..eb62fe49 100644 --- a/frontend/src/pages/M3U.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import useUserAgentsStore from '../store/userAgents'; import M3UsTable from '../components/tables/M3UsTable'; -import UserAgentsTable from '../components/tables/UserAgentsTable'; -import { Box } from '@mantine/core'; +import EPGsTable from '../components/tables/EPGsTable'; +import { Box, Stack } from '@mantine/core'; const M3UPage = () => { const isLoading = useUserAgentsStore((state) => state.isLoading); @@ -12,13 +12,9 @@ const M3UPage = () => { if (error) return
Error: {error}
; return ( - @@ -26,9 +22,9 @@ const M3UPage = () => { - + - + ); }; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index bcc2c238..d40f64d6 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -80,7 +80,10 @@ export default function TVChannelGuide({ startDate, endDate }) { const filteredChannels = Object.values(channels) .filter((ch) => programIds.includes(ch.epg_data?.tvg_id)) // Add sorting by channel_number - .sort((a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity)); + .sort( + (a, b) => + (a.channel_number || Infinity) - (b.channel_number || Infinity) + ); console.log( `found ${filteredChannels.length} channels with matching tvg_ids` @@ -105,15 +108,15 @@ export default function TVChannelGuide({ startDate, endDate }) { // Apply search filter if (searchQuery) { const query = searchQuery.toLowerCase(); - result = result.filter(channel => + result = result.filter((channel) => channel.name.toLowerCase().includes(query) ); } // Apply channel group filter if (selectedGroupId !== 'all') { - result = result.filter(channel => - channel.channel_group?.id === parseInt(selectedGroupId) + result = result.filter( + (channel) => channel.channel_group?.id === parseInt(selectedGroupId) ); } @@ -122,16 +125,22 @@ export default function TVChannelGuide({ startDate, endDate }) { // Get the profile's enabled channels const profileChannels = profiles[selectedProfileId]?.channels || []; const enabledChannelIds = profileChannels - .filter(pc => pc.enabled) - .map(pc => pc.id); + .filter((pc) => pc.enabled) + .map((pc) => pc.id); - result = result.filter(channel => + result = result.filter((channel) => enabledChannelIds.includes(channel.id) ); } setFilteredChannels(result); - }, [searchQuery, selectedGroupId, selectedProfileId, guideChannels, profiles]); + }, [ + searchQuery, + selectedGroupId, + selectedProfileId, + guideChannels, + profiles, + ]); // Use start/end from props or default to "today at midnight" +24h const defaultStart = dayjs(startDate || dayjs().startOf('day')); @@ -213,7 +222,7 @@ export default function TVChannelGuide({ startDate, endDate }) { hours.push({ time: current, isNewDay, - dayLabel: formatDayLabel(current) + dayLabel: formatDayLabel(current), }); current = current.add(1, 'hour'); @@ -223,12 +232,21 @@ export default function TVChannelGuide({ startDate, endDate }) { // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { - if (guideRef.current && timelineRef.current && programs.length > 0 && !initialScrollComplete) { + if ( + guideRef.current && + timelineRef.current && + programs.length > 0 && + !initialScrollComplete + ) { // Round the current time to the nearest half-hour mark - const roundedNow = now.minute() < 30 ? now.startOf('hour') : now.startOf('hour').add(30, 'minute'); + const roundedNow = + now.minute() < 30 + ? now.startOf('hour') + : now.startOf('hour').add(30, 'minute'); const nowOffset = roundedNow.diff(start, 'minute'); const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - + MINUTE_BLOCK_WIDTH; const scrollPos = Math.max(scrollPosition, 0); guideRef.current.scrollLeft = scrollPos; @@ -345,19 +363,22 @@ export default function TVChannelGuide({ startDate, endDate }) { const currentScrollPosition = guideRef.current.scrollLeft; // Check if we need to scroll (if program start is before current view or too close to edge) - if (desiredScrollPosition < currentScrollPosition || - leftPx - currentScrollPosition < 100) { // 100px from left edge + if ( + desiredScrollPosition < currentScrollPosition || + leftPx - currentScrollPosition < 100 + ) { + // 100px from left edge // Smooth scroll to the program's start guideRef.current.scrollTo({ left: desiredScrollPosition, - behavior: 'smooth' + behavior: 'smooth', }); // Also sync the timeline scroll timelineRef.current.scrollTo({ left: desiredScrollPosition, - behavior: 'smooth' + behavior: 'smooth', }); } } @@ -375,10 +396,14 @@ export default function TVChannelGuide({ startDate, endDate }) { const scrollToNow = () => { if (guideRef.current && timelineRef.current && nowPosition >= 0) { // Round the current time to the nearest half-hour mark - const roundedNow = now.minute() < 30 ? now.startOf('hour') : now.startOf('hour').add(30, 'minute'); + const roundedNow = + now.minute() < 30 + ? now.startOf('hour') + : now.startOf('hour').add(30, 'minute'); const nowOffset = roundedNow.diff(start, 'minute'); const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - + MINUTE_BLOCK_WIDTH; const scrollPos = Math.max(scrollPosition, 0); guideRef.current.scrollLeft = scrollPos; @@ -410,7 +435,8 @@ export default function TVChannelGuide({ startDate, endDate }) { const scrollAmount = e.shiftKey ? 250 : 125; // Scroll horizontally based on wheel direction - timelineRef.current.scrollLeft += e.deltaY > 0 ? scrollAmount : -scrollAmount; + timelineRef.current.scrollLeft += + e.deltaY > 0 ? scrollAmount : -scrollAmount; // Sync the main content scroll position if (guideRef.current) { @@ -457,7 +483,8 @@ export default function TVChannelGuide({ startDate, endDate }) { const snappedOffset = snappedTime.diff(start, 'minute'); // Convert to pixels - const scrollPosition = (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + const scrollPosition = + (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; // Scroll both containers to the snapped position timelineRef.current.scrollLeft = scrollPosition; @@ -476,7 +503,8 @@ export default function TVChannelGuide({ startDate, endDate }) { // Calculate width with a small gap (2px on each side) const gapSize = 2; - const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - (gapSize * 2); + const widthPx = + (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2; // Check if we have a recording for this program const recording = recordings.find((recording) => { @@ -499,7 +527,10 @@ export default function TVChannelGuide({ startDate, endDate }) { const isExpanded = expandedProgramId === program.id; // Calculate how much of the program is cut off - const cutOffMinutes = Math.max(0, channelStart.diff(programStart, 'minute')); + const cutOffMinutes = Math.max( + 0, + channelStart.diff(programStart, 'minute') + ); const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; // Set the height based on expanded state @@ -522,7 +553,9 @@ export default function TVChannelGuide({ startDate, endDate }) { height: rowHeight - 4, // Adjust for the parent row padding cursor: 'pointer', zIndex: isExpanded ? 25 : 5, // Increase z-index when expanded - transition: isExpanded ? 'height 0.2s ease, width 0.2s ease' : 'height 0.2s ease', + transition: isExpanded + ? 'height 0.2s ease, width 0.2s ease' + : 'height 0.2s ease', }} onClick={(e) => handleProgramClick(program, e)} > @@ -530,7 +563,7 @@ export default function TVChannelGuide({ startDate, endDate }) { elevation={isExpanded ? 4 : 2} className={`guide-program ${isLive ? 'live' : isPast ? 'past' : 'not-live'} ${isExpanded ? 'expanded' : ''}`} style={{ - width: "100%", // Fill container width (which may be expanded) + width: '100%', // Fill container width (which may be expanded) height: '100%', overflow: 'hidden', position: 'relative', @@ -542,12 +575,12 @@ export default function TVChannelGuide({ startDate, endDate }) { ? isLive ? '#1a365d' // Darker blue when expanded and live : isPast - ? '#2d3748' // Darker gray when expanded and past + ? '#18181B' // Darker gray when expanded and past : '#1e40af' // Darker blue when expanded and upcoming : isLive - ? '#2d3748' // Default live program color + ? '#18181B' // Default live program color : isPast - ? '#4a5568' // Slightly darker color for past programs + ? '#27272A' // Slightly darker color for past programs : '#2c5282', // Default color for upcoming programs color: isPast ? '#a0aec0' : '#fff', // Dim text color for past programs boxShadow: isExpanded ? '0 4px 8px rgba(0,0,0,0.4)' : 'none', @@ -556,7 +589,7 @@ export default function TVChannelGuide({ startDate, endDate }) { > 0) { // Get unique channel group IDs from the channels that have program data const usedGroupIds = new Set(); - guideChannels.forEach(channel => { + guideChannels.forEach((channel) => { if (channel.channel_group?.id) { usedGroupIds.add(channel.channel_group.id); } @@ -665,12 +698,12 @@ export default function TVChannelGuide({ startDate, endDate }) { // Only add groups that are actually used by channels in the guide Object.values(channelGroups) - .filter(group => usedGroupIds.has(group.id)) + .filter((group) => usedGroupIds.has(group.id)) .sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically - .forEach(group => { + .forEach((group) => { options.push({ value: group.id.toString(), - label: group.name + label: group.name, }); }); } @@ -683,11 +716,12 @@ export default function TVChannelGuide({ startDate, endDate }) { const options = [{ value: 'all', label: 'All Profiles' }]; if (profiles) { - Object.values(profiles).forEach(profile => { - if (profile.id !== '0') { // Skip the 'All' default profile + Object.values(profiles).forEach((profile) => { + if (profile.id !== '0') { + // Skip the 'All' default profile options.push({ value: profile.id.toString(), - label: profile.name + label: profile.name, }); } }); @@ -720,7 +754,7 @@ export default function TVChannelGuide({ startDate, endDate }) { overflow: 'hidden', width: '100%', height: '100%', - backgroundColor: '#1a202c', + // backgroundColor: 'rgb(39, 39, 42)', color: '#fff', fontFamily: 'Roboto, sans-serif', }} @@ -730,7 +764,7 @@ export default function TVChannelGuide({ startDate, endDate }) { } rightSection={ searchQuery ? ( - setSearchQuery('')} variant="subtle" color="gray" size="sm"> + setSearchQuery('')} + variant="subtle" + color="gray" + size="sm" + > ) : null @@ -794,20 +833,29 @@ export default function TVChannelGuide({ startDate, endDate }) { clearable={true} // Allow clearing the selection /> - {(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && ( + {(searchQuery !== '' || + selectedGroupId !== 'all' || + selectedProfileId !== 'all') && ( )} - {filteredChannels.length} {filteredChannels.length === 1 ? 'channel' : 'channels'} + {filteredChannels.length}{' '} + {filteredChannels.length === 1 ? 'channel' : 'channels'} {/* Guide container with headers and scrollable content */} - + {/* Logo header - Sticky, non-scrollable */} @@ -870,10 +918,10 @@ export default function TVChannelGuide({ startDate, endDate }) { height: '40px', position: 'relative', color: '#a0aec0', - borderRight: '1px solid #4a5568', + borderRight: '1px solid #8DAFAA', cursor: 'pointer', - borderLeft: isNewDay ? '2px solid #4299e1' : 'none', // Highlight day boundaries - backgroundColor: isNewDay ? 'rgba(66, 153, 225, 0.05)' : '#171923', // Subtle background for new days + borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries + backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days }} onClick={(e) => handleTimeClick(time, e)} > @@ -900,10 +948,11 @@ export default function TVChannelGuide({ startDate, endDate }) { display: 'block', opacity: 0.7, fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions - color: isNewDay ? '#4299e1' : undefined, + color: isNewDay ? '#3BA882' : undefined, }} > - {formatDayLabel(time)} {/* Use same formatDayLabel function for all hours */} + {formatDayLabel(time)}{' '} + {/* Use same formatDayLabel function for all hours */} {time.format('h:mm')} @@ -919,7 +968,7 @@ export default function TVChannelGuide({ startDate, endDate }) { top: 0, bottom: 0, width: '1px', - backgroundColor: '#4a5568', + backgroundColor: '#27272A', zIndex: 10, }} /> @@ -969,12 +1018,14 @@ export default function TVChannelGuide({ startDate, endDate }) { onScroll={handleGuideScroll} > {/* Content wrapper with min-width to ensure scroll range */} - + {/* Now line - positioned absolutely within content */} {nowPosition >= 0 && ( p.tvg_id === channel.epg_data?.tvg_id ); // Check if any program in this channel is expanded - const hasExpandedProgram = channelPrograms.some(prog => prog.id === expandedProgramId); - const rowHeight = hasExpandedProgram ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; + const hasExpandedProgram = channelPrograms.some( + (prog) => prog.id === expandedProgramId + ); + const rowHeight = hasExpandedProgram + ? EXPANDED_PROGRAM_HEIGHT + : PROGRAM_HEIGHT; return ( - {/* Changed from Video to Play and increased size */} + {' '} + {/* Changed from Video to Play and increased size */} )} @@ -1108,11 +1164,11 @@ export default function TVChannelGuide({ startDate, endDate }) { bottom: '4px', left: '50%', transform: 'translateX(-50%)', - backgroundColor: '#2d3748', + backgroundColor: '#18181B', padding: '2px 8px', borderRadius: 4, fontSize: '0.85em', - border: '1px solid #4a5568', + border: '1px solid #27272A', height: '24px', display: 'flex', alignItems: 'center', @@ -1126,14 +1182,18 @@ export default function TVChannelGuide({ startDate, endDate }) { {/* Programs for this channel */} - - {channelPrograms.map((prog) => renderProgram(prog, start))} + + {channelPrograms.map((prog) => + renderProgram(prog, start) + )} ); @@ -1160,4 +1220,3 @@ export default function TVChannelGuide({ startDate, endDate }) { ); } - diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index dcc99fe9..81a85c78 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -3,8 +3,20 @@ import API from '../api'; import useSettingsStore from '../store/settings'; import useUserAgentsStore from '../store/userAgents'; import useStreamProfilesStore from '../store/streamProfiles'; -import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core'; +import { + Box, + Button, + Center, + Flex, + Group, + Paper, + Select, + Stack, + Title, +} from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; +import UserAgentsTable from '../components/tables/UserAgentsTable'; +import StreamProfilesTable from '../components/tables/StreamProfilesTable'; const SettingsPage = () => { const { settings } = useSettingsStore(); @@ -309,65 +321,81 @@ const SettingsPage = () => { }; return ( -
- +
- - Settings - -
- ({ + value: `${option.id}`, + label: option.name, + }))} + /> - ({ - label: r.label, - value: `${r.value}`, - }))} - /> + ({ + label: r.label, + value: `${r.value}`, + }))} + /> - - - -
- -
+ + + + +
+
+ + + + + + ); }; diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 93ae58ce..4c34be06 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -33,6 +33,7 @@ import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import { Sparkline } from '@mantine/charts'; import useStreamProfilesStore from '../store/streamProfiles'; +import { useLocation } from 'react-router-dom'; dayjs.extend(duration); dayjs.extend(relativeTime); @@ -75,6 +76,8 @@ const getStartDate = (uptime) => { // Create a separate component for each channel card to properly handle the hook const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => { + const location = useLocation(); + const clientsColumns = useMemo( () => [ { @@ -90,7 +93,9 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => { const channelClientsTable = useMantineReactTable({ ...TableHelper.defaultProperties, columns: clientsColumns, - data: clients.filter(client => client.channel.channel_id === channel.channel_id), + data: clients.filter( + (client) => client.channel.channel_id === channel.channel_id + ), enablePagination: false, enableTopToolbar: false, enableBottomToolbar: false, @@ -140,6 +145,10 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel }) => { }, }); + if (location.pathname != '/stats') { + return <>; + } + return ( { > - channel logo + channel logo diff --git a/frontend/src/pages/guide.css b/frontend/src/pages/guide.css index 5b6568c1..600bb449 100644 --- a/frontend/src/pages/guide.css +++ b/frontend/src/pages/guide.css @@ -7,22 +7,22 @@ white-space: nowrap; text-overflow: ellipsis; border-radius: 8px; - background: linear-gradient(to right, #2d3748, #2d3748); + background: linear-gradient(to right, #2D2D2F, #1F1F20); /* Default background */ color: #fff; transition: all 0.2s ease-out; } .tv-guide .guide-program-container .guide-program.live { - background: linear-gradient(to right, #1e3a8a, #2c5282); + background: linear-gradient(to right, #3BA882, #245043); } .tv-guide .guide-program-container .guide-program.live:hover { - background: linear-gradient(to right, #1e3a8a, #2a4365); + background: linear-gradient(to right, #2E9E80, #206E5E); } .tv-guide .guide-program-container .guide-program.not-live:hover { - background: linear-gradient(to right, #2d3748, #1a202c); + background: linear-gradient(to right, #2F3F3A, #1E2926); } /* New styles for expanded programs */ @@ -33,15 +33,15 @@ } .tv-guide .guide-program-container .guide-program.expanded.live { - background: linear-gradient(to right, #1a365d, #1e40af); + background: linear-gradient(to right, #226F5D, #3BA882); } .tv-guide .guide-program-container .guide-program.expanded.not-live { - background: linear-gradient(to right, #1a365d, #1e3a8a); + background: linear-gradient(to right, #2C3F3A, #206E5E); } .tv-guide .guide-program-container .guide-program.expanded.past { - background: linear-gradient(to right, #2d3748, #4a5568); + background: linear-gradient(to right, #1F2423, #2F3A37); } /* Ensure channel logo is always on top */ @@ -66,4 +66,4 @@ /* Make sure the main scrolling container establishes the right stacking context */ .tv-guide { position: relative; -} \ No newline at end of file +} diff --git a/frontend/src/store/playlists.jsx b/frontend/src/store/playlists.jsx index c723e8c1..e04263e7 100644 --- a/frontend/src/store/playlists.jsx +++ b/frontend/src/store/playlists.jsx @@ -65,11 +65,11 @@ const usePlaylistsStore = create((set) => ({ // @TODO: remove playlist profiles here })), - setRefreshProgress: (id, progress) => + setRefreshProgress: (data) => set((state) => ({ refreshProgress: { ...state.refreshProgress, - [id]: progress, + [data.account]: data, }, })), diff --git a/frontend/src/utils.js b/frontend/src/utils.js index b765c405..a4bb163d 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -51,3 +51,9 @@ export function useDebounce(value, delay = 500) { return debouncedValue; } + +export function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} From 9710560fad6a1649d44a280ec7dafd27b0a8f9b9 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:22:24 +0000 Subject: [PATCH 2/6] Increment build number to 13 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index aa2de582..c7e64b66 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '12' # Auto-incremented on builds +__build__ = '13' # Auto-incremented on builds From d647ff16d583a2b14d42bcc4ac7b49ec67f4863f Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 13:26:13 -0400 Subject: [PATCH 3/6] updated new link / page --- frontend/src/components/Sidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index ea33f8b8..e74b6392 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -107,7 +107,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { path: '/channels', badge: `(${Object.keys(channels).length})`, }, - { label: 'M3U', icon: , path: '/m3u' }, + { label: 'M3U & EPG Manager', icon: , path: '/sources' }, { label: 'EPG', icon: , path: '/epg' }, { label: 'Stream Profiles', From e04c0a49ee4d2f3d91efd0bc929ca22aa3b63d6d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:26:34 +0000 Subject: [PATCH 4/6] Increment build number to 14 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index c7e64b66..5ce3238f 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '13' # Auto-incremented on builds +__build__ = '14' # Auto-incremented on builds From 804dbe85915674e067a8bf1382df410e25ac1f6b Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 11 Apr 2025 13:26:48 -0400 Subject: [PATCH 5/6] removed old navlink --- frontend/src/components/Sidebar.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index e74b6392..e875c05b 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -108,7 +108,6 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { badge: `(${Object.keys(channels).length})`, }, { label: 'M3U & EPG Manager', icon: , path: '/sources' }, - { label: 'EPG', icon: , path: '/epg' }, { label: 'Stream Profiles', icon: , From bd48ac289b829d2be0f6b5157dc1ee572ec8c9eb Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 11 Apr 2025 17:27:10 +0000 Subject: [PATCH 6/6] Increment build number to 15 [skip ci] --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 5ce3238f..f9ac793b 100644 --- a/version.py +++ b/version.py @@ -2,4 +2,4 @@ Dispatcharr version information. """ __version__ = '0.1.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) -__build__ = '14' # Auto-incremented on builds +__build__ = '15' # Auto-incremented on builds