diff --git a/CHANGELOG.md b/CHANGELOG.md index 19462cd7..7f1e54d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) +- **Targeted stream-stats refresh in the channel table**: expanding a channel row now refreshes `stream_stats` for that channel's streams via a new lightweight delta endpoint (`GET /api/channels/channels/{channel_id}/streams/stats/`) instead of relying on a full channel-list re-fetch. The endpoint accepts a `since` (ISO 8601) cursor and an optional `ids` filter and returns only streams whose `stream_stats_updated_at` is strictly newer than the cursor, so the response is empty when nothing has changed. The frontend computes the cursor on demand from the streams already in the store, fires the request once on row expand, and fires it again scoped to a single stream ID when the in-app preview player closes. A new `patchChannelStreamStats` Zustand mutator merges the response into the store while preserving object identity for unchanged channels and streams, so memoized rows do not re-render. This restores the live-stats refresh behaviour that the channel-table performance work removed (`requeryChannels()`) without re-introducing the page-wide re-fetch. - **Editable default M3U profile patterns**: the default profile in the M3U profiles editor now exposes Search Pattern and Replace Pattern fields, allowing users to apply a URL transformation to every stream in the playlist (useful for replacing a local IP address, for example). A warning alert explains the fallback behaviour. A "Reset to Defaults" button restores the pass-through patterns (`^(.*)$` / `$1`). The live regex demonstration panel is also shown for the default profile. The backend serializer and `transform_url` were updated accordingly: `search_pattern` and `replace_pattern` are now permitted fields when updating a default profile, and `transform_url` uses `regex.subn()` to detect a genuine non-matches, logging a `WARNING` when the pattern does not match any part of the URL. ### Performance diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index b2af3167..756e2341 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -15,6 +15,7 @@ from .api_views import ( RecordingViewSet, RecurringRecordingRuleViewSet, GetChannelStreamsAPIView, + GetChannelStreamStatsAPIView, SeriesRulesAPIView, DeleteSeriesRuleAPIView, EvaluateSeriesRulesAPIView, @@ -41,6 +42,7 @@ urlpatterns = [ path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'), path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'), path('channels//streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), + path('channels//streams/stats/', GetChannelStreamStatsAPIView.as_view(), name='get_channel_stream_stats'), path('profiles//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), # DVR series rules (order matters: specific routes before catch-all slug) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index d3c76f88..5ef575a0 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -2237,6 +2237,99 @@ class GetChannelStreamsAPIView(APIView): return Response(serializer.data) +class GetChannelStreamStatsAPIView(APIView): + """Returns a stats delta for a channel's streams (id, stream_stats, + stream_stats_updated_at). Supports `since` (ISO 8601) and `ids` + (comma-separated) query params.""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description=( + "Return a minimal stats delta for the streams attached to a " + "channel. Used by the channel table to refresh `stream_stats` " + "on row expand and after a preview closes without re-pulling " + "full stream rows." + ), + parameters=[ + OpenApiParameter( + name="since", + type=OpenApiTypes.DATETIME, + location=OpenApiParameter.QUERY, + required=False, + description=( + "ISO 8601 timestamp. Returns only streams whose " + "`stream_stats_updated_at` is strictly newer than this " + "value. Omit to return all streams for the channel." + ), + ), + OpenApiParameter( + name="ids", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description=( + "Comma-separated stream IDs to restrict the response " + "to. Combined with `since` via AND." + ), + ), + ], + responses={ + 200: inline_serializer( + name="ChannelStreamStatsDelta", + fields={ + "id": serializers.IntegerField(), + "stream_stats": serializers.JSONField(allow_null=True), + "stream_stats_updated_at": serializers.DateTimeField(allow_null=True), + }, + many=True, + ), + 400: inline_serializer( + name="ChannelStreamStatsErrorResponse", + fields={"detail": serializers.CharField()}, + ), + }, + ) + def get(self, request, channel_id): + from django.utils.dateparse import parse_datetime + + get_object_or_404(Channel, id=channel_id) + + qs = Stream.objects.filter(channels=channel_id) + + since_raw = request.query_params.get("since") + if since_raw: + since_dt = parse_datetime(since_raw) + if since_dt is None: + return Response( + {"detail": "Invalid 'since' value. Expected ISO 8601."}, + status=status.HTTP_400_BAD_REQUEST, + ) + qs = qs.filter(stream_stats_updated_at__gt=since_dt) + + ids_raw = request.query_params.get("ids") + if ids_raw: + try: + ids = [int(x) for x in ids_raw.split(",") if x.strip()] + except ValueError: + return Response( + {"detail": "Invalid 'ids' value. Expected comma-separated integers."}, + status=status.HTTP_400_BAD_REQUEST, + ) + qs = qs.filter(id__in=ids) + + data = list( + qs.values("id", "stream_stats", "stream_stats_updated_at") + ) + return Response(data) + + class UpdateChannelMembershipAPIView(APIView): permission_classes = [IsOwnerOfObject] diff --git a/frontend/src/api.js b/frontend/src/api.js index 761cb259..ff080c88 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1007,6 +1007,27 @@ export default class API { } } + /** + * Fetches a stats delta for a channel's streams. Errors are swallowed + * since this is a background refresh. + */ + static async getChannelStreamStats(channelId, since, ids) { + try { + const params = new URLSearchParams(); + if (since) params.set('since', since); + if (Array.isArray(ids) && ids.length > 0) { + params.set('ids', ids.join(',')); + } + const qs = params.toString(); + const response = await request( + `${host}/api/channels/channels/${channelId}/streams/stats/${qs ? `?${qs}` : ''}` + ); + return Array.isArray(response) ? response : []; + } catch (e) { + return []; + } + } + static async queryStreams(params) { try { const response = await request( diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 2e3ddb96..237f0c83 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -340,7 +340,8 @@ const StreamInfoCell = React.memo( onClick={() => handleWatchStream( stream.stream_hash || stream.id, - stream.name + stream.name, + stream.id ) } style={{ marginLeft: 2 }} @@ -496,18 +497,25 @@ const ChannelStreams = ({ channel }) => { (state) => state.getChannelStreams(channel.id), shallow ); + const patchChannelStreamStats = useChannelsTableStore( + (s) => s.patchChannelStreamStats + ); const playlists = usePlaylistsStore((s) => s.playlists); const authUser = useAuthStore((s) => s.user); const showVideo = useVideoStore((s) => s.showVideo); + const isVideoVisible = useVideoStore((s) => s.isVisible); const env_mode = useSettingsStore((s) => s.environment.env_mode); const handleWatchStream = useCallback( - (streamHash, streamName) => { + (streamHash, streamName, streamId) => { let vidUrl = `/proxy/ts/stream/${streamHash}`; if (env_mode === 'dev') { vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; } - showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); + const meta = {}; + if (streamName) meta.name = streamName; + if (streamId != null) meta.streamId = streamId; + showVideo(vidUrl, 'live', Object.keys(meta).length ? meta : null); }, [env_mode, showVideo] ); @@ -526,6 +534,57 @@ const ChannelStreams = ({ channel }) => { const dataIds = useMemo(() => data?.map(({ id }) => id), [data]); + // Fire-and-forget refresh of stream stats. Cursor is the newest + // stream_stats_updated_at already in the store; server returns only + // entries strictly newer than that (empty array when nothing changed). + const refreshStats = useCallback( + (opts) => { + const channelId = channelRef.current?.id; + if (!channelId) return; + const streams = dataRef.current || []; + let since = null; + for (const s of streams) { + const t = s.stream_stats_updated_at; + if (t && (since === null || t > since)) since = t; + } + const ids = opts && opts.ids; + API.getChannelStreamStats(channelId, since, ids).then((updates) => { + if (!updates || updates.length === 0) return; + patchChannelStreamStats(channelId, updates); + }); + }, + [patchChannelStreamStats] + ); + + // Refresh once when the row is expanded. + useEffect(() => { + refreshStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Refresh just the previewed stream when the floating player closes. + // Metadata is captured while visible because hideVideo clears it. + const prevVisibleRef = useRef(isVideoVisible); + const lastPreviewMetaRef = useRef(null); + useEffect(() => { + if (isVideoVisible) { + lastPreviewMetaRef.current = useVideoStore.getState().metadata; + } + const wasVisible = prevVisibleRef.current; + prevVisibleRef.current = isVideoVisible; + if (wasVisible && !isVideoVisible) { + const meta = lastPreviewMetaRef.current; + lastPreviewMetaRef.current = null; + const streamId = meta && meta.streamId; + if ( + streamId != null && + (dataRef.current || []).some((s) => s.id === streamId) + ) { + refreshStats({ ids: [streamId] }); + } + } + }, [isVideoVisible, refreshStats]); + const removeStream = useCallback(async (stream) => { const newStreamList = dataRef.current.filter((s) => s.id !== stream.id); setData(newStreamList); diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index baa20925..e8d9445f 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -76,6 +76,45 @@ const useChannelsTableStore = create((set, get) => ({ ), })); }, + + /** + * Merges stream-stats deltas into the target channel's streams. Preserves + * object identity for unchanged streams and channels so memoized rows + * don't re-render. + */ + patchChannelStreamStats: (channelId, updates) => { + if (!Array.isArray(updates) || updates.length === 0) return; + set((state) => { + const updateMap = new Map(updates.map((u) => [u.id, u])); + let channelChanged = false; + const nextChannels = state.channels.map((channel) => { + if (channel.id !== channelId) return channel; + const streams = channel.streams || []; + let streamsChanged = false; + const nextStreams = streams.map((stream) => { + const u = updateMap.get(stream.id); + if (!u) return stream; + if ( + stream.stream_stats_updated_at === u.stream_stats_updated_at && + stream.stream_stats === u.stream_stats + ) { + return stream; + } + streamsChanged = true; + return { + ...stream, + stream_stats: u.stream_stats, + stream_stats_updated_at: u.stream_stats_updated_at, + }; + }); + if (!streamsChanged) return channel; + channelChanged = true; + return { ...channel, streams: nextStreams }; + }); + if (!channelChanged) return state; + return { channels: nextChannels }; + }); + }, })); export default useChannelsTableStore;