Compute channel size and store on Channel document

Allow viewing this information in a new Channel table

Run compute after every video download concludes
This commit is contained in:
Caleb Rogers 2026-06-17 15:11:05 +02:00
parent c4ac6441bd
commit 2cf19acdd5
10 changed files with 307 additions and 11 deletions

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()}` : ''}`;

View File

@ -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) => (
<th
onClick={() => onSort?.(column)}
style={{ cursor: 'pointer' }}
className={sortBy === column ? 'sort-active' : ''}
>
{label}
{getSortIndicator(column)}
</th>
);
if (!channelList) {
return <LoadingIndicator />;
}
@ -26,6 +55,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>
{renderSortableHeader('name', 'Channel')}
{renderSortableHeader('subscribers', 'Subscribers')}
{renderSortableHeader('video_count', 'Videos')}
{renderSortableHeader('duration', 'Duration')}
{renderSortableHeader('media_size', 'Media size')}
{renderSortableHeader('last_refresh', 'Last refreshed')}
<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_media_duration ?? '0s'}</td>
<td>{humanFileSize(channel.channel_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

@ -1,7 +1,12 @@
import { useOutletContext } from 'react-router-dom';
import loadChannelList, { ChannelsListResponse } from '../api/loader/loadChannelList';
import loadChannelList, {
ChannelsListResponse,
ChannelSortByType,
} from '../api/loader/loadChannelList';
import { SortOrderType } from '../api/loader/loadVideoListByPage';
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 +46,9 @@ export type ChannelType = {
channel_tags?: string[];
channel_thumb_url: string;
channel_tvart_url: string;
channel_media_size?: number;
channel_video_count?: number;
channel_media_duration?: number;
};
const Channels = () => {
@ -61,6 +69,19 @@ const Channels = () => {
const channels = channelListResponseData?.data;
const pagination = channelListResponseData?.paginate;
const [sortBy, setSortBy] = useState<ChannelSortByType>('name');
const [sortOrder, setSortOrder] = useState<SortOrderType>('asc');
const viewStyle = userConfig.view_style_channel;
const handleSort = (column: ChannelSortByType) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
const updatedUserConfig = await updateUserConfig(config);
const { data: updatedUserConfigData } = updatedUserConfig;
@ -72,13 +93,27 @@ const Channels = () => {
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 (
<>
@ -194,11 +229,28 @@ 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>
<div className={`channel-list ${userConfig.view_style_channel}`}>
<ChannelList channelList={channels} refreshChannelList={setRefresh} />
<ChannelList
channelList={channels}
refreshChannelList={setRefresh}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
/>
</div>
{pagination && (

View File

@ -1078,6 +1078,10 @@ video:-webkit-full-screen {
gap: 1rem;
}
.channel-list.table {
display: block;
}
.channel-item.list {
padding-bottom: 1rem;
}
@ -1086,6 +1090,52 @@ video:-webkit-full-screen {
display: block;
}
.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-banner img {
width: 100%;
}