Add table for channels

This commit is contained in:
Caleb Rogers 2026-01-28 22:52:46 +08:00
parent c4ac6441bd
commit d8afde8ef4
6 changed files with 230 additions and 3 deletions

View File

@ -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
)

View File

@ -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)

View File

@ -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
)

View File

@ -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 <LoadingIndicator />;
@ -26,6 +30,84 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
return <h2>No channels found...</h2>;
}
if (viewStyle === ViewStylesEnum.Table) {
return (
<div className={`channel-item ${viewStyle}`}>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Subscribers</th>
<th>Videos</th>
<th>Duration</th>
<th>Media size</th>
<th>Last refreshed</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{channelList.map(channel => {
return (
<tr key={channel.channel_id}>
<td className="no-nowrap">
<div className="channel-table-title">
<div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelIcon
channelId={channel.channel_id}
channelThumbUrl={channel.channel_thumb_url}
/>
</Link>
</div>
<div>
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</div>
</div>
</td>
<td>
{channel.channel_subs !== null ? (
<FormattedNumber text="" number={channel.channel_subs} />
) : (
'-'
)}
</td>
<td>{channel.channel_video_count ?? 0}</td>
<td>{channel.channel_video_duration_str ?? '0s'}</td>
<td>{humanFileSize(channel.channel_video_media_size ?? 0, useSiUnits)}</td>
<td>{formatDate(channel.channel_last_refresh)}</td>
<td>
{channel.channel_subscribed ? (
<Button
label="Unsubscribe"
className="unsubscribe"
type="button"
title={`Unsubscribe from ${channel.channel_name}`}
onClick={async () => {
await updateChannelSubscription(channel.channel_id, false);
refreshChannelList(true);
}}
/>
) : (
<Button
label="Subscribe"
type="button"
title={`Subscribe to ${channel.channel_name}`}
onClick={async () => {
await updateChannelSubscription(channel.channel_id, true);
refreshChannelList(true);
}}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
return (
<>
{channelList.map(channel => {

View File

@ -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"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({
view_style_channel: ViewStylesEnum.Table as ViewStylesType,
});
}}
data-origin="channel"
data-value="table"
alt="table view"
/>
</div>
</div>

View File

@ -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;
}