Video index rebuild, #build

Changed:
- Fixed publish date indexing and sorting
- Bump django, fixing forward auth
- Fix task command serialization
- Improved subtitle selection
- Added table view layout
This commit is contained in:
Simon 2025-06-05 10:33:43 +07:00
commit b7b6ae0216
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
30 changed files with 401 additions and 184 deletions

View File

@ -239,7 +239,8 @@
"type": "keyword"
},
"published": {
"type": "date"
"type": "date",
"format": "epoch_second||strict_date_optional_time"
},
"playlist": {
"type": "text",

View File

@ -69,6 +69,7 @@ class NotificationSerializer(serializers.Serializer):
level = serializers.ChoiceField(choices=["info", "error"])
messages = serializers.ListField(child=serializers.CharField())
progress = serializers.FloatField(required=False)
command = serializers.ChoiceField(choices=["STOP", "KILL"], required=False)
class NotificationQueryFilterSerializer(serializers.Serializer):

View File

@ -1,10 +1,10 @@
-r requirements.txt
ipython==9.2.0
ipython==9.3.0
pre-commit==4.2.0
pylint-django==2.6.1
pylint==3.3.7
pytest-django==4.11.1
pytest==8.3.5
pytest==8.4.0
python-dotenv==1.1.0
requirementscheck==0.0.6
types-requests==2.32.0.20250515
types-requests==2.32.0.20250602

View File

@ -1,15 +1,15 @@
apprise==1.9.3
celery==5.5.2
celery==5.5.3
django-auth-ldap==5.2.0
django-celery-beat==2.8.0
django-celery-beat==2.8.1
django-cors-headers==4.7.0
Django==5.2.1
Django==5.2.2
djangorestframework==3.16.0
drf-spectacular==0.28.0
Pillow==11.2.1
redis==6.1.0
redis==6.2.0
requests==2.32.3
ryd-client==0.0.6
uvicorn==0.34.2
uvicorn==0.34.3
whitenoise==6.9.0
yt-dlp[default]==2025.5.22

View File

@ -79,7 +79,7 @@ class BaseTask(Task):
message.update({"level": level, "id": task_id})
task_result = TaskManager().get_task(task_id)
if task_result:
command = task_result.get("command", False)
command = task_result.get("command", None)
message.update({"command": command})
key = f"message:{message.get('group')}:{task_id.split('-')[0]}"

View File

@ -31,7 +31,9 @@ class UserMeConfigSerializer(serializers.Serializer):
page_size = serializers.IntegerField()
sort_by = serializers.ChoiceField(choices=SortEnum.names())
sort_order = serializers.ChoiceField(choices=OrderEnum.values())
view_style_home = serializers.ChoiceField(choices=["grid", "list"])
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 File

@ -31,6 +31,8 @@ class SortEnum(enum.Enum):
LIKES = "stats.like_count"
DURATION = "player.duration"
MEDIASIZE = "media_size"
WIDTH = "streams.width"
HEIGHT = "streams.height"
@classmethod
def values(cls) -> list[str]:

View File

@ -7,12 +7,15 @@ functionality:
import json
import os
import re
from datetime import datetime
from operator import itemgetter
import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.helper import requests_headers
from common.src.helper import rand_sleep, requests_headers
from yt_dlp.utils import orderedSet_from_options
class YoutubeSubtitle:
@ -35,85 +38,67 @@ class YoutubeSubtitle:
# no subtitles
return False
relevant_subtitles = []
for lang in self.languages:
user_sub = self._get_user_subtitles(lang)
if user_sub:
relevant_subtitles.append(user_sub)
continue
available_subtitles = self._get_all_subtitles("user")
if self.video.config["downloads"]["subtitle_source"] == "auto":
for lang, auto_cap in self._get_all_subtitles("auto").items():
if lang not in available_subtitles:
available_subtitles[lang] = auto_cap
if self.video.config["downloads"]["subtitle_source"] == "auto":
auto_cap = self._get_auto_caption(lang)
if auto_cap:
relevant_subtitles.append(auto_cap)
all_sub_langs = tuple(available_subtitles.keys())
relevant_subtitles = False
try:
relevant_subtitles = [
available_subtitles[lang]
for lang in orderedSet_from_options(
self.languages, {"all": all_sub_langs}, use_regex=True
)
]
except re.error as e:
raise ValueError(f"wrong regex in subtitle config: {e.pattern}")
return relevant_subtitles
def _get_auto_caption(self, lang):
"""get auto_caption subtitles"""
print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles")
all_subtitles = self.video.youtube_meta.get("automatic_captions")
def _get_all_subtitles(self, source):
"""get video subtitles or automatic captions"""
print(f"{self.video.youtube_id}: get {source} subtitles")
youtube_meta_keys = {"user": "subtitles", "auto": "automatic_captions"}
if not (youtube_meta_key := youtube_meta_keys.get(source, None)):
raise ValueError(f"unknown subtitles source: {source}")
all_subtitles = self.video.youtube_meta.get(youtube_meta_key)
if not all_subtitles:
return False
return {}
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
all_formats = all_subtitles.get(lang)
if not all_formats:
return False
subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
if not subtitle_json3:
print(f"{self.video.youtube_id}-{lang}: json3 not processed")
return False
subtitle = subtitle_json3[0]
subtitle.update(
{"lang": lang, "source": "auto", "media_url": media_url}
)
return subtitle
def _normalize_lang(self):
"""normalize country specific language keys"""
all_subtitles = self.video.youtube_meta.get("subtitles")
if not all_subtitles:
return False
all_keys = list(all_subtitles.keys())
for key in all_keys:
lang = key.split("-")[0]
old = all_subtitles.pop(key)
candidate_subtitles = {}
for lang, all_formats in all_subtitles.items():
if lang == "live_chat":
# not supported yet
continue
all_subtitles[lang] = old
return all_subtitles
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
if not all_formats:
# no subtitles found
continue
def _get_user_subtitles(self, lang):
"""get subtitles uploaded from channel owner"""
print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles")
all_subtitles = self._normalize_lang()
if not all_subtitles:
return False
subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
if not subtitle_json3:
print(f"{self.video.youtube_id}-{lang}: json3 not processed")
continue
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
all_formats = all_subtitles.get(lang)
if not all_formats:
# no user subtitles found
return False
subtitle = subtitle_json3[0]
subtitle.update(
{"lang": lang, "source": source, "media_url": media_url}
)
candidate_subtitles[lang] = subtitle
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update(
{"lang": lang, "source": "user", "media_url": media_url}
)
return subtitle
return candidate_subtitles
def download_subtitles(self, relevant_subtitles):
"""download subtitle files to archive"""
subtitle_list = ", ".join(map(itemgetter("lang"), relevant_subtitles))
print(
f"{self.video.youtube_id}: downloading subtitles: {subtitle_list}"
)
videos_base = EnvironmentSettings.MEDIA_DIR
indexed = []
for subtitle in relevant_subtitles:
@ -124,17 +109,21 @@ class YoutubeSubtitle:
subtitle["url"], headers=requests_headers(), timeout=30
)
if not response.ok:
print(f"{self.video.youtube_id}: failed to download subtitle")
subtitle_key = f"{self.video.youtube_id}-{lang}"
print(f"{subtitle_key}: failed to download subtitle")
print(response.text)
rand_sleep(self.video.config)
continue
if not response.text:
print(f"{self.video.youtube_id}: skip empty subtitle")
print(f"{subtitle_key}: skip empty subtitle")
rand_sleep(self.video.config)
continue
parser = SubtitleParser(response.text, lang, source)
parser.process()
if not parser.all_cues:
rand_sleep(self.video.config)
continue
subtitle_str = parser.get_subtitle_str()
@ -144,6 +133,7 @@ class YoutubeSubtitle:
self._index_subtitle(query_str)
indexed.append(subtitle)
rand_sleep(self.video.config)
return indexed

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" width="960pt" height="960pt" viewBox="0 0 960 960" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0,960)scale(.075,.075)">
<path id="path1" d="M 740 -10481 c -77 24 -152 94 -170 160 -6 23 -10 376 -10 946 l 0 910 24 50 c 28 59 66 97 121 122 38 17 268 18 5620 21 3923 1 5595 -1 5632 -8 70 -15 124 -56 157 -121 l 27 -54 0 -920 c 0 -637 -4 -932 -11 -958 -17 -58 -84 -122 -150 -141 -49 -15 -544 -16 -5635 -15 -3069 0 -5591 4 -5605 8 z M 9615 -7400 c -11 4 -31 20 -45 35 l -25 27 -3 889 c -3 978 -5 935 57 972 27 16 117 17 1253 17 1321 0 1252 3 1293 -54 20 -27 20 -43 20 -919 0 -860 -1 -893 -19 -920 -41 -60 37 -57 -1293 -56 -670 0 -1227 4 -1238 9 z M 3658 -7384 c -61 32 -58 -10 -58 959 0 973 -3 928 60 960 25 13 178 15 1250 15 1072 0 1225 -2 1250 -15 63 -32 60 13 60 -960 0 -973 3 -928 -60 -960 -25 -13 -178 -15 -1252 -15 -1062 1 -1227 3 -1250 16 z M 6551 -7374 c -17 14 -35 42 -41 62 -6 24 -10 339 -10 892 0 826 1 856 20 898 35 78 -60 73 1315 70 l 1225 -3 32 -33 33 -32 3 -895 c 2 -788 0 -899 -13 -925 -33 -64 48 -60 -1304 -60 l -1229 0 -31 26 z M 616 -7369 c -59 46 -56 0 -56 951 0 976 -4 924 70 960 33 17 113 18 1250 18 1187 0 1216 -1 1247 -20 66 -40 63 12 63 -955 0 -791 -2 -880 -16 -911 -33 -68 54 -64 -1302 -64 l -1229 0 -27 21 z M 9615 -4530 c -11 4 -31 20 -45 35 l -25 27 -3 901 c -2 891 -2 902 18 935 40 65 -32 62 1296 62 1335 0 1255 4 1292 -64 16 -29 17 -101 17 -921 0 -832 -1 -892 -18 -922 -36 -67 50 -63 -1294 -62 -670 0 -1227 4 -1238 9 z M 3664 -4514 c -68 33 -64 -26 -64 973 l 0 901 34 38 34 37 1242 0 1242 0 34 -37 34 -38 0 -900 c 0 -989 3 -942 -60 -975 -25 -13 -178 -15 -1247 -15 -1064 0 -1222 2 -1249 16 z M 6551 -4504 c -17 14 -35 42 -41 62 -14 51 -14 1733 0 1784 6 20 24 48 41 62 l 31 26 1237 0 c 1360 0 1263 5 1296 -60 23 -44 23 -1796 0 -1840 -33 -64 47 -60 -1304 -60 l -1229 0 -31 26 z M 615 -4498 c -57 44 -55 13 -55 963 0 844 1 877 19 913 11 21 31 43 46 50 20 9 323 12 1260 12 l 1233 0 31 -30 c 17 -17 33 -45 36 -63 3 -18 4 -429 3 -915 l -3 -884 -37 -34 -38 -34 -1234 0 -1233 0 -28 22 z " />
</g>
</svg>

