diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index 22db7565..b4aa7fdc 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -81,6 +81,24 @@ class ChannelListQuerySerializer(serializers.Serializer): choices=["subscribed", "unsubscribed"], required=False ) page = serializers.IntegerField(required=False) + view = serializers.ChoiceField( + choices=["grid", "list", "table"], required=False + ) + sort = serializers.ChoiceField( + choices=[ + "name", + "subscribers", + "video_count", + "duration", + "media_size", + "last_refresh", + ], + required=False, + default="name", + ) + order = serializers.ChoiceField( + choices=["asc", "desc"], required=False, default="asc" + ) class ChannelUpdateSerializer(serializers.Serializer): diff --git a/backend/channel/views.py b/backend/channel/views.py index c9da6259..b4c6500f 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -85,16 +85,21 @@ class ChannelApiListView(ApiBaseView): ) def get(self, request): """get request""" - self.data.update( - {"sort": [{"channel_name.keyword": {"order": "asc"}}]} - ) - serializer = ChannelListQuerySerializer(data=request.query_params) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data - must_list = [] + view = validated_data.get("view") + sort = validated_data.get("sort", "name") + order = validated_data.get("order", "asc") query_filter = validated_data.get("filter") + + # Aggregation-based sort fields + agg_sorts = ["video_count", "duration", "media_size"] + needs_aggregations = view == "table" or sort in agg_sorts + + # Build filter + must_list = [] if query_filter is not None: channel_subscribed = query_filter == "subscribed" must_list.append( @@ -102,8 +107,23 @@ class ChannelApiListView(ApiBaseView): ) self.data["query"] = {"bool": {"must": must_list}} + + # Apply ES-level sort for non-aggregation fields + sort_field_map = { + "name": "channel_name.keyword", + "subscribers": "channel_subs", + "last_refresh": "channel_last_refresh", + } + if sort in sort_field_map: + self.data["sort"] = [{sort_field_map[sort]: {"order": order}}] + else: + # For aggregation-based sorts, use default ES sort first + self.data["sort"] = [{"channel_name.keyword": {"order": "asc"}}] + self.get_document_list(request) - if self.response.get("data"): + + # Conditionally compute aggregations + if self.response.get("data") and needs_aggregations: channel_ids = [ channel["channel_id"] for channel in self.response["data"] ] @@ -120,10 +140,30 @@ class ChannelApiListView(ApiBaseView): }, ) ) + + # Sort by aggregated field in Python + if sort in agg_sorts: + self._sort_by_aggregation(sort, order) + serializer = ChannelListSerializer(self.response) return Response(serializer.data) + def _sort_by_aggregation(self, sort: str, order: str): + """Sort response data by aggregated field""" + sort_key_map = { + "video_count": "channel_video_count", + "duration": "channel_video_duration", + "media_size": "channel_video_media_size", + } + key = sort_key_map.get(sort) + if key: + self.response["data"] = sorted( + self.response["data"], + key=lambda x: x.get(key, 0), + reverse=(order == "desc"), + ) + def post(self, request): """subscribe/unsubscribe to list of channels""" data = request.data diff --git a/frontend/src/api/loader/loadChannelList.ts b/frontend/src/api/loader/loadChannelList.ts index a46a9574..0aea9aa6 100644 --- a/frontend/src/api/loader/loadChannelList.ts +++ b/frontend/src/api/loader/loadChannelList.ts @@ -1,21 +1,40 @@ import { PaginationType } from '../../components/Pagination'; +import { ViewStylesType } from '../../configuration/constants/ViewStyle'; import APIClient from '../../functions/APIClient'; import { ChannelType } from '../../pages/Channels'; import { ConfigType } from '../../pages/Home'; +export type ChannelSortOptions = + | 'name' + | 'subscribers' + | 'video_count' + | 'duration' + | 'media_size' + | 'last_refresh'; +export type SortOrder = 'asc' | 'desc'; + export type ChannelsListResponse = { data: ChannelType[]; paginate: PaginationType; config?: ConfigType; }; -const loadChannelList = async (page: number, showSubscribed: boolean | null) => { +const loadChannelList = async ( + page: number, + showSubscribed: boolean | null, + viewStyle?: ViewStylesType, + sort?: ChannelSortOptions, + order?: SortOrder, +) => { const searchParams = new URLSearchParams(); if (page) searchParams.append('page', page.toString()); if (showSubscribed !== null) { searchParams.append('filter', showSubscribed ? 'subscribed' : 'unsubscribed'); } + if (viewStyle) searchParams.append('view', viewStyle); + if (sort) searchParams.append('sort', sort); + if (order) searchParams.append('order', order); const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx index e94981c3..6300a944 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -12,17 +12,43 @@ import ChannelBanner from './ChannelBanner'; import LoadingIndicator from './LoadingIndicator'; import { useUserConfigStore } from '../stores/UserConfigStore'; import { ViewStylesEnum } from '../configuration/constants/ViewStyle'; +import { ChannelSortOptions, SortOrder } from '../api/loader/loadChannelList'; type ChannelListProps = { channelList: ChannelType[] | undefined; refreshChannelList: (refresh: boolean) => void; + sortBy?: ChannelSortOptions; + sortOrder?: SortOrder; + onSort?: (column: ChannelSortOptions) => void; }; -const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { +const ChannelList = ({ + channelList, + refreshChannelList, + sortBy, + sortOrder, + onSort, +}: ChannelListProps) => { const { userConfig } = useUserConfigStore(); const viewStyle = userConfig.view_style_channel; const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric; + const getSortIndicator = (column: ChannelSortOptions) => { + if (sortBy !== column) return ''; + return sortOrder === 'asc' ? ' \u25B2' : ' \u25BC'; + }; + + const renderSortableHeader = (column: ChannelSortOptions, label: string) => ( + onSort?.(column)} + style={{ cursor: 'pointer' }} + className={sortBy === column ? 'sort-active' : ''} + > + {label} + {getSortIndicator(column)} + + ); + if (!channelList) { return ; } @@ -36,12 +62,12 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { - - - - - - + {renderSortableHeader('name', 'Channel')} + {renderSortableHeader('subscribers', 'Subscribers')} + {renderSortableHeader('video_count', 'Videos')} + {renderSortableHeader('duration', 'Duration')} + {renderSortableHeader('media_size', 'Media size')} + {renderSortableHeader('last_refresh', 'Last refreshed')} diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 80f1a932..53b4bf11 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -1,5 +1,9 @@ import { useOutletContext } from 'react-router-dom'; -import loadChannelList, { ChannelsListResponse } from '../api/loader/loadChannelList'; +import loadChannelList, { + ChannelSortOptions, + ChannelsListResponse, + SortOrder, +} from '../api/loader/loadChannelList'; import iconGridView from '/img/icon-gridview.svg'; import iconListView from '/img/icon-listview.svg'; import iconTableView from '/img/icon-tableview.svg'; @@ -60,6 +64,10 @@ const Channels = () => { const [showFilterItems, setShowFilterItems] = useState(false); const [showNotification, setShowNotification] = useState(false); const [channelsToSubscribeTo, setChannelsToSubscribeTo] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); + + const viewStyle = userConfig.view_style_channel; const { data: channelListResponseData } = channelListResponse ?? {}; @@ -75,15 +83,38 @@ const Channels = () => { } }; + const handleSort = (column: ChannelSortOptions) => { + if (sortBy === column) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(column); + setSortOrder('asc'); + } + }; + useEffect(() => { (async () => { - const channelListResponse = await loadChannelList(currentPage, userConfig.show_subed_only); + const channelListResponse = await loadChannelList( + currentPage, + userConfig.show_subed_only, + viewStyle, + sortBy, + sortOrder, + ); setChannelListResponse(channelListResponse); setShowNotification(false); setRefresh(false); })(); - }, [refresh, userConfig.show_subed_only, currentPage, pagination?.current_page]); + }, [ + refresh, + userConfig.show_subed_only, + currentPage, + pagination?.current_page, + viewStyle, + sortBy, + sortOrder, + ]); return ( <> @@ -213,8 +244,14 @@ const Channels = () => { -
- +
+
{pagination && (
ChannelSubscribersVideosDurationMedia sizeLast refreshedStatus