diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json index 79c257bf..c8f21812 100644 --- a/backend/appsettings/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -93,6 +93,15 @@ }, "channel_tabs": { "type": "keyword" + }, + "channel_media_size": { + "type": "long" + }, + "channel_video_count": { + "type": "long" + }, + "channel_media_duration": { + "type": "long" } }, "expected_set": { diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index d2dc4184..ec95de12 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -51,6 +51,9 @@ class ChannelSerializer(serializers.Serializer): channel_tabs = serializers.ListField( child=serializers.ChoiceField(VideoTypeEnum.values_known()) ) + channel_media_size = serializers.IntegerField(required=False) + channel_video_count = serializers.IntegerField(required=False) + channel_media_duration = serializers.IntegerField(required=False) _index = serializers.CharField(required=False) _score = serializers.IntegerField(required=False) @@ -69,6 +72,24 @@ class ChannelListQuerySerializer(serializers.Serializer): choices=["subscribed", "unsubscribed"], required=False ) page = serializers.IntegerField(required=False) + view = serializers.ChoiceField( + choices=["list", "grid", "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/src/index.py b/backend/channel/src/index.py index 390f9023..b337c9f2 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -168,6 +168,31 @@ class YoutubeChannel(YouTubeItem): print(f"sync to videos failed with status code {status_code}") print(response) + def update_channel_stats(self) -> None: + """Recompute and store media stats on the channel document.""" + data = { + "query": { + "term": {"channel.channel_id": {"value": self.youtube_id}} + }, + "aggs": { + "total_items": {"value_count": {"field": "youtube_id"}}, + "total_size": {"sum": {"field": "media_size"}}, + "total_duration": {"sum": {"field": "player.duration"}}, + }, + "size": 0, + } + response, _ = ElasticWrap("ta_video/_search").get(data=data) + aggs = response.get("aggregations", {}) + + update = { + "doc": { + "channel_video_count": int(aggs["total_items"]["value"]), + "channel_media_size": int(aggs["total_size"]["value"]), + "channel_media_duration": int(aggs["total_duration"]["value"]), + } + } + ElasticWrap(f"ta_channel/_update/{self.youtube_id}").post(update) + def change_subscribe(self, new_subscribe_state: bool): """change subscribe status""" if not self.json_data: diff --git a/backend/channel/views.py b/backend/channel/views.py index 204aaf73..e27aa44d 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -33,6 +33,15 @@ class ChannelApiListView(ApiBaseView): valid_filter = ["subscribed"] permission_classes = [AdminWriteOnly] + sort_field_map = { + "name": "channel_name.keyword", + "subscribers": "channel_subs", + "video_count": "channel_video_count", + "duration": "channel_media_duration", + "media_size": "channel_media_size", + "last_refresh": "channel_last_refresh", + } + @extend_schema( responses={ 200: OpenApiResponse(ChannelListSerializer()), @@ -41,14 +50,15 @@ 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 + sort_by = validated_data.get("sort", "name") + sort_order = validated_data.get("order", "asc") + es_sort_field = self.sort_field_map[sort_by] + self.data["sort"] = [{es_sort_field: {"order": sort_order}}] + must_list = [] query_filter = validated_data.get("filter") if query_filter is not None: diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index 8aaf0234..547efe02 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -87,6 +87,7 @@ class VideoDownloader(DownloaderBase): self._notify(video_data, "Move downloaded file to archive") self.move_to_archive(vid_dict) self._delete_from_pending(youtube_id) + YoutubeChannel(channel_id).update_channel_stats() downloaded += 1 # post processing diff --git a/backend/user/serializers.py b/backend/user/serializers.py index 4a54aa4c..d1727a82 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -34,7 +34,9 @@ class UserMeConfigSerializer(serializers.Serializer): view_style_home = serializers.ChoiceField( choices=["grid", "list", "table"] ) - view_style_channel = serializers.ChoiceField(choices=["grid", "list"]) + view_style_channel = serializers.ChoiceField( + choices=["grid", "list", "table"] + ) view_style_downloads = serializers.ChoiceField(choices=["grid", "list"]) view_style_playlist = serializers.ChoiceField(choices=["grid", "list"]) vid_type_filter = serializers.ChoiceField( diff --git a/frontend/src/api/loader/loadChannelList.ts b/frontend/src/api/loader/loadChannelList.ts index a46a9574..ec5f3cb2 100644 --- a/frontend/src/api/loader/loadChannelList.ts +++ b/frontend/src/api/loader/loadChannelList.ts @@ -1,7 +1,9 @@ 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'; +import { SortOrderType } from './loadVideoListByPage'; export type ChannelsListResponse = { data: ChannelType[]; @@ -9,13 +11,30 @@ export type ChannelsListResponse = { config?: ConfigType; }; -const loadChannelList = async (page: number, showSubscribed: boolean | null) => { +export type ChannelSortByType = + | 'name' + | 'subscribers' + | 'video_count' + | 'duration' + | 'media_size' + | 'last_refresh'; + +const loadChannelList = async ( + page: number, + showSubscribed: boolean | null, + viewStyle?: ViewStylesType, + sort?: ChannelSortByType, + order?: SortOrderType, +) => { 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 7df96c8e..40a94faf 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -3,22 +3,51 @@ import { ChannelType } from '../pages/Channels'; import Routes from '../configuration/routes/RouteList'; import updateChannelSubscription from '../api/actions/updateChannelSubscription'; import formatDate from '../functions/formatDates'; +import humanFileSize from '../functions/humanFileSize'; +import { FileSizeUnits } from '../api/actions/updateUserConfig'; import FormattedNumber from './FormattedNumber'; import Button from './Button'; import ChannelIcon from './ChannelIcon'; import ChannelBanner from './ChannelBanner'; import LoadingIndicator from './LoadingIndicator'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import { ViewStylesEnum } from '../configuration/constants/ViewStyle'; +import { ChannelSortByType } from '../api/loader/loadChannelList'; +import { SortOrderType } from '../api/loader/loadVideoListByPage'; type ChannelListProps = { channelList: ChannelType[] | undefined; refreshChannelList: (refresh: boolean) => void; + sortBy?: ChannelSortByType; + sortOrder?: SortOrderType; + onSort?: (column: ChannelSortByType) => 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: ChannelSortByType) => { + if (sortBy !== column) return ''; + return sortOrder === 'asc' ? ' \u25B2' : ' \u25BC'; + }; + const renderSortableHeader = (column: ChannelSortByType, label: string) => ( +
| Status | +||||||
|---|---|---|---|---|---|---|
|
+
+
+
+
+
+
+ {channel.channel_name}
+
+ |
+
+ {channel.channel_subs !== null ? (
+ |
+ {channel.channel_video_count ?? 0} | +{channel.channel_media_duration ?? '0s'} | +{humanFileSize(channel.channel_media_size ?? 0, useSiUnits)} | +{formatDate(channel.channel_last_refresh)} | ++ {channel.channel_subscribed ? ( + | +