View File

@ -1,5 +1,6 @@
import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home';
import { ViewStylesType } from '../../configuration/constants/ViewStyle';
import APIClient from '../../functions/APIClient';
import { SortByType, SortOrderType } from '../loader/loadVideoListByPage';
export type ColourVariants =
| 'dark.css'
@ -26,10 +27,10 @@ export type UserConfigType = {
page_size: number;
sort_by: SortByType;
sort_order: SortOrderType;
view_style_home: ViewLayoutType;
view_style_channel: ViewLayoutType;
view_style_downloads: ViewLayoutType;
view_style_playlist: ViewLayoutType;
view_style_home: ViewStylesType;
view_style_channel: ViewStylesType;
view_style_downloads: ViewStylesType;
view_style_playlist: ViewStylesType;
grid_items: number;
hide_watched: boolean;
file_size_unit: 'binary' | 'metric';

View File

@ -1,4 +1,4 @@
import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home';
import { ConfigType, VideoType } from '../../pages/Home';
import { PaginationType } from '../../components/Pagination';
import APIClient from '../../functions/APIClient';
@ -8,9 +8,41 @@ export type VideoListByFilterResponseType = {
paginate?: PaginationType;
};
type WatchTypes = 'watched' | 'unwatched' | 'continue';
export type SortByType =
| 'published'
| 'downloaded'
| 'views'
| 'likes'
| 'duration'
| 'mediasize'
| 'width'
| 'height';
export const SortByEnum = {
Published: 'published',
Downloaded: 'downloaded',
Views: 'views',
Likes: 'likes',
Duration: 'duration',
'Media Size': 'mediasize',
Width: 'width',
Height: 'height',
};
export type SortOrderType = 'asc' | 'desc';
export const SortOrderEnum = {
Asc: 'asc',
Desc: 'desc',
};
export type VideoTypes = 'videos' | 'streams' | 'shorts';
export type WatchTypes = 'watched' | 'unwatched' | 'continue';
export const WatchTypesEnum = {
Watched: 'watched',
Unwatched: 'unwatched',
Continue: 'continue',
};
type FilterType = {
page?: number;
playlist?: string;

View File

@ -16,7 +16,7 @@ type ChannelListProps = {
const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
const { userConfig } = useUserConfigStore();
const viewLayout = userConfig.view_style_channel;
const viewStyle = userConfig.view_style_channel;
if (!channelList || channelList.length === 0) {
return <p>No channels found.</p>;
@ -26,8 +26,8 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
<>
{channelList.map(channel => {
return (
<div key={channel.channel_id} className={`channel-item ${viewLayout}`}>
<div className={`channel-banner ${viewLayout}`}>
<div key={channel.channel_id} className={`channel-item ${viewStyle}`}>
<div className={`channel-banner ${viewStyle}`}>
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelBanner
channelId={channel.channel_id}
@ -35,7 +35,7 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
/>
</Link>
</div>
<div className={`info-box info-box-2 ${viewLayout}`}>
<div className={`info-box info-box-2 ${viewStyle}`}>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}>

View File

@ -16,14 +16,14 @@ type DownloadListItemProps = {
const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
const { userConfig } = useUserConfigStore();
const view = userConfig.view_style_downloads;
const viewStyle = userConfig.view_style_downloads;
const showIgnored = userConfig.show_ignored_only;
const [hideDownload, setHideDownload] = useState(false);
return (
<div className={`video-item ${view}`} id={`dl-${download.youtube_id}`}>
<div className={`video-thumb-wrap ${view}`}>
<div className={`video-item ${viewStyle}`} id={`dl-${download.youtube_id}`}>
<div className={`video-thumb-wrap ${viewStyle}`}>
<div className="video-thumb">
<VideoThumbnail videoThumbUrl={download.vid_thumb_url} />
@ -39,7 +39,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
</div>
</div>
<div className={`video-desc ${view}`}>
<div className={`video-desc ${viewStyle}`}>
<div>
{download.channel_indexed && (
<Link to={Routes.Channel(download.channel_id)}>{download.channel_name}</Link>

View File

@ -1,24 +1,45 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import iconSort from '/img/icon-sort.svg';
import iconAdd from '/img/icon-add.svg';
import iconSubstract from '/img/icon-substract.svg';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import { SortByType, SortOrderType } from '../pages/Home';
import iconTableView from '/img/icon-tableview.svg';
import { useUserConfigStore } from '../stores/UserConfigStore';
import { ViewStyles } from '../configuration/constants/ViewStyle';
import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import {
SortByEnum,
SortByType,
SortOrderEnum,
SortOrderType,
} from '../api/loader/loadVideoListByPage';
type FilterbarProps = {
hideToggleText: string;
viewStyleName: string;
viewStyle: ViewStyleNamesType;
showSort?: boolean;
};
const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: FilterbarProps) => {
const Filterbar = ({ hideToggleText, viewStyle, showSort = true }: FilterbarProps) => {
const { userConfig, setUserConfig } = useUserConfigStore();
const [showHidden, setShowHidden] = useState(false);
const isGridView = userConfig.view_style_home === ViewStyles.grid;
const currentViewStyle = userConfig[viewStyle];
const isGridView = currentViewStyle === ViewStylesEnum.Grid;
useEffect(() => {
if (!showSort) {
return;
}
if (currentViewStyle === ViewStylesEnum.Table) {
setShowHidden(true);
} else {
setShowHidden(false);
}
}, [currentViewStyle, showSort]);
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
const updatedUserConfig = await updateUserConfig(config);
@ -55,7 +76,7 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
</div>
</div>
{showHidden && showSort && (
{showHidden && (
<div className="sort">
<div id="form">
<span>Sort by:</span>
@ -67,12 +88,9 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
handleUserConfigUpdate({ sort_by: event.target.value as SortByType });
}}
>
<option value="published">date published</option>
<option value="downloaded">date downloaded</option>
<option value="views">views</option>
<option value="likes">likes</option>
<option value="duration">duration</option>
<option value="mediasize">media size</option>
{Object.entries(SortByEnum).map(([key, value]) => {
return <option value={value}>{key}</option>;
})}
</select>
<select
name="sort_order"
@ -82,19 +100,20 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
}}
>
<option value="asc">asc</option>
<option value="desc">desc</option>
{Object.entries(SortOrderEnum).map(([key, value]) => {
return <option value={value}>{key}</option>;
})}
</select>
</div>
</div>
)}
<div className="view-icons">
{setShowHidden && showSort && (
{showSort && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden?.(!showHidden);
setShowHidden(!showHidden);
}}
id="animate-icon"
/>
@ -125,17 +144,24 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyleName]: 'grid' });
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyleName]: 'list' });
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div>
</div>
);

View File

@ -14,7 +14,7 @@ type PlaylistListProps = {
const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
const { userConfig } = useUserConfigStore();
const viewLayout = userConfig.view_style_playlist;
const viewStyle = userConfig.view_style_playlist;
if (!playlistList || playlistList.length === 0) {
return <p>No playlists found.</p>;
@ -24,7 +24,7 @@ const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
<>
{playlistList.map((playlist: PlaylistType) => {
return (
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
<div key={playlist.playlist_id} className={`playlist-item ${viewStyle}`}>
<div className="playlist-thumbnail">
<Link to={Routes.Playlist(playlist.playlist_id)}>
<PlaylistThumbnail
@ -33,7 +33,7 @@ const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
/>
</Link>
</div>
<div className={`playlist-desc ${viewLayout}`}>
<div className={`playlist-desc ${viewStyle}`}>
{playlist.playlist_type != 'custom' && (
<Link to={Routes.Channel(playlist.playlist_channel_id)}>
<h3>{playlist.playlist_channel}</h3>

View File

@ -1,9 +1,11 @@
import { VideoType, ViewLayoutType } from '../pages/Home';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
import { VideoType } from '../pages/Home';
import VideoListItem from './VideoListItem';
import VideoListItemTable from './VideoListItemTable';
type VideoListProps = {
videoList: VideoType[] | undefined;
viewLayout: ViewLayoutType;
viewStyle: ViewStylesType;
playlistId?: string;
showReorderButton?: boolean;
refreshVideoList: (refresh: boolean) => void;
@ -11,7 +13,7 @@ type VideoListProps = {
const VideoList = ({
videoList,
viewLayout,
viewStyle,
playlistId,
showReorderButton = false,
refreshVideoList,
@ -20,6 +22,10 @@ const VideoList = ({
return <p>No videos found.</p>;
}
if (viewStyle === ViewStylesEnum.Table) {
return <VideoListItemTable videoList={videoList} viewStyle={viewStyle} />;
}
return (
<>
{videoList.map(video => {
@ -27,7 +33,7 @@ const VideoList = ({
<VideoListItem
key={video.youtube_id}
video={video}
viewLayout={viewLayout}
viewStyle={viewStyle}
playlistId={playlistId}
showReorderButton={showReorderButton}
refreshVideoList={refreshVideoList}

View File

@ -1,6 +1,6 @@
import { Link, useSearchParams } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { VideoType, ViewLayoutType } from '../pages/Home';
import { VideoType } from '../pages/Home';
import iconPlay from '/img/icon-play.svg';
import iconDotMenu from '/img/icon-dot-menu.svg';
import iconClose from '/img/icon-close.svg';
@ -11,10 +11,11 @@ import MoveVideoMenu from './MoveVideoMenu';
import { useState } from 'react';
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
import VideoThumbnail from './VideoThumbail';
import { ViewStylesType } from '../configuration/constants/ViewStyle';
type VideoListItemProps = {
video: VideoType;
viewLayout: ViewLayoutType;
viewStyle: ViewStylesType;
playlistId?: string;
showReorderButton?: boolean;
refreshVideoList: (refresh: boolean) => void;
@ -22,7 +23,7 @@ type VideoListItemProps = {
const VideoListItem = ({
video,
viewLayout,
viewStyle,
playlistId,
showReorderButton = false,
refreshVideoList,
@ -36,7 +37,7 @@ const VideoListItem = ({
}
return (
<div className={`video-item ${viewLayout}`}>
<div className={`video-item ${viewStyle}`}>
<a
onClick={() => {
setSearchParams(params => {
@ -46,7 +47,7 @@ const VideoListItem = ({
});
}}
>
<div className={`video-thumb-wrap ${viewLayout}`}>
<div className={`video-thumb-wrap ${viewStyle}`}>
<div className="video-thumb">
<VideoThumbnail videoThumbUrl={video.vid_thumb_url} />
@ -72,7 +73,7 @@ const VideoListItem = ({
</div>
</div>
</a>
<div className={`video-desc ${viewLayout}`}>
<div className={`video-desc ${viewStyle}`}>
<div className="video-desc-player" id={`video-info-${video.youtube_id}`}>
<WatchedCheckBox
watched={video.player.watched}

View File

@ -0,0 +1,64 @@
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { VideoType } from '../pages/Home';
import { ViewStylesType } from '../configuration/constants/ViewStyle';
import humanFileSize from '../functions/humanFileSize';
import { FileSizeUnits } from '../api/actions/updateUserConfig';
import { useUserConfigStore } from '../stores/UserConfigStore';
type VideoListItemProps = {
videoList: VideoType[] | undefined;
viewStyle: ViewStylesType;
};
const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
const { userConfig } = useUserConfigStore();
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
return (
<div className={`video-item ${viewStyle}`}>
<table>
<thead>
<tr>
<th>Channel</th>
<th>Title</th>
<th>Type</th>
<th>Resolution</th>
<th>Media size</th>
<th>Video codec</th>
<th>Video bitrate</th>
<th>Audio codec</th>
<th>Audio bitrate</th>
</tr>
</thead>
<tbody>
{videoList?.map(({ youtube_id, title, channel, vid_type, media_size, streams }) => {
const [videoStream, audioStream] = streams;
return (
<tr key={youtube_id}>
<td className="no-nowrap">
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</td>
<td className="no-nowrap title">
<Link to={Routes.Video(youtube_id)}>{title}</Link>
</td>
<td>{vid_type}</td>
<td>{`${videoStream.width}x${videoStream.height}`}</td>
<td>{humanFileSize(media_size, useSiUnits)}</td>
<td>{videoStream.codec}</td>
<td>{humanFileSize(videoStream.bitrate, useSiUnits)}</td>
<td>{audioStream.codec}</td>
<td>{humanFileSize(audioStream.bitrate, useSiUnits)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default VideoListItemTable;

View File

@ -1,11 +1,18 @@
export type ViewStyleNamesType =
| 'view_style_home'
| 'view_style_channel'
| 'view_style_downloads'
| 'view_style_playlist';
export const ViewStyleNames = {
home: 'view_style_home',
channel: 'view_style_channel',
downloads: 'view_style_downloads',
playlist: 'view_style_playlist',
Home: 'view_style_home',
Channel: 'view_style_channel',
Downloads: 'view_style_downloads',
Playlist: 'view_style_playlist',
};
export const ViewStyles = {
grid: 'grid',
list: 'list',
export type ViewStylesType = 'grid' | 'list' | 'table';
export const ViewStylesEnum = {
Grid: 'grid',
List: 'list',
Table: 'table',
};

View File

@ -11,6 +11,7 @@ import iconListView from '/img/icon-listview.svg';
import { useUserConfigStore } from '../stores/UserConfigStore';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
import { ViewStylesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
const ChannelPlaylist = () => {
const { channelId } = useParams();
@ -27,7 +28,7 @@ const ChannelPlaylist = () => {
const playlistList = playlistsResponseData?.data;
const pagination = playlistsResponseData?.paginate;
const view = userConfig.view_style_playlist;
const viewStyle = userConfig.view_style_playlist;
const showSubedOnly = userConfig.show_subed_only;
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
@ -87,14 +88,18 @@ const ChannelPlaylist = () => {
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ view_style_playlist: 'grid' });
handleUserConfigUpdate({
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
});
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ view_style_playlist: 'list' });
handleUserConfigUpdate({
view_style_playlist: ViewStylesEnum.List as ViewStylesType,
});
}}
alt="list view"
/>
@ -103,7 +108,7 @@ const ChannelPlaylist = () => {
</div>
<div className={`boxed-content`}>
<div className={`playlist-list ${view}`}>
<div className={`playlist-list ${viewStyle}`}>
<PlaylistList playlistList={playlistList} setRefresh={setRefreshPlaylists} />
</div>
</div>

View File

@ -5,7 +5,11 @@ import VideoList from '../components/VideoList';
import Routes from '../configuration/routes/RouteList';
import Pagination from '../components/Pagination';
import Filterbar from '../components/Filterbar';
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
import {
ViewStyleNames,
ViewStyleNamesType,
ViewStylesEnum,
} from '../configuration/constants/ViewStyle';
import ChannelOverview from '../components/ChannelOverview';
import loadChannelById, { ChannelResponseType } from '../api/loader/loadChannelById';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
@ -15,6 +19,8 @@ import Button from '../components/Button';
import loadVideoListByFilter, {
VideoListByFilterResponseType,
VideoTypes,
WatchTypes,
WatchTypesEnum,
} from '../api/loader/loadVideoListByPage';
import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs';
import humanFileSize from '../functions/humanFileSize';
@ -56,8 +62,8 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
const hasVideos = videoResponseData?.data?.length !== 0;
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
const view = userConfig.view_style_home;
const isGridView = view === ViewStyles.grid;
const viewStyle = userConfig.view_style_home;
const isGridView = viewStyle === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${userConfig.grid_items}` : '';
const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';
@ -67,7 +73,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
const videos = await loadVideoListByFilter({
channel: channelId,
page: currentPage,
watch: userConfig.hide_watched ? 'unwatched' : undefined,
watch: userConfig.hide_watched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined,
sort: userConfig.sort_by,
order: userConfig.sort_order,
type: videoType,
@ -160,13 +166,16 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
</div>
<div className={`boxed-content ${gridView}`}>
<Filterbar hideToggleText={'Hide watched videos:'} viewStyleName={ViewStyleNames.home} />
<Filterbar
hideToggleText={'Hide watched videos:'}
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
/>
</div>
<EmbeddableVideoPlayer videoId={videoId} />
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${view} ${gridViewGrid}`}>
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
{!hasVideos && (
<>
<h2>No videos found...</h2>
@ -177,7 +186,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
</>
)}
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
<VideoList videoList={videoList} viewStyle={viewStyle} refreshVideoList={setRefresh} />
</div>
</div>
{pagination && (

View File

@ -15,6 +15,7 @@ import useIsAdmin from '../functions/useIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
type ChannelOverwritesType = {
download_format: string | null;
@ -172,16 +173,19 @@ const Channels = () => {
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ view_style_channel: 'grid' });
handleUserConfigUpdate({
view_style_channel: ViewStylesEnum.Grid as ViewStylesType,
});
}}
data-origin="channel"
data-value="grid"
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ view_style_channel: 'list' });
handleUserConfigUpdate({
view_style_channel: ViewStylesEnum.List as ViewStylesType,
});
}}
data-origin="channel"
data-value="list"

View File

@ -10,7 +10,7 @@ import { ConfigType } from './Home';
import loadDownloadQueue from '../api/loader/loadDownloadQueue';
import { OutletContextType } from './Base';
import Pagination, { PaginationType } from '../components/Pagination';
import { ViewStyles } from '../configuration/constants/ViewStyle';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
import updateTaskByName from '../api/actions/updateTaskByName';
import Notifications from '../components/Notifications';
@ -81,11 +81,11 @@ const Download = () => {
? downloadResponseData?.data[0].channel_name
: '';
const view = userConfig.view_style_downloads;
const viewStyle = userConfig.view_style_downloads;
const gridItems = userConfig.grid_items;
const showIgnored =
ignoredOnlyParam !== null ? ignoredOnlyParam === 'true' : userConfig.show_ignored_only;
const isGridView = view === ViewStyles.grid;
const isGridView = viewStyle === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
@ -315,14 +315,18 @@ const Download = () => {
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ view_style_downloads: 'grid' });
handleUserConfigUpdate({
view_style_downloads: ViewStylesEnum.Grid as ViewStylesType,
});
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ view_style_downloads: 'list' });
handleUserConfigUpdate({
view_style_downloads: ViewStylesEnum.List as ViewStylesType,
});
}}
alt="list view"
/>
@ -341,7 +345,7 @@ const Download = () => {
</div>
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${view} ${gridViewGrid}`}>
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
{downloadList &&
downloadList?.map(download => {
return (

View File

@ -4,12 +4,18 @@ import Routes from '../configuration/routes/RouteList';
import Pagination from '../components/Pagination';
import loadVideoListByFilter, {
VideoListByFilterResponseType,
WatchTypes,
WatchTypesEnum,
} from '../api/loader/loadVideoListByPage';
import VideoList from '../components/VideoList';
import { ChannelType } from './Channels';
import { OutletContextType } from './Base';
import Filterbar from '../components/Filterbar';
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
import {
ViewStyleNames,
ViewStyleNamesType,
ViewStylesEnum,
} from '../configuration/constants/ViewStyle';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import { SponsorBlockType } from './Video';
@ -99,10 +105,6 @@ export type ConfigType = {
downloads: DownloadsType;
};
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'mediasize';
export type SortOrderType = 'asc' | 'desc';
export type ViewLayoutType = 'grid' | 'list';
const Home = () => {
const { userConfig } = useUserConfigStore();
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
@ -125,15 +127,16 @@ const Home = () => {
const hasVideos = videoResponseData?.data?.length !== 0;
const isGridView = userConfig.view_style_home === ViewStyles.grid;
const isGridView = userConfig.view_style_home === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${userConfig.grid_items}` : '';
const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';
const isTableView = userConfig.view_style_home === ViewStylesEnum.Table;
useEffect(() => {
(async () => {
const videos = await loadVideoListByFilter({
page: currentPage,
watch: userConfig.hide_watched ? 'unwatched' : undefined,
watch: userConfig.hide_watched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined,
sort: userConfig.sort_by,
order: userConfig.sort_order,
});
@ -168,7 +171,7 @@ const Home = () => {
<EmbeddableVideoPlayer videoId={videoId} />
<div className={`boxed-content ${gridView}`}>
{continueVideos && continueVideos.length > 0 && (
{continueVideos && continueVideos.length > 0 && !isTableView && (
<>
<div className="title-bar">
<h1>Continue Watching</h1>
@ -176,7 +179,7 @@ const Home = () => {
<div className={`video-list ${userConfig.view_style_home} ${gridViewGrid}`}>
<VideoList
videoList={continueVideos}
viewLayout={userConfig.view_style_home}
viewStyle={userConfig.view_style_home}
refreshVideoList={setRefreshVideoList}
/>
</div>
@ -187,7 +190,10 @@ const Home = () => {
<h1>Recent Videos</h1>
</div>
<Filterbar hideToggleText="Show unwatched only:" viewStyleName={ViewStyleNames.home} />
<Filterbar
hideToggleText="Show unwatched only:"
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
/>
</div>
<div className={`boxed-content ${gridView}`}>
@ -206,7 +212,7 @@ const Home = () => {
{hasVideos && (
<VideoList
videoList={videoList}
viewLayout={userConfig.view_style_home}
viewStyle={userConfig.view_style_home}
refreshVideoList={setRefreshVideoList}
/>
)}

View File

@ -9,7 +9,11 @@ import VideoList from '../components/VideoList';
import Pagination, { PaginationType } from '../components/Pagination';
import ChannelOverview from '../components/ChannelOverview';
import Linkify from '../components/Linkify';
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
import {
ViewStyleNames,
ViewStyleNamesType,
ViewStylesEnum,
} from '../configuration/constants/ViewStyle';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import deletePlaylist from '../api/actions/deletePlaylist';
import Routes from '../configuration/routes/RouteList';
@ -19,7 +23,10 @@ import updateWatchedState from '../api/actions/updateWatchedState';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import Button from '../components/Button';
import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
import loadVideoListByFilter, {
WatchTypes,
WatchTypesEnum,
} from '../api/loader/loadVideoListByPage';
import useIsAdmin from '../functions/useIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore';
import { ApiResponseType } from '../functions/APIClient';
@ -62,10 +69,10 @@ const Playlist = () => {
const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length);
const videoInPlaylistCount = Number(palylistEntries?.length);
const view = userConfig.view_style_home;
const viewStyle = userConfig.view_style_home; // its a list of videos, so view_style_home
const gridItems = userConfig.grid_items;
const hideWatched = userConfig.hide_watched;
const isGridView = view === ViewStyles.grid;
const isGridView = viewStyle === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
@ -75,7 +82,7 @@ const Playlist = () => {
const video = await loadVideoListByFilter({
playlist: playlistId,
page: currentPage,
watch: hideWatched ? 'unwatched' : undefined,
watch: hideWatched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined,
});
setPlaylistResponse(playlist);
@ -304,7 +311,7 @@ const Playlist = () => {
<div className={`boxed-content ${gridView}`}>
<Filterbar
hideToggleText="Hide watched videos:"
viewStyleName={ViewStyleNames.home}
viewStyle={ViewStyleNames.Home as ViewStyleNamesType} // its a list of videos, so ViewStyleNames.Home
showSort={false}
/>
</div>
@ -312,7 +319,7 @@ const Playlist = () => {
<EmbeddableVideoPlayer videoId={videoId} />
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${view} ${gridViewGrid}`}>
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
{videoInPlaylistCount === 0 && (
<>
<h2>No videos found...</h2>
@ -334,7 +341,7 @@ const Playlist = () => {
{videoInPlaylistCount !== 0 && (
<VideoList
videoList={videos}
viewLayout={view}
viewStyle={viewStyle}
playlistId={playlistId}
showReorderButton={isCustomPlaylist}
refreshVideoList={setRefresh}

View File

@ -18,6 +18,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
import Notifications from '../components/Notifications';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
const Playlists = () => {
const { userConfig, setUserConfig } = useUserConfigStore();
@ -39,7 +40,7 @@ const Playlists = () => {
const hasPlaylists = playlistResponseData?.data?.length !== 0;
const view = userConfig.view_style_playlist;
const viewStyle = userConfig.view_style_playlist;
const showSubedOnly = userConfig.show_subed_only;
useEffect(() => {
@ -177,21 +178,25 @@ const Playlists = () => {
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ view_style_playlist: 'grid' });
handleUserConfigUpdate({
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
});
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ view_style_playlist: 'list' });
handleUserConfigUpdate({
view_style_playlist: ViewStylesEnum.List as ViewStylesType,
});
}}
alt="list view"
/>
</div>
</div>
<div className={`playlist-list ${view}`}>
<div className={`playlist-list ${viewStyle}`}>
{!hasPlaylists && <h2>No playlists found...</h2>}
{hasPlaylists && <PlaylistList playlistList={playlistList} setRefresh={setRefresh} />}

View File

@ -5,7 +5,7 @@ import VideoList from '../components/VideoList';
import ChannelList from '../components/ChannelList';
import PlaylistList from '../components/PlaylistList';
import SubtitleList from '../components/SubtitleList';
import { ViewStyles } from '../configuration/constants/ViewStyle';
import { ViewStylesEnum } from '../configuration/constants/ViewStyle';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import SearchExampleQueries from '../components/SearchExampleQueries';
import { useUserConfigStore } from '../stores/UserConfigStore';
@ -61,7 +61,7 @@ const Search = () => {
const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery;
const isFullTextQuery = queryType === 'full' || isSimpleQuery;
const isGridView = viewVideos === ViewStyles.grid;
const isGridView = viewVideos === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
@ -125,7 +125,7 @@ const Search = () => {
<div id="video-results" className={`video-list ${viewVideos} ${gridViewGrid}`}>
<VideoList
videoList={videoList}
viewLayout={viewVideos}
viewStyle={viewVideos}
refreshVideoList={setRefresh}
/>
</div>

View File

@ -44,6 +44,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
import NotFound from './NotFound';
import { ApiResponseType } from '../functions/APIClient';
import VideoThumbnail from '../components/VideoThumbail';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
const isInPlaylist = (videoId: string, playlist: PlaylistType) => {
return playlist.playlist_entries.some(entry => {
@ -581,7 +582,7 @@ const Video = () => {
<div className="video-list grid grid-3" id="similar-videos">
<VideoList
videoList={similarVideosResponseData}
viewLayout="grid"
viewStyle={ViewStylesEnum.Grid as ViewStylesType}
refreshVideoList={setRefreshVideoList}
/>
</div>

View File

@ -1,5 +1,7 @@
import { create } from 'zustand';
import { UserConfigType } from '../api/actions/updateUserConfig';
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
import { SortOrderEnum, SortOrderType } from '../api/loader/loadVideoListByPage';
interface UserConfigState {
userConfig: UserConfigType;
@ -11,11 +13,11 @@ export const useUserConfigStore = create<UserConfigState>(set => ({
stylesheet: 'dark.css',
page_size: 12,
sort_by: 'published',
sort_order: 'desc',
view_style_home: 'grid',
view_style_channel: 'list',
view_style_downloads: 'list',
view_style_playlist: 'grid',
sort_order: SortOrderEnum.Desc as SortOrderType,
view_style_home: ViewStylesEnum.Grid as ViewStylesType,
view_style_channel: ViewStylesEnum.List as ViewStylesType,
view_style_downloads: ViewStylesEnum.List as ViewStylesType,
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
grid_items: 3,
hide_watched: false,
file_size_unit: 'binary',

View File

@ -133,6 +133,40 @@ button:hover {
color: var(--main-bg);
}
.video-item.table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.video-item.table table {
min-width: 800px;
width: 100%;
border-collapse: collapse;
position: relative;
}
.video-item.table th,
.video-item.table td {
padding: 8px 12px;
text-align: left;
white-space: nowrap;
}
.video-item.table th {
font-weight: 600;
border-bottom: 1px solid #ddd;
}
.video-item.table td {
border-bottom: 1px solid #eee;
}
.video-item.table td.no-nowrap {
white-space: normal;
word-break: keep-all;
min-width: 100px;
}
.button-box {
padding: 5px 2px;
display: inline-flex;