diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index c4b544b0..f751922b 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -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): diff --git a/core/api_urls.py b/core/api_urls.py index 00e20a6e..ce71706c 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -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)), ] diff --git a/core/api_views.py b/core/api_views.py index 6b9743f6..c962e5b9 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 04555488..895ed902 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 = () => { + diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 67f7edfc..b1e1905f 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -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) { diff --git a/frontend/src/api.js b/frontend/src/api.js index 0c763dd8..762a3f5b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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/`); diff --git a/frontend/src/components/UpdateIgnoreDialog.jsx b/frontend/src/components/UpdateIgnoreDialog.jsx new file mode 100644 index 00000000..53cd05ce --- /dev/null +++ b/frontend/src/components/UpdateIgnoreDialog.jsx @@ -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 }) => ( + onSelect(value)} + style={{ cursor: 'pointer', borderColor: selected ? `var(--mantine-color-${color}-6)` : undefined }} + > + + onSelect(value)} value={value} aria-label={title} /> + + {title} + + {description} + + + + +); + +const UpdateIgnoreDialog = ({ opened, onClose, version, url, onChoice }) => { + const [selection, setSelection] = useState('later'); + + const save = () => { + onChoice(selection); + }; + + return ( + + + + + Update Available + + } + overlayProps={{ blur: 2, opacity: 0.35 }} + > + + + + Dispatcharr {version} is available + + {url && ( + + View release + + )} + + + + Choose how you want to be notified about this update. You can change this later in Settings → Notifications. + + + + + + + + + + + + + + + + + ); +}; + +export default UpdateIgnoreDialog; diff --git a/frontend/src/components/UpdatePromptManager.jsx b/frontend/src/components/UpdatePromptManager.jsx new file mode 100644 index 00000000..b90b6cc6 --- /dev/null +++ b/frontend/src/components/UpdatePromptManager.jsx @@ -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 ( + + ); +}; + +export default UpdatePromptManager; diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index 916a2c30..43557c17 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -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: ( + + Dispatcharr {latest.latest_version} is available.{' '} + + View on GitHub + + + ), + 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 = () => { + {null} ); }; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 85ea85f1..bdb15901 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 = () => { + + + + Notifications + + + + + Version + Current: {currentVersion || '—'} + + Latest: {latestRelease?.latest_version || '—'}{' '} + {latestRelease?.latest_url && ( + + View on GitHub + + )} + + + +