From d8afde8ef48725b43867f06fc0a43f6ca7ff62da Mon Sep 17 00:00:00 2001 From: Caleb Rogers Date: Wed, 28 Jan 2026 22:52:46 +0800 Subject: [PATCH] Add table for channels --- backend/channel/serializers.py | 12 ++++ backend/channel/views.py | 61 ++++++++++++++++++ backend/user/serializers.py | 12 +++- frontend/src/components/ChannelList.tsx | 82 +++++++++++++++++++++++++ frontend/src/pages/Channels.tsx | 16 +++++ frontend/src/style.css | 50 +++++++++++++++ 6 files changed, 230 insertions(+), 3 deletions(-) diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index d2dc4184..22db7565 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -45,6 +45,18 @@ class ChannelSerializer(serializers.Serializer): channel_overwrites = ChannelOverwriteSerializer(required=False) channel_subs = serializers.IntegerField() channel_subscribed = serializers.BooleanField() + channel_video_count = serializers.IntegerField( + required=False, allow_null=True + ) + channel_video_duration = serializers.IntegerField( + required=False, allow_null=True + ) + channel_video_duration_str = serializers.CharField( + required=False, allow_null=True + ) + channel_video_media_size = serializers.IntegerField( + required=False, allow_null=True + ) channel_tags = serializers.ListField( child=serializers.CharField(), required=False ) diff --git a/backend/channel/views.py b/backend/channel/views.py index 204aaf73..c9da6259 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -12,6 +12,8 @@ from channel.serializers import ( from channel.src.index import YoutubeChannel, channel_overwrites from channel.src.nav import ChannelNav from common.serializers import ErrorResponseSerializer +from common.src.es_connect import ElasticWrap +from common.src.helper import get_duration_str from common.src.urlparser import Parser from common.views_base import AdminWriteOnly, ApiBaseView from drf_spectacular.utils import ( @@ -24,6 +26,48 @@ from task.tasks import index_channel_playlists, subscribe_to class ChannelApiListView(ApiBaseView): + @staticmethod + def _get_channel_video_stats(channel_ids): + if not channel_ids: + return {} + + data = { + "size": 0, + "query": {"terms": {"channel.channel_id": channel_ids}}, + "aggs": { + "channel_stats": { + "multi_terms": { + "size": len(channel_ids), + "terms": [ + {"field": "channel.channel_id"}, + {"field": "channel.channel_name.keyword"}, + ], + }, + "aggs": { + "doc_count": {"value_count": {"field": "_index"}}, + "duration": {"sum": {"field": "player.duration"}}, + "media_size": {"sum": {"field": "media_size"}}, + }, + } + }, + } + response, _ = ElasticWrap("ta_video/_search").get(data=data) + buckets = ( + response.get("aggregations", {}) + .get("channel_stats", {}) + .get("buckets", []) + ) + stats = {} + for bucket in buckets: + duration_value = int(bucket["duration"]["value"]) + stats[bucket["key"][0]] = { + "channel_video_count": int(bucket["doc_count"]["value"]), + "channel_video_duration": duration_value, + "channel_video_duration_str": get_duration_str(duration_value), + "channel_video_media_size": int(bucket["media_size"]["value"]), + } + return stats + """resolves to /api/channel/ GET: returns list of channels POST: edit a list of channels @@ -59,6 +103,23 @@ class ChannelApiListView(ApiBaseView): self.data["query"] = {"bool": {"must": must_list}} self.get_document_list(request) + if self.response.get("data"): + channel_ids = [ + channel["channel_id"] for channel in self.response["data"] + ] + channel_stats = self._get_channel_video_stats(channel_ids) + for channel in self.response["data"]: + channel.update( + channel_stats.get( + channel["channel_id"], + { + "channel_video_count": 0, + "channel_video_duration": 0, + "channel_video_duration_str": get_duration_str(0), + "channel_video_media_size": 0, + }, + ) + ) serializer = ChannelListSerializer(self.response) return Response(serializer.data) diff --git a/backend/user/serializers.py b/backend/user/serializers.py index 4a54aa4c..494462ad 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -34,9 +34,15 @@ class UserMeConfigSerializer(serializers.Serializer): view_style_home = serializers.ChoiceField( choices=["grid", "list", "table"] ) - view_style_channel = serializers.ChoiceField(choices=["grid", "list"]) - view_style_downloads = serializers.ChoiceField(choices=["grid", "list"]) - view_style_playlist = serializers.ChoiceField(choices=["grid", "list"]) + view_style_channel = serializers.ChoiceField( + choices=["grid", "list", "table"] + ) + view_style_downloads = serializers.ChoiceField( + choices=["grid", "list", "table"] + ) + view_style_playlist = serializers.ChoiceField( + choices=["grid", "list", "table"] + ) vid_type_filter = serializers.ChoiceField( choices=VideoTypeEnum.values_known(), allow_null=True ) diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx index 7df96c8e..e94981c3 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -3,12 +3,15 @@ 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'; type ChannelListProps = { channelList: ChannelType[] | undefined; @@ -18,6 +21,7 @@ type ChannelListProps = { const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { const { userConfig } = useUserConfigStore(); const viewStyle = userConfig.view_style_channel; + const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric; if (!channelList) { return ; @@ -26,6 +30,84 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { return

No channels found...

; } + if (viewStyle === ViewStylesEnum.Table) { + return ( +
+ + + + + + + + + + + + + + {channelList.map(channel => { + return ( + + + + + + + + + + ); + })} + +
ChannelSubscribersVideosDurationMedia sizeLast refreshedStatus
+
+
+ + + +
+
+ {channel.channel_name} +
+
+
+ {channel.channel_subs !== null ? ( + + ) : ( + '-' + )} + {channel.channel_video_count ?? 0}{channel.channel_video_duration_str ?? '0s'}{humanFileSize(channel.channel_video_media_size ?? 0, useSiUnits)}{formatDate(channel.channel_last_refresh)} + {channel.channel_subscribed ? ( +
+
+ ); + } + return ( <> {channelList.map(channel => { diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index e5b7212d..80f1a932 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -2,6 +2,7 @@ import { useOutletContext } from 'react-router-dom'; import loadChannelList, { ChannelsListResponse } 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'; import iconAdd from '/img/icon-add.svg'; import iconFilter from '/img/icon-filter.svg'; import { useEffect, useState } from 'react'; @@ -41,6 +42,10 @@ export type ChannelType = { channel_tags?: string[]; channel_thumb_url: string; channel_tvart_url: string; + channel_video_count?: number; + channel_video_duration?: number; + channel_video_duration_str?: string; + channel_video_media_size?: number; }; const Channels = () => { @@ -194,6 +199,17 @@ const Channels = () => { data-value="list" alt="list view" /> + { + handleUserConfigUpdate({ + view_style_channel: ViewStylesEnum.Table as ViewStylesType, + }); + }} + data-origin="channel" + data-value="table" + alt="table view" + /> diff --git a/frontend/src/style.css b/frontend/src/style.css index 7db5d0d2..bef24c36 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1078,10 +1078,60 @@ video:-webkit-full-screen { gap: 1rem; } +.channel-list.table { + display: block; +} + .channel-item.list { padding-bottom: 1rem; } +.channel-item.table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.channel-item.table table { + min-width: 700px; + width: 100%; + border-collapse: collapse; + position: relative; +} + +.channel-item.table th, +.channel-item.table td { + padding: 8px 12px; + text-align: left; + white-space: nowrap; +} + +.channel-item.table th { + font-weight: 600; + border-bottom: 1px solid #ddd; +} + +.channel-item.table td { + border-bottom: 1px solid #eee; +} + +.channel-item.table td.no-nowrap { + white-space: normal; + word-break: keep-all; + min-width: 160px; +} + +.channel-item.table td img, +.channel-item.table th img { + width: 40px; + height: 40px; +} + +.channel-table-title { + display: inline-flex; + align-items: center; + gap: 10px; +} + .channel-item.grid > .info-box { display: block; }