Update Notification

Added update notification
Added notification settings
This commit is contained in:
Dispatcharr 2025-09-07 22:18:24 -05:00
parent 0938a3c592
commit 981824a814
14 changed files with 648 additions and 46 deletions

View file

@ -138,7 +138,7 @@ class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer
def get_permissions(self):
if self.action == "me":
if self.action in ["me", "preferences"]:
return [Authenticated()]
return [IsAdmin()]
@ -176,6 +176,28 @@ class UserViewSet(viewsets.ModelViewSet):
serializer = UserSerializer(user)
return Response(serializer.data)
@swagger_auto_schema(
method="get",
operation_description="Get current user's preferences (custom_properties)",
)
@swagger_auto_schema(
method="patch",
operation_description="Update current user's preferences (custom_properties)",
)
@action(detail=False, methods=["get", "patch"], url_path="me/preferences")
def preferences(self, request):
user = request.user
if request.method == "GET":
return Response(user.custom_properties or {})
data = request.data or {}
# Merge incoming data into existing custom_properties (shallow merge)
props = user.custom_properties or {}
props.update(data)
user.custom_properties = props
user.save(update_fields=["custom_properties"])
return Response(user.custom_properties)
# 🔹 3) Group Management APIs
class GroupViewSet(viewsets.ModelViewSet):

View file

@ -2,7 +2,15 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint
from .api_views import (
UserAgentViewSet,
StreamProfileViewSet,
CoreSettingsViewSet,
environment,
version,
rehash_streams_endpoint,
latest_release,
)
router = DefaultRouter()
router.register(r'useragents', UserAgentViewSet, basename='useragent')
@ -11,6 +19,7 @@ router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
urlpatterns = [
path('settings/env/', environment, name='token_refresh'),
path('version/', version, name='version'),
path('latest-release/', latest_release, name='latest_release'),
path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'),
path('', include(router.urls)),
]

View file

