diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json
index 236890a2..d1bbd500 100644
--- a/backend/appsettings/index_mapping.json
+++ b/backend/appsettings/index_mapping.json
@@ -239,7 +239,8 @@
"type": "keyword"
},
"published": {
- "type": "date"
+ "type": "date",
+ "format": "epoch_second||strict_date_optional_time"
},
"playlist": {
"type": "text",
diff --git a/backend/common/serializers.py b/backend/common/serializers.py
index a22eac5e..faac033e 100644
--- a/backend/common/serializers.py
+++ b/backend/common/serializers.py
@@ -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):
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
index 871caba6..a5ee0352 100644
--- a/backend/requirements-dev.txt
+++ b/backend/requirements-dev.txt
@@ -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
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 0edecd4c..fa21380e 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/task/tasks.py b/backend/task/tasks.py
index 525938b4..d879cb9e 100644
--- a/backend/task/tasks.py
+++ b/backend/task/tasks.py
@@ -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]}"
diff --git a/backend/user/serializers.py b/backend/user/serializers.py
index 3c89d5cb..be617437 100644
--- a/backend/user/serializers.py
+++ b/backend/user/serializers.py
@@ -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"])
diff --git a/backend/video/src/constants.py b/backend/video/src/constants.py
index 12fc4b23..11b7de74 100644
--- a/backend/video/src/constants.py
+++ b/backend/video/src/constants.py
@@ -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]:
diff --git a/backend/video/src/subtitle.py b/backend/video/src/subtitle.py
index 38df1783..26370b08 100644
--- a/backend/video/src/subtitle.py
+++ b/backend/video/src/subtitle.py
@@ -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
diff --git a/frontend/public/img/icon-tableview.svg b/frontend/public/img/icon-tableview.svg
new file mode 100644
index 00000000..fe81095c
--- /dev/null
+++ b/frontend/public/img/icon-tableview.svg
@@ -0,0 +1,7 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/api/actions/updateUserConfig.ts b/frontend/src/api/actions/updateUserConfig.ts
index ac806e20..9d2aac7a 100644
--- a/frontend/src/api/actions/updateUserConfig.ts
+++ b/frontend/src/api/actions/updateUserConfig.ts
@@ -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';
diff --git a/frontend/src/api/loader/loadVideoListByPage.ts b/frontend/src/api/loader/loadVideoListByPage.ts
index 06066f77..b7a4af93 100644
--- a/frontend/src/api/loader/loadVideoListByPage.ts
+++ b/frontend/src/api/loader/loadVideoListByPage.ts
@@ -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;
diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx
index 9bb42e50..75319fe5 100644
--- a/frontend/src/components/ChannelList.tsx
+++ b/frontend/src/components/ChannelList.tsx
@@ -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
No channels found.
;
@@ -26,8 +26,8 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
<>
{channelList.map(channel => {
return (
-
-
+
+
{
/>
-
+
diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx
index cd9ac14a..bb0a82e3 100644
--- a/frontend/src/components/DownloadListItem.tsx
+++ b/frontend/src/components/DownloadListItem.tsx
@@ -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 (
-
-
+
+
@@ -39,7 +39,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
-
+
{download.channel_indexed && (
{download.channel_name}
diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx
index 57394b19..8219bea1 100644
--- a/frontend/src/components/Filterbar.tsx
+++ b/frontend/src/components/Filterbar.tsx
@@ -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) => {
const updatedUserConfig = await updateUserConfig(config);
@@ -55,7 +76,7 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
- {showHidden && showSort && (
+ {showHidden && (
Sort by:
@@ -67,12 +88,9 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
handleUserConfigUpdate({ sort_by: event.target.value as SortByType });
}}
>
-
-
-
-
-
-
+ {Object.entries(SortByEnum).map(([key, value]) => {
+ return ;
+ })}
)}
- {setShowHidden && showSort && (
+ {showSort && (

{
- setShowHidden?.(!showHidden);
+ setShowHidden(!showHidden);
}}
id="animate-icon"
/>
@@ -125,17 +144,24 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar

{
- handleUserConfigUpdate({ [viewStyleName]: 'grid' });
+ handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>

{
- handleUserConfigUpdate({ [viewStyleName]: 'list' });
+ handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
+

{
+ handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
+ }}
+ alt="table view"
+ />
);
diff --git a/frontend/src/components/PlaylistList.tsx b/frontend/src/components/PlaylistList.tsx
index 182499db..555d1e83 100644
--- a/frontend/src/components/PlaylistList.tsx
+++ b/frontend/src/components/PlaylistList.tsx
@@ -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
No playlists found.
;
@@ -24,7 +24,7 @@ const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
<>
{playlistList.map((playlist: PlaylistType) => {
return (
-
+
-
+
{playlist.playlist_type != 'custom' && (
{playlist.playlist_channel}
diff --git a/frontend/src/components/VideoList.tsx b/frontend/src/components/VideoList.tsx
index 440ed6bc..bff8217c 100644
--- a/frontend/src/components/VideoList.tsx
+++ b/frontend/src/components/VideoList.tsx
@@ -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
No videos found.
;
}
+ if (viewStyle === ViewStylesEnum.Table) {
+ return
;
+ }
+
return (
<>
{videoList.map(video => {
@@ -27,7 +33,7 @@ const VideoList = ({
void;
@@ -22,7 +23,7 @@ type VideoListItemProps = {
const VideoListItem = ({
video,
- viewLayout,
+ viewStyle,
playlistId,
showReorderButton = false,
refreshVideoList,
@@ -36,7 +37,7 @@ const VideoListItem = ({
}
return (
-
+
{
setSearchParams(params => {
@@ -46,7 +47,7 @@ const VideoListItem = ({
});
}}
>
-
+
@@ -72,7 +73,7 @@ const VideoListItem = ({
-
+
{
+ const { userConfig } = useUserConfigStore();
+
+ const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
+
+ return (
+
+
+
+
+ | Channel |
+ Title |
+ Type |
+ Resolution |
+ Media size |
+ Video codec |
+ Video bitrate |
+ Audio codec |
+ Audio bitrate |
+
+
+
+
+ {videoList?.map(({ youtube_id, title, channel, vid_type, media_size, streams }) => {
+ const [videoStream, audioStream] = streams;
+
+ return (
+
+ |
+ {channel.channel_name}
+ |
+
+ {title}
+ |
+ {vid_type} |
+ {`${videoStream.width}x${videoStream.height}`} |
+ {humanFileSize(media_size, useSiUnits)} |
+ {videoStream.codec} |
+ {humanFileSize(videoStream.bitrate, useSiUnits)} |
+ {audioStream.codec} |
+ {humanFileSize(audioStream.bitrate, useSiUnits)} |
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default VideoListItemTable;
diff --git a/frontend/src/configuration/constants/ViewStyle.ts b/frontend/src/configuration/constants/ViewStyle.ts
index cafe55cc..5390a310 100644
--- a/frontend/src/configuration/constants/ViewStyle.ts
+++ b/frontend/src/configuration/constants/ViewStyle.ts
@@ -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',
};
diff --git a/frontend/src/pages/ChannelPlaylist.tsx b/frontend/src/pages/ChannelPlaylist.tsx
index c3730e56..14b2912e 100644
--- a/frontend/src/pages/ChannelPlaylist.tsx
+++ b/frontend/src/pages/ChannelPlaylist.tsx
@@ -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) => {
@@ -87,14 +88,18 @@ const ChannelPlaylist = () => {
{
- handleUserConfigUpdate({ view_style_playlist: 'grid' });
+ handleUserConfigUpdate({
+ view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
+ });
}}
alt="grid view"
/>
{
- handleUserConfigUpdate({ view_style_playlist: 'list' });
+ handleUserConfigUpdate({
+ view_style_playlist: ViewStylesEnum.List as ViewStylesType,
+ });
}}
alt="list view"
/>
@@ -103,7 +108,7 @@ const ChannelPlaylist = () => {
-
diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx
index a6f3f4ed..e5a123c5 100644
--- a/frontend/src/pages/ChannelVideo.tsx
+++ b/frontend/src/pages/ChannelVideo.tsx
@@ -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) => {
-
+
-
+
{!hasVideos && (
<>
No videos found...
@@ -177,7 +186,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
>
)}
-
+
{pagination && (
diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx
index d71c2743..40e6dc2a 100644
--- a/frontend/src/pages/Channels.tsx
+++ b/frontend/src/pages/Channels.tsx
@@ -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 = () => {

{
- handleUserConfigUpdate({ view_style_channel: 'grid' });
+ handleUserConfigUpdate({
+ view_style_channel: ViewStylesEnum.Grid as ViewStylesType,
+ });
}}
data-origin="channel"
- data-value="grid"
alt="grid view"
/>

{
- handleUserConfigUpdate({ view_style_channel: 'list' });
+ handleUserConfigUpdate({
+ view_style_channel: ViewStylesEnum.List as ViewStylesType,
+ });
}}
data-origin="channel"
data-value="list"
diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx
index 1e31dd2b..f08de4fa 100644
--- a/frontend/src/pages/Download.tsx
+++ b/frontend/src/pages/Download.tsx
@@ -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 = () => {

{
- handleUserConfigUpdate({ view_style_downloads: 'grid' });
+ handleUserConfigUpdate({
+ view_style_downloads: ViewStylesEnum.Grid as ViewStylesType,
+ });
}}
alt="grid view"
/>

{
- handleUserConfigUpdate({ view_style_downloads: 'list' });
+ handleUserConfigUpdate({
+ view_style_downloads: ViewStylesEnum.List as ViewStylesType,
+ });
}}
alt="list view"
/>
@@ -341,7 +345,7 @@ const Download = () => {
-
+
{downloadList &&
downloadList?.map(download => {
return (
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index 45a5b49b..67fb7fac 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -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 = () => {
- {continueVideos && continueVideos.length > 0 && (
+ {continueVideos && continueVideos.length > 0 && !isTableView && (
<>
Continue Watching
@@ -176,7 +179,7 @@ const Home = () => {
@@ -187,7 +190,10 @@ const Home = () => {
Recent Videos
-
+
@@ -206,7 +212,7 @@ const Home = () => {
{hasVideos && (
)}
diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx
index e30112d6..37657f64 100644
--- a/frontend/src/pages/Playlist.tsx
+++ b/frontend/src/pages/Playlist.tsx
@@ -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 = () => {
@@ -312,7 +319,7 @@ const Playlist = () => {
-
+
{videoInPlaylistCount === 0 && (
<>
No videos found...
@@ -334,7 +341,7 @@ const Playlist = () => {
{videoInPlaylistCount !== 0 && (
{
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 = () => {
{
- handleUserConfigUpdate({ view_style_playlist: 'grid' });
+ handleUserConfigUpdate({
+ view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
+ });
}}
alt="grid view"
/>
{
- handleUserConfigUpdate({ view_style_playlist: 'list' });
+ handleUserConfigUpdate({
+ view_style_playlist: ViewStylesEnum.List as ViewStylesType,
+ });
}}
alt="list view"
/>
-
+
{!hasPlaylists &&
No playlists found...
}
{hasPlaylists &&
}
diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx
index b2718d79..6af56508 100644
--- a/frontend/src/pages/Search.tsx
+++ b/frontend/src/pages/Search.tsx
@@ -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 = () => {
diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx
index 87031962..785a6e23 100644
--- a/frontend/src/pages/Video.tsx
+++ b/frontend/src/pages/Video.tsx
@@ -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 = () => {
diff --git a/frontend/src/stores/UserConfigStore.ts b/frontend/src/stores/UserConfigStore.ts
index 20b2a417..077715c5 100644
--- a/frontend/src/stores/UserConfigStore.ts
+++ b/frontend/src/stores/UserConfigStore.ts
@@ -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
(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',
diff --git a/frontend/src/style.css b/frontend/src/style.css
index f5301d5b..13a70796 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -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;