@ -282,6 +282,75 @@ def version(request):
)
@swagger_auto_schema(
method="get",
operation_description="Fetch latest GitHub release info for Dispatcharr",
responses={200: "Latest release information"},
)
@api_view(["GET"])
def latest_release(request):
"""Return information about the latest GitHub release and whether it's newer than current."""
from version import __version__ as current_version
api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
try:
r = requests.get(
api_url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Dispatcharr",
},
timeout=10,
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.warning(f"Failed to fetch latest release: {e}")
return Response(
{
"current_version": current_version,
"latest_version": current_version,
"latest_url": None,
"is_newer": False,
"error": True,
"message": "Unable to check GitHub releases",
}
)
tag = data.get("tag_name") or data.get("name") or ""
latest_version = str(tag).lstrip("vV").strip() or current_version
def parse_ver(v: str):
parts = []
for p in v.split("."):
try:
parts.append(int(p))
except Exception:
# Strip non-digits prefix/suffix like rc, beta
num = "".join(ch for ch in p if ch.isdigit())
parts.append(int(num) if num else 0)
# Normalize length
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
try:
is_newer = parse_ver(latest_version) > parse_ver(current_version)
except Exception:
is_newer = latest_version != current_version
return Response(
{
"current_version": current_version,
"latest_version": latest_version,
"latest_url": data.get("html_url"),
"is_newer": bool(is_newer),
"published_at": data.get("published_at"),
"release_name": data.get("name"),
"prerelease": data.get("prerelease", False),
}
)
@swagger_auto_schema(
method="post",
operation_description="Trigger rehashing of all streams",

View file

@ -31,6 +31,7 @@ import mantineTheme from './mantineTheme';
import API from './api';
import { Notifications } from '@mantine/notifications';
import M3URefreshNotification from './components/M3URefreshNotification';
import UpdatePromptManager from './components/UpdatePromptManager';
import 'allotment/dist/style.css';
const drawerWidth = 240;
@ -164,6 +165,7 @@ const App = () => {
</AppShell.Main>
</AppShell>
<M3URefreshNotification />
<UpdatePromptManager />
<Notifications containerWidth={350} />
</Router>
</WebsocketProvider>

View file

@ -13,6 +13,7 @@ import useLogosStore from './store/logos';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import { Box, Button, Stack, Alert, Group } from '@mantine/core';
import useNotificationsStore from './store/notifications';
import API from './api';
import useSettingsStore from './store/settings';
import useAuthStore from './store/auth';
@ -296,10 +297,12 @@ export const WebsocketProvider = ({ children }) => {
break;
case 'epg_channels':
notifications.show({
message: 'EPG channels updated!',
color: 'green.5',
});
if (useNotificationsStore.getState().isEnabled('epg_events')) {
notifications.show({
message: 'EPG channels updated!',
color: 'green.5',
});
}
// If source_id is provided, update that specific EPG's status
if (parsedEvent.data.source_id) {
@ -316,10 +319,12 @@ export const WebsocketProvider = ({ children }) => {
break;
case 'epg_match':
notifications.show({
message: parsedEvent.data.message || 'EPG match is complete!',
color: 'green.5',
});
if (useNotificationsStore.getState().isEnabled('epg_events')) {
notifications.show({
message: parsedEvent.data.message || 'EPG match is complete!',
color: 'green.5',
});
}
// Check if we have associations data and use the more efficient batch API
if (
@ -354,10 +359,12 @@ export const WebsocketProvider = ({ children }) => {
break;
case 'recording_started':
notifications.show({
title: 'Recording started!',
message: `Started recording channel ${parsedEvent.data.channel}`,
});
if (useNotificationsStore.getState().isEnabled('epg_events')) {
notifications.show({
title: 'Recording started!',
message: `Started recording channel ${parsedEvent.data.channel}`,
});
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
@ -366,10 +373,12 @@ export const WebsocketProvider = ({ children }) => {
break;
case 'recording_ended':
notifications.show({
title: 'Recording finished!',
message: `Stopped recording channel ${parsedEvent.data.channel}`,
});
if (useNotificationsStore.getState().isEnabled('epg_events')) {
notifications.show({
title: 'Recording finished!',
message: `Stopped recording channel ${parsedEvent.data.channel}`,
});
}
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {

View file

@ -1219,6 +1219,18 @@ export default class API {
}
}
static async getLatestRelease() {
try {
const response = await request(`${host}/api/core/latest-release/`, {
auth: false,
});
return response;
} catch (e) {
errorNotification('Failed to check latest release', e);
}
}
static async checkSetting(values) {
const { id, ...payload } = values;
@ -1854,6 +1866,25 @@ export default class API {
return await request(`${host}/api/accounts/users/me/`);
}
static async getMyPreferences() {
try {
return await request(`${host}/api/accounts/users/me/preferences/`);
} catch (e) {
errorNotification('Failed to fetch preferences', e);
}
}
static async updateMyPreferences(body) {
try {
return await request(`${host}/api/accounts/users/me/preferences/`, {
method: 'PATCH',
body,
});
} catch (e) {
errorNotification('Failed to update preferences', e);
}
}
static async getUsers() {
try {
const response = await request(`${host}/api/accounts/users/`);

View file

@ -0,0 +1,118 @@
import React, { useState } from 'react';
import {
Modal,
Button,
Group,
Stack,
Text,
Badge,
Anchor,
Paper,
Radio,
SimpleGrid,
ThemeIcon,
} from '@mantine/core';
import { Bell, ExternalLink } from 'lucide-react';
const ChoiceCard = ({ value, selected, title, description, onSelect, color }) => (
<Paper
withBorder
shadow={selected ? 'md' : 'xs'}
p="md"
radius="md"
onClick={() => onSelect(value)}
style={{ cursor: 'pointer', borderColor: selected ? `var(--mantine-color-${color}-6)` : undefined }}
>
<Group align="flex-start" gap="sm">
<Radio checked={selected} onChange={() => onSelect(value)} value={value} aria-label={title} />
<Stack gap={4} style={{ flex: 1 }}>
<Text fw={600}>{title}</Text>
<Text c="dimmed" size="sm">
{description}
</Text>
</Stack>
</Group>
</Paper>
);
const UpdateIgnoreDialog = ({ opened, onClose, version, url, onChoice }) => {
const [selection, setSelection] = useState('later');
const save = () => {
onChoice(selection);
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="md"
size="md"
title={
<Group gap="xs">
<ThemeIcon color="blue" radius="xl" variant="light">
<Bell size={18} />
</ThemeIcon>
<Text fw={700}>Update Available</Text>
</Group>
}
overlayProps={{ blur: 2, opacity: 0.35 }}
>
<Stack gap="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={500}>
Dispatcharr <Badge color="blue" variant="light">{version}</Badge> is available
</Text>
{url && (
<Anchor href={url} target="_blank" rel="noreferrer" size="sm">
View release <ExternalLink size={14} style={{ marginLeft: 4, verticalAlign: 'text-bottom' }} />
</Anchor>
)}
</Group>
<Text size="sm" c="dimmed">
Choose how you want to be notified about this update. You can change this later in Settings Notifications.
</Text>
<Radio.Group value={selection} onChange={setSelection}>
<SimpleGrid cols={{ base: 1, sm: 1 }} spacing="sm">
<ChoiceCard
value="later"
selected={selection === 'later'}
title="Remind me next login"
description="Well show this update again the next time you sign in."
onSelect={setSelection}
color="blue"
/>
<ChoiceCard
value="ignore_version"
selected={selection === 'ignore_version'}
title="Ignore this version"
description="Skip notifications for this specific version. Well notify you when a newer version is available."
onSelect={setSelection}
color="yellow"
/>
<ChoiceCard
value="never"
selected={selection === 'never'}
title="Never notify"
description="Turn off update notifications entirely. You can re-enable them in Settings."
onSelect={setSelection}
color="red"
/>
</SimpleGrid>
</Radio.Group>
<Group justify="space-between" mt="xs">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button onClick={save}>Save preference</Button>
</Group>
</Stack>
</Modal>
);
};
export default UpdateIgnoreDialog;

View file

@ -0,0 +1,41 @@
import React from 'react';
import UpdateIgnoreDialog from './UpdateIgnoreDialog';
import useUpdatePromptStore from '../store/updatePrompt';
import useNotificationsStore from '../store/notifications';
const UpdatePromptManager = () => {
const { open, version, url, close } = useUpdatePromptStore();
const notifStore = useNotificationsStore();
const onChoice = async (choice) => {
try {
if (choice === 'later') {
notifStore.setLastShownNow();
await notifStore.save();
} else if (choice === 'ignore_version') {
notifStore.addIgnoredVersion(version);
notifStore.setLastShownNow();
await notifStore.save();
} else if (choice === 'never') {
notifStore.setUpdatePolicy('never');
await notifStore.save();
}
} catch (e) {
// non-fatal
} finally {
close();
}
};
return (
<UpdateIgnoreDialog
opened={open}
version={version}
url={url}
onClose={close}
onChoice={onChoice}
/>
);
};
export default UpdatePromptManager;

View file

@ -1,7 +1,11 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import API from '../../api';
import useNotificationsStore from '../../store/notifications';
import useUpdatePromptStore from '../../store/updatePrompt';
const LoginForm = () => {
const login = useAuthStore((s) => s.login);
@ -11,6 +15,8 @@ const LoginForm = () => {
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
const notifStore = useNotificationsStore();
const openUpdatePrompt = useUpdatePromptStore((s) => s.openWith);
// useEffect(() => {
// if (isAuthenticated) {
@ -31,6 +37,51 @@ const LoginForm = () => {
try {
await initData();
// After successful login and data init, check for updates per user prefs
try {
const latest = await API.getLatestRelease();
if (latest && latest.is_newer) {
const prefs = notifStore.prefs?.notifications || {};
const updates = prefs.updates || {};
const ignored = new Set(updates.ignored_versions || []);
const policy = updates.policy || 'on_login';
// Respect policy and ignored versions
if (!ignored.has(latest.latest_version)) {
const now = new Date();
let allow = false;
if (policy === 'never') allow = false;
else if (policy === 'on_login') allow = true;
else if (policy === 'daily' || policy === 'weekly') {
const last = updates.last_shown_at ? new Date(updates.last_shown_at) : null;
const diffMs = last ? now - last : Number.POSITIVE_INFINITY;
const threshold = policy === 'daily' ? 24 * 3600 * 1000 : 7 * 24 * 3600 * 1000;
allow = diffMs >= threshold;
} else allow = true;
if (allow) {
notifications.show({
title: 'Update available',
message: (
<span>
Dispatcharr {latest.latest_version} is available.{' '}
<a href={latest.latest_url} target="_blank" rel="noreferrer">
View on GitHub
</a>
</span>
),
color: 'blue.5',
autoClose: false,
onClose: () => openUpdatePrompt(latest.latest_version, latest.latest_url),
});
// Record show time locally (persist on modal choice)
}
}
}
} catch (e) {
// non-fatal
console.warn('Update check failed:', e);
}
navigate('/channels');
} catch (e) {
console.log(`Failed to login: ${e}`);
@ -75,6 +126,7 @@ const LoginForm = () => {
</Stack>
</form>
</Paper>
{null}
</Center>
);
};

View file

@ -32,6 +32,8 @@ import {
} from '../constants';
import ConfirmationDialog from '../components/ConfirmationDialog';
import useWarningsStore from '../store/warnings';
import useNotificationsStore, { DEFAULT_CATEGORIES } from '../store/notifications';
import { notifications as toast } from '@mantine/notifications';
const SettingsPage = () => {
const settings = useSettingsStore((s) => s.settings);
@ -61,6 +63,12 @@ const SettingsPage = () => {
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h');
const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy');
const [currentVersion, setCurrentVersion] = useState(null);
const [latestRelease, setLatestRelease] = useState(null);
const notifPrefs = useNotificationsStore((s) => s.prefs);
const setCategory = useNotificationsStore((s) => s.setCategory);
const setUpdatePolicy = useNotificationsStore((s) => s.setUpdatePolicy);
const saveNotifPrefs = useNotificationsStore((s) => s.save);
const regionChoices = REGION_CHOICES;
@ -170,6 +178,29 @@ const SettingsPage = () => {
}
}, [settings]);
useEffect(() => {
// Load version info for Notification settings
(async () => {
try {
const v = await API.getVersion();
setCurrentVersion(v?.version || null);
} catch {}
try {
const r = await API.getLatestRelease();
setLatestRelease(r || null);
} catch {}
})();
}, []);
const onSaveNotifications = async () => {
try {
await saveNotifPrefs();
toast.show({ title: 'Notification settings saved' });
} catch (e) {
// errorNotification handled in API
}
};
const onSubmit = async () => {
const values = form.getValues();
const changedSettings = {};
@ -823,6 +854,73 @@ const SettingsPage = () => {
</form>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="notifications">
<Accordion.Control>
<Box>Notifications</Box>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Box>
<Text size="sm" fw={600}>Version</Text>
<Text size="sm">Current: {currentVersion || '—'}</Text>
<Text size="sm">
Latest: {latestRelease?.latest_version || '—'}{' '}
{latestRelease?.latest_url && (
<a
href={latestRelease.latest_url}
target="_blank"
rel="noreferrer"
style={{ marginLeft: 6 }}
>
View on GitHub
</a>
)}
</Text>
</Box>
<Select
label="Update notification frequency"
value={
notifPrefs?.notifications?.updates?.policy || 'on_login'
}
onChange={(v) => setUpdatePolicy(v)}
data={[
{ value: 'on_login', label: 'On login' },
{ value: 'daily', label: 'Once per day (on login)' },
{ value: 'weekly', label: 'Once per week (on login)' },
{ value: 'never', label: 'Never' },
]}
/>
<Box>
<Text size="sm" fw={600} mb={4}>
App notifications
</Text>
{Object.entries(DEFAULT_CATEGORIES).map(([k, _]) => (
<Group key={k} justify="space-between" mt={8}>
<Text size="sm" style={{ textTransform: 'capitalize' }}>
{k.replaceAll('_', ' ')}
</Text>
<Switch
checked={
(notifPrefs?.notifications?.categories || {})[k] !==
false
}
onChange={(e) => setCategory(k, e.currentTarget.checked)}
/>
</Group>
))}
</Box>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button variant="default" onClick={onSaveNotifications}>
Save
</Button>
</Flex>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</>
)}
</Accordion>

View file

@ -9,6 +9,7 @@ import useUserAgentsStore from './userAgents';
import useUsersStore from './users';
import useLogosStore from './logos';
import API from '../api';
import useNotificationsStore from './notifications';
import { USER_LEVELS } from '../constants';
const decodeToken = (token) => {
@ -63,6 +64,13 @@ const useAuthStore = create((set, get) => ({
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
// Load notification preferences from user custom_properties
try {
useNotificationsStore.getState().loadFromUser(user);
} catch (e) {
console.warn('Failed to load notification prefs:', e);
}
set({ user, isAuthenticated: true });
} catch (error) {
console.error('Error initializing data:', error);

View file

@ -1,6 +1,7 @@
import { create } from 'zustand';
import api from '../api';
import { notifications } from '@mantine/notifications';
import useNotificationsStore from './notifications';
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
@ -332,11 +333,13 @@ const useChannelsStore = create((set, get) => ({
const channel = channelId ? channels[channelId] : null;
if (channel) {
notifications.show({
title: 'New channel streaming',
message: channel.name,
color: 'blue.5',
});
if (useNotificationsStore.getState().isEnabled('channel_stream_started')) {
notifications.show({
title: 'New channel streaming',
message: channel.name,
color: 'blue.5',
});
}
}
}
}
@ -345,11 +348,13 @@ const useChannelsStore = create((set, get) => ({
// This check prevents the notifications if streams are active on page load
if (currentStats.channels) {
if (oldClients[client.client_id] === undefined) {
notifications.show({
title: 'New client started streaming',
message: `Client streaming from ${client.ip_address}`,
color: 'blue.5',
});
if (useNotificationsStore.getState().isEnabled('client_stream_started')) {
notifications.show({
title: 'New client started streaming',
message: `Client streaming from ${client.ip_address}`,
color: 'blue.5',
});
}
}
}
});
@ -363,28 +368,32 @@ const useChannelsStore = create((set, get) => ({
const channelId = channelsByUUID[uuid];
const channel = channelId && channels[channelId];
if (channel) {
notifications.show({
title: 'Channel streaming stopped',
message: channel.name,
color: 'blue.5',
});
} else {
notifications.show({
title: 'Channel streaming stopped',
message: `Channel (${uuid})`,
color: 'blue.5',
});
if (useNotificationsStore.getState().isEnabled('channel_stream_stopped')) {
if (channel) {
notifications.show({
title: 'Channel streaming stopped',
message: channel.name,
color: 'blue.5',
});
} else {
notifications.show({
title: 'Channel streaming stopped',
message: `Channel (${uuid})`,
color: 'blue.5',
});
}
}
}
}
for (const clientId in oldClients) {
if (newClients[clientId] === undefined) {
notifications.show({
title: 'Client stopped streaming',
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
color: 'blue.5',
});
if (useNotificationsStore.getState().isEnabled('client_stream_stopped')) {
notifications.show({
title: 'Client stopped streaming',
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
color: 'blue.5',
});
}
}
}
}

View file

@ -0,0 +1,122 @@
import { create } from 'zustand';
import API from '../api';
// Categories we support gating notifications for
export const DEFAULT_CATEGORIES = {
channel_stream_started: true,
channel_stream_stopped: true,
client_stream_started: true,
client_stream_stopped: true,
epg_events: true, // group flag for EPG related notifications
};
const defaultPrefs = {
notifications: {
updates: {
policy: 'on_login', // on_login | daily | weekly | never
ignored_versions: [],
last_shown_at: null,
},
categories: { ...DEFAULT_CATEGORIES },
},
};
const normalize = (prefs) => {
const merged = { ...defaultPrefs.notifications, ...(prefs?.notifications || {}) };
merged.categories = { ...DEFAULT_CATEGORIES, ...(merged.categories || {}) };
if (!Array.isArray(merged.updates?.ignored_versions)) {
merged.updates.ignored_versions = merged.updates?.ignored_versions || [];
}
return { notifications: merged };
};
const useNotificationsStore = create((set, get) => ({
prefs: defaultPrefs,
loadFromUser: (user) => {
const incoming = user?.custom_properties || {};
const normalized = normalize(incoming);
set({ prefs: normalized });
},
refreshFromServer: async () => {
const prefs = await API.getMyPreferences();
const normalized = normalize(prefs || {});
set({ prefs: normalized });
},
isEnabled: (categoryKey) => {
const categories = get().prefs.notifications.categories || {};
return categories[categoryKey] !== false;
},
setCategory: (categoryKey, value) => {
set((state) => ({
prefs: {
...state.prefs,
notifications: {
...state.prefs.notifications,
categories: {
...state.prefs.notifications.categories,
[categoryKey]: !!value,
},
},
},
}));
},
setUpdatePolicy: (policy) => {
set((state) => ({
prefs: {
...state.prefs,
notifications: {
...state.prefs.notifications,
updates: {
...state.prefs.notifications.updates,
policy,
},
},
},
}));
},
addIgnoredVersion: (version) => {
set((state) => {
const list = new Set(state.prefs.notifications.updates.ignored_versions || []);
if (version) list.add(version);
return {
prefs: {
...state.prefs,
notifications: {
...state.prefs.notifications,
updates: {
...state.prefs.notifications.updates,
ignored_versions: Array.from(list),
},
},
},
};
});
},
setLastShownNow: () => {
set((state) => ({
prefs: {
...state.prefs,
notifications: {
...state.prefs.notifications,
updates: {
...state.prefs.notifications.updates,
last_shown_at: new Date().toISOString(),
},
},
},
}));
},
save: async () => {
await API.updateMyPreferences(get().prefs);
},
}));
export default useNotificationsStore;

View file

@ -0,0 +1,12 @@
import { create } from 'zustand';
const useUpdatePromptStore = create((set) => ({
open: false,
version: null,
url: null,
openWith: (version, url) => set({ open: true, version, url }),
close: () => set({ open: false }),
}));
export default useUpdatePromptStore;