diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f771c767..206029a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,6 +140,9 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers. +Note: +- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers. + ### Native Instruction For convenience, it's recommended to still run Redis and ES in a docker container. Make sure both containers can be reachable over the network. @@ -178,7 +181,7 @@ Then from the frontend folder, install the dependencies with: npm install ``` -Then to start the developlent server: +Then to start the frontend development server: ```bash npm run dev diff --git a/Dockerfile b/Dockerfile index e7271478..29f75c40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm # install debug tools for testing environment RUN if [ "$INSTALL_DEBUG" ] ; then \ apt-get -y update && apt-get -y install --no-install-recommends \ - vim htop bmon net-tools iputils-ping procps \ + vim htop bmon net-tools iputils-ping procps lsof \ && pip install --user ipython pytest pytest-django \ ; fi diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py index 90b9e068..5f001f8d 100644 --- a/backend/common/src/helper.py +++ b/backend/common/src/helper.py @@ -275,3 +275,19 @@ def get_channel_overwrites() -> dict[str, dict[str, Any]]: overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result} return overwrites + + +def calc_is_watched(duration: float, position: float) -> bool: + """considered watched based on duration position""" + + if not duration or duration <= 0: + return False + + if duration < 60: + threshold = 0.5 + elif duration > 900: + threshold = 1 - (180 / duration) + else: + threshold = 0.9 + + return position >= duration * threshold diff --git a/backend/common/src/search_processor.py b/backend/common/src/search_processor.py index 9fc1f2b1..a5669852 100644 --- a/backend/common/src/search_processor.py +++ b/backend/common/src/search_processor.py @@ -45,7 +45,11 @@ class SearchProcess: if not all_positions: return None - pos_index = {i["youtube_id"]: i["position"] for i in all_positions} + pos_index = { + i["youtube_id"]: i["position"] + for i in all_positions + if not i.get("watched") + } return pos_index def _process_result(self, result): @@ -102,13 +106,15 @@ class SearchProcess: vid_thumb_url = ThumbManager(video_id).vid_thumb_path() channel = self._process_channel(video_dict["channel"]) + cache_root = EnvironmentSettings().get_cache_root() + media_root = EnvironmentSettings().get_media_root() + if "subtitles" in video_dict: for idx, _ in enumerate(video_dict["subtitles"]): url = video_dict["subtitles"][idx]["media_url"] - video_dict["subtitles"][idx]["media_url"] = f"/media/{url}" - - cache_root = EnvironmentSettings().get_cache_root() - media_root = EnvironmentSettings().get_media_root() + video_dict["subtitles"][idx][ + "media_url" + ] = f"{media_root}/{url}" video_dict.update( { diff --git a/backend/common/src/watched.py b/backend/common/src/watched.py index ad5a671a..d3d3b376 100644 --- a/backend/common/src/watched.py +++ b/backend/common/src/watched.py @@ -6,15 +6,17 @@ functionality: from datetime import datetime from common.src.es_connect import ElasticWrap +from common.src.ta_redis import RedisArchivist from common.src.urlparser import Parser class WatchState: """handle watched checkbox for videos and channels""" - def __init__(self, youtube_id, is_watched): + def __init__(self, youtube_id: str, is_watched: bool, user_id: int): self.youtube_id = youtube_id self.is_watched = is_watched + self.user_id = user_id self.stamp = int(datetime.now().timestamp()) self.pipeline = f"_ingest/pipeline/watch_{youtube_id}" @@ -50,6 +52,8 @@ class WatchState: } } response, status_code = ElasticWrap(path).post(data=data) + key = f"{self.user_id}:progress:{self.youtube_id}" + RedisArchivist().del_message(key) if status_code != 200: print(response) raise ValueError("failed to mark video as watched") diff --git a/backend/common/views.py b/backend/common/views.py index 20c12804..b7b477c9 100644 --- a/backend/common/views.py +++ b/backend/common/views.py @@ -75,7 +75,7 @@ class WatchedView(ApiBaseView): message = {"message": "missing id or is_watched"} return Response(message, status=400) - WatchState(youtube_id, is_watched).change() + WatchState(youtube_id, is_watched, request.user.id).change() return Response({"message": "success"}, status=200) diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py index a18e5ef0..52070818 100644 --- a/backend/download/src/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -82,23 +82,24 @@ class YtWrap: def extract(self, url): """make extract request""" - try: - response = yt_dlp.YoutubeDL(self.obs).extract_info(url) - except cookiejar.LoadError as err: - print(f"cookie file is invalid: {err}") - return False - except yt_dlp.utils.ExtractorError as err: - print(f"{url}: failed to extract with message: {err}, continue...") - return False - except yt_dlp.utils.DownloadError as err: - if "This channel does not have a" in str(err): + with yt_dlp.YoutubeDL(self.obs) as ydl: + try: + response = ydl.extract_info(url) + except cookiejar.LoadError as err: + print(f"cookie file is invalid: {err}") return False + except yt_dlp.utils.ExtractorError as err: + print(f"{url}: failed to extract: {err}, continue...") + return False + except yt_dlp.utils.DownloadError as err: + if "This channel does not have a" in str(err): + return False - print(f"{url}: failed to get info from youtube with message {err}") - if "Temporary failure in name resolution" in str(err): - raise ConnectionError("lost the internet, abort!") from err + print(f"{url}: failed to get info from youtube: {err}") + if "Temporary failure in name resolution" in str(err): + raise ConnectionError("lost the internet, abort!") from err - return False + return False self._validate_cookie() @@ -131,7 +132,8 @@ class CookieHandler: def set_cookie(self, cookie): """set cookie str and activate in config""" - RedisArchivist().set_message("cookie", cookie, save=True) + cookie_clean = cookie.strip("\x00") + RedisArchivist().set_message("cookie", cookie_clean, save=True) AppConfig().update_config({"downloads.cookie_import": True}) self.config["downloads"]["cookie_import"] = True print("[cookie]: activated and stored in Redis") @@ -161,9 +163,10 @@ class CookieHandler: self.store_validation(response) # update in redis to avoid expiring - modified = validator.obs["cookiefile"].getvalue() + modified = validator.obs["cookiefile"].getvalue().strip("\x00") if modified: - RedisArchivist().set_message("cookie", modified) + cookie_clean = modified.strip("\x00") + RedisArchivist().set_message("cookie", cookie_clean) if not response: mess_dict = { diff --git a/backend/download/views.py b/backend/download/views.py index fd1fc7a2..33e245a1 100644 --- a/backend/download/views.py +++ b/backend/download/views.py @@ -20,7 +20,14 @@ class DownloadApiListView(ApiBaseView): def get(self, request): """get request""" query_filter = request.GET.get("filter", False) - self.data.update({"sort": [{"timestamp": {"order": "asc"}}]}) + self.data.update( + { + "sort": [ + {"auto_start": {"order": "desc"}}, + {"timestamp": {"order": "asc"}}, + ], + } + ) must_list = [] if query_filter: diff --git a/backend/video/src/query_building.py b/backend/video/src/query_building.py index da98cd34..2a2cd637 100644 --- a/backend/video/src/query_building.py +++ b/backend/video/src/query_building.py @@ -63,10 +63,15 @@ class QueryBuilder: if not results: return None - ids = [{"match": {"youtube_id": i.get("youtube_id")}} for i in results] - continue_ids = {"bool": {"should": ids}} + ids = [ + {"match": {"youtube_id": i.get("youtube_id")}} + for i in results + if not i.get("watched") + ] + if not ids: + return None - return continue_ids + return {"bool": {"should": ids}} def parse_type(self, video_type: str): """parse video type""" diff --git a/backend/video/views.py b/backend/video/views.py index 08f484ab..0b3d93bb 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -1,6 +1,8 @@ """all API views for video endpoints""" +from common.src.helper import calc_is_watched from common.src.ta_redis import RedisArchivist +from common.src.watched import WatchState from common.views_base import AdminWriteOnly, ApiBaseView from playlist.src.index import YoutubePlaylist from rest_framework.response import Response @@ -102,18 +104,55 @@ class VideoProgressView(ApiBaseView): handle progress status for video """ + search_base = "ta_video/_doc/" + + @staticmethod + def _get_key(user_id: int, video_id: str) -> str: + """redis key""" + return f"{user_id}:progress:{video_id}" + def post(self, request, video_id): """set progress position in redis""" position = request.data.get("position", 0) - key = f"{request.user.id}:progress:{video_id}" - message = {"position": position, "youtube_id": video_id} - RedisArchivist().set_message(key, message) - self.response = request.data - return Response(self.response) + key = self._get_key(request.user.id, video_id) + redis_con = RedisArchivist() + current_progress = redis_con.get_message_dict(key) + + if not current_progress: + self.get_document(video_id) + if self.status_code != 200: + return Response(status=self.status_code) + + current_progress = self.response["data"]["player"] + + current_progress.update({"position": position, "youtube_id": video_id}) + watched = self._check_watched(request, video_id, current_progress) + if watched: + expire = 60 + else: + expire = False + + current_progress.update({"watched": watched}) + redis_con.set_message(key, current_progress, expire=expire) + + return Response(current_progress) + + def _check_watched(self, request, video_id, current_progress) -> bool: + """check watched state""" + if current_progress["watched"]: + return True + + watched = calc_is_watched( + current_progress["duration"], current_progress["position"] + ) + if watched: + WatchState(video_id, watched, request.user.id).change() + + return watched def delete(self, request, video_id): """delete progress position""" - key = f"{request.user.id}:progress:{video_id}" + key = self._get_key(request.user.id, video_id) RedisArchivist().del_message(key) self.response = {"progress-reset": video_id} diff --git a/deploy.sh b/deploy.sh index 486da8ca..5f4aa931 100755 --- a/deploy.sh +++ b/deploy.sh @@ -34,7 +34,7 @@ function sync_blackhole { --exclude ".mypy_cache" \ . -e ssh "$host":tubearchivist - ssh "$host" 'docker build -t bbilly1/tubearchivist:unstable tubearchivist' + ssh "$host" 'docker build --build-arg INSTALL_DEBUG=1 -t bbilly1/tubearchivist:unstable tubearchivist' ssh "$host" 'docker compose -f docker/docker-compose.yml up -d' } diff --git a/docker_assets/run.sh b/docker_assets/run.sh index 283b70f0..33029f1c 100644 --- a/docker_assets/run.sh +++ b/docker_assets/run.sh @@ -20,7 +20,11 @@ python manage.py ta_startup # start all tasks nginx & -celery -A task.celery worker --loglevel=INFO --max-tasks-per-child 10 & +celery -A task.celery worker \ + --loglevel=INFO \ + --concurrency 4 \ + --max-tasks-per-child 5 \ + --max-memory-per-child 150000 & celery -A task beat --loglevel=INFO \ --scheduler django_celery_beat.schedulers:DatabaseScheduler & python backend_start.py diff --git a/frontend/README.md b/frontend/README.md index 275fc390..3fbdc1f2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -12,6 +12,7 @@ src ┐ │ ├───colours // Css loader for themes │ ├───constants // global constants that have no good place │ └───routes // Routes definitions used in Links and react-router-dom configuration - ├───functions // Useful functions - └───pages // React components that define a page/route + ├───functions // Useful functions and hooks + ├───pages // React components that define a page/route + └───stores // zustand stores ``` diff --git a/frontend/src/api/actions/updateVideoProgressById.ts b/frontend/src/api/actions/updateVideoProgressById.ts index 8ef6b7ff..e791afeb 100644 --- a/frontend/src/api/actions/updateVideoProgressById.ts +++ b/frontend/src/api/actions/updateVideoProgressById.ts @@ -1,11 +1,23 @@ import APIClient from '../../functions/APIClient'; +type VideoProgressResponseType = { + watched: boolean; + duration: number; + duration_str: string; + watched_date: number; + position: number; + youtube_id: string; +}; + type VideoProgressProp = { youtubeId: string; currentProgress: number; }; -const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => { +const updateVideoProgressById = async ({ + youtubeId, + currentProgress, +}: VideoProgressProp): Promise => { return APIClient(`/api/video/${youtubeId}/progress/`, { method: 'POST', body: { position: currentProgress }, diff --git a/frontend/src/components/EmbeddableVideoPlayer.tsx b/frontend/src/components/EmbeddableVideoPlayer.tsx index 7be85110..039855de 100644 --- a/frontend/src/components/EmbeddableVideoPlayer.tsx +++ b/frontend/src/components/EmbeddableVideoPlayer.tsx @@ -28,52 +28,55 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { const [, setSearchParams] = useSearchParams(); - const [refresh, setRefresh] = useState(false); - const [loading, setLoading] = useState(false); + const [refresh, setRefresh] = useState(true); const [videoResponse, setVideoResponse] = useState(); const [playlists, setPlaylists] = useState(); useEffect(() => { (async () => { - setLoading(true); - const videoResponse = await loadVideoById(videoId); + if (refresh || videoId !== videoResponse?.data.youtube_id) { + const videoResponse = await loadVideoById(videoId); - const playlistIds = videoResponse.data.playlist; - if (playlistIds !== undefined) { - const playlists = await Promise.all( - playlistIds.map(async playlistid => { - const playlistResponse = await loadPlaylistById(playlistid); + const playlistIds = videoResponse.data.playlist; + if (playlistIds !== undefined) { + const playlists = await Promise.all( + playlistIds.map(async playlistid => { + const playlistResponse = await loadPlaylistById(playlistid); - return playlistResponse.data; - }), - ); + return playlistResponse.data; + }), + ); - const playlistsFiltered = playlists - .filter(playlist => { - return playlist.playlist_subscribed; - }) - .map(playlist => { - return { - id: playlist.playlist_id, - name: playlist.playlist_name, - }; - }); + const playlistsFiltered = playlists + .filter(playlist => { + return playlist.playlist_subscribed; + }) + .map(playlist => { + return { + id: playlist.playlist_id, + name: playlist.playlist_name, + }; + }); - setPlaylists(playlistsFiltered); + setPlaylists(playlistsFiltered); + } + + setVideoResponse(videoResponse); + + inlinePlayerRef.current?.scrollIntoView(); + + setRefresh(false); } - - setVideoResponse(videoResponse); - - inlinePlayerRef.current?.scrollIntoView(); - - setRefresh(false); - setLoading(false); })(); }, [videoId, refresh]); + useEffect(() => { + inlinePlayerRef.current?.scrollIntoView(); + }, []); + if (videoResponse === undefined) { - return []; + return
; } const video = videoResponse.data; @@ -94,14 +97,15 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { <>
- {!loading && ( - - )} + { + setRefresh(true); + }} + />
{ setSearchParams({}); }} /> + { @@ -125,12 +130,16 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { setRefresh(true); }} /> + {cast && ( { setRefresh(true); }} + onWatchStateChanged={() => { + setRefresh(true); + }} /> )} diff --git a/frontend/src/components/GoogleCast.tsx b/frontend/src/components/GoogleCast.tsx index f75175e4..4024c561 100644 --- a/frontend/src/components/GoogleCast.tsx +++ b/frontend/src/components/GoogleCast.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { VideoType } from '../pages/Home'; -import updateWatchedState from '../api/actions/updateWatchedState'; import updateVideoProgressById from '../api/actions/updateVideoProgressById'; -import watchedThreshold from '../functions/watchedThreshold'; const getURL = () => { return window.location.origin; @@ -29,6 +27,7 @@ async function castVideoProgress( duration: number; }, video: VideoType | undefined, + onWatchStateChanged?: (status: boolean) => void, ) { if (!video) { console.log('castVideoProgress: Video to cast not found...'); @@ -42,19 +41,13 @@ async function castVideoProgress( if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) { // Check progress every 10 seconds or else progress is checked a few times a second - await updateVideoProgressById({ + const videoProgressResponse = await updateVideoProgressById({ youtubeId: videoId, currentProgress: currentTime, }); - if (!video.player.watched) { - // Check if video is already marked as watched - if (watchedThreshold(currentTime, duration)) { - await updateWatchedState({ - id: videoId, - is_watched: true, - }); - } + if (videoProgressResponse.watched && video.player.watched !== videoProgressResponse.watched) { + onWatchStateChanged?.(true); } } } @@ -93,9 +86,10 @@ async function castVideoPaused( type GoogleCastProps = { video?: VideoType; setRefresh?: () => void; + onWatchStateChanged?: (status: boolean) => void; }; -const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => { +const GoogleCast = ({ video, setRefresh, onWatchStateChanged }: GoogleCastProps) => { const [isConnected, setIsConnected] = useState(false); const setup = useCallback(() => { @@ -118,12 +112,14 @@ const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => { setIsConnected(player.isConnected); }, ); + playerController.addEventListener( cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED, function () { - castVideoProgress(player, video); + castVideoProgress(player, video, onWatchStateChanged); }, ); + playerController.addEventListener( cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, function () { @@ -131,6 +127,8 @@ const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => { setRefresh?.(); }, ); + + // eslint-disable-next-line react-hooks/exhaustive-deps }, [setRefresh, video]); const startPlayback = useCallback(() => { diff --git a/frontend/src/components/VideoListItem.tsx b/frontend/src/components/VideoListItem.tsx index f6deb65d..6876ed45 100644 --- a/frontend/src/components/VideoListItem.tsx +++ b/frontend/src/components/VideoListItem.tsx @@ -3,6 +3,7 @@ import Routes from '../configuration/routes/RouteList'; import { VideoType, ViewLayoutType } 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'; import defaultVideoThumb from '/img/default-video-thumb.jpg'; import updateWatchedState from '../api/actions/updateWatchedState'; import formatDate from '../functions/formatDates'; @@ -10,6 +11,7 @@ import WatchedCheckBox from './WatchedCheckBox'; import MoveVideoMenu from './MoveVideoMenu'; import { useState } from 'react'; import getApiUrl from '../configuration/getApiUrl'; +import deleteVideoProgressById from '../api/actions/deleteVideoProgressById'; type VideoListItemProps = { video: VideoType; @@ -84,6 +86,17 @@ const VideoListItem = ({ refreshVideoList(true); }} /> + {video.player.progress && ( + { + await deleteVideoProgressById(video.youtube_id); + refreshVideoList(true); + }} + /> + )} {formatDate(video.published)} | {video.player.duration_str} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index d217b118..e720a66d 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -1,11 +1,20 @@ import updateVideoProgressById from '../api/actions/updateVideoProgressById'; -import updateWatchedState from '../api/actions/updateWatchedState'; import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video'; -import watchedThreshold from '../functions/watchedThreshold'; -import { Dispatch, Fragment, SetStateAction, SyntheticEvent, useState } from 'react'; +import { + Dispatch, + Fragment, + SetStateAction, + SyntheticEvent, + useEffect, + useRef, + useState, +} from 'react'; import formatTime from '../functions/formatTime'; import { useSearchParams } from 'react-router-dom'; import getApiUrl from '../configuration/getApiUrl'; +import { useKeyPress } from '../functions/useKeypressHook'; + +const VIDEO_PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3]; type VideoTag = SyntheticEvent; @@ -54,6 +63,7 @@ const handleTimeUpdate = watched: boolean, sponsorBlock?: SponsorBlockType, setSponsorSegmentSkipped?: Dispatch>, + onWatchStateChanged?: (status: boolean) => void, ) => async (videoTag: VideoTag) => { const currentTime = Number(videoTag.currentTarget.currentTime); @@ -78,22 +88,16 @@ const handleTimeUpdate = }); } - if (currentTime < 10) return; + if (currentTime < 10 && currentTime === Number(videoTag.currentTarget.duration)) return; if (Number((currentTime % 10).toFixed(1)) <= 0.2) { // Check progress every 10 seconds or else progress is checked a few times a second - await updateVideoProgressById({ + const videoProgressResponse = await updateVideoProgressById({ youtubeId, currentProgress: currentTime, }); - if (!watched) { - // Check if video is already marked as watched - if (watchedThreshold(currentTime, duration)) { - await updateWatchedState({ - id: youtubeId, - is_watched: true, - }); - } + if (videoProgressResponse.watched && watched !== videoProgressResponse.watched) { + onWatchStateChanged?.(true); } } }; @@ -103,6 +107,7 @@ type VideoPlayerProps = { sponsorBlock?: SponsorBlockType; embed?: boolean; autoplay?: boolean; + onWatchStateChanged?: (status: boolean) => void; onVideoEnd?: () => void; }; @@ -111,12 +116,31 @@ const VideoPlayer = ({ sponsorBlock, embed, autoplay = false, + onWatchStateChanged, onVideoEnd, }: VideoPlayerProps) => { + const videoRef = useRef(null); + const [searchParams] = useSearchParams(); const searchParamVideoProgress = searchParams.get('t'); const [skippedSegments, setSkippedSegments] = useState({}); + const [isMuted, setIsMuted] = useState(false); + const [playbackSpeedIndex, setPlaybackSpeedIndex] = useState(3); + const [lastSubtitleTack, setLastSubtitleTack] = useState(0); + const [showHelpDialog, setShowHelpDialog] = useState(false); + const [showInfoDialog, setShowInfoDialog] = useState(false); + const [infoDialogContent, setInfoDialogContent] = useState(''); + + const questionmarkPressed = useKeyPress('?'); + const mutePressed = useKeyPress('m'); + const fullscreenPressed = useKeyPress('f'); + const subtitlesPressed = useKeyPress('c'); + const increasePlaybackSpeedPressed = useKeyPress('>'); + const decreasePlaybackSpeedPressed = useKeyPress('<'); + const resetPlaybackSpeedPressed = useKeyPress('='); + const arrowRightPressed = useKeyPress('ArrowRight'); + const arrowLeftPressed = useKeyPress('ArrowLeft'); const videoId = video.data.youtube_id; const videoUrl = video.data.media_url; @@ -132,16 +156,32 @@ const VideoPlayer = ({ videoSrcProgress = searchParamVideoProgress; } + const infoDialog = (content: string) => { + setInfoDialogContent(content); + setShowInfoDialog(true); + + setTimeout(() => { + setShowInfoDialog(false); + setInfoDialogContent(''); + }, 500); + }; + const handleVideoEnd = ( youtubeId: string, watched: boolean, setSponsorSegmentSkipped?: Dispatch>, ) => - async () => { - if (!watched) { - // Check if video is already marked as watched - await updateWatchedState({ id: youtubeId, is_watched: true }); + async (videoTag: VideoTag) => { + const currentTime = Number(videoTag.currentTarget.currentTime); + + const videoProgressResponse = await updateVideoProgressById({ + youtubeId, + currentProgress: currentTime, + }); + + if (videoProgressResponse.watched && watched !== videoProgressResponse.watched) { + onWatchStateChanged?.(true); } setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { @@ -157,11 +197,131 @@ const VideoPlayer = ({ onVideoEnd?.(); }; + useEffect(() => { + if (mutePressed) { + setIsMuted(!isMuted); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mutePressed]); + + useEffect(() => { + if (increasePlaybackSpeedPressed) { + const newSpeed = playbackSpeedIndex + 1; + + if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeed]) { + const speed = VIDEO_PLAYBACK_SPEEDS[newSpeed]; + videoRef.current.playbackRate = speed; + + setPlaybackSpeedIndex(newSpeed); + infoDialog(`${speed}x`); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [increasePlaybackSpeedPressed]); + + useEffect(() => { + if (decreasePlaybackSpeedPressed) { + const newSpeedIndex = playbackSpeedIndex - 1; + + if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { + const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]; + videoRef.current.playbackRate = speed; + + setPlaybackSpeedIndex(newSpeedIndex); + infoDialog(`${speed}x`); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decreasePlaybackSpeedPressed]); + + useEffect(() => { + if (resetPlaybackSpeedPressed) { + const newSpeedIndex = 3; + + if (videoRef.current && VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]) { + const speed = VIDEO_PLAYBACK_SPEEDS[newSpeedIndex]; + videoRef.current.playbackRate = speed; + + setPlaybackSpeedIndex(newSpeedIndex); + infoDialog(`${speed}x`); + } + } + }, [resetPlaybackSpeedPressed]); + + useEffect(() => { + if (fullscreenPressed) { + if (videoRef.current && videoRef.current.requestFullscreen && !document.fullscreenElement) { + videoRef.current.requestFullscreen().catch(e => { + console.error(e); + infoDialog('Unable to enter fullscreen'); + }); + } else { + document.exitFullscreen().catch(e => { + console.error(e); + infoDialog('Unable to exit fullscreen'); + }); + } + } + }, [fullscreenPressed]); + + useEffect(() => { + if (subtitlesPressed) { + if (videoRef.current) { + const tracks = [...videoRef.current.textTracks]; + + if (tracks.length === 0) { + return; + } + + const lastIndex = tracks.findIndex(x => x.mode === 'showing'); + const active = tracks[lastIndex]; + + if (!active && lastSubtitleTack !== 0) { + tracks[lastSubtitleTack - 1].mode = 'showing'; + } else { + if (active) { + active.mode = 'hidden'; + + setLastSubtitleTack(lastIndex + 1); + } + } + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subtitlesPressed]); + + useEffect(() => { + if (arrowLeftPressed) { + infoDialog('- 5 seconds'); + } + }, [arrowLeftPressed]); + + useEffect(() => { + if (arrowRightPressed) { + infoDialog('+ 5 seconds'); + } + }, [arrowRightPressed]); + + useEffect(() => { + if (questionmarkPressed) { + if (!showHelpDialog) { + setTimeout(() => { + setShowHelpDialog(false); + }, 3000); + } + + setShowHelpDialog(!showHelpDialog); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [questionmarkPressed]); + return ( <>
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Show help?
Toggle mutem
Toggle fullscreenf
Toggle subtitles (if available)c
Increase speed>
Decrease speed<
Reset speed=
Back 5 seconds
Forward 5 seconds
+ +
+ +
+
+
+ + +
{infoDialogContent}
+
+
{sponsorBlock?.is_enabled && ( <> diff --git a/frontend/src/functions/APIClient.ts b/frontend/src/functions/APIClient.ts index 21a5e734..48b7900a 100644 --- a/frontend/src/functions/APIClient.ts +++ b/frontend/src/functions/APIClient.ts @@ -10,6 +10,11 @@ export interface ApiClientOptions extends Omit { body?: Record | string; } +export interface ApiError { + status: number; + message: string; +} + const APIClient = async ( endpoint: string, { method = 'GET', body, headers = {}, ...options }: ApiClientOptions = {}, @@ -30,6 +35,14 @@ const APIClient = async ( }); // Handle common errors + if (response.status === 400) { + const data = await response.json(); + throw { + status: response.status, + message: data?.message || 'An error occurred while processing the request.', + } as ApiError; + } + if (response.status === 401) { logOut(); window.location.href = Routes.Login; diff --git a/frontend/src/functions/useKeypressHook.tsx b/frontend/src/functions/useKeypressHook.tsx new file mode 100644 index 00000000..55287795 --- /dev/null +++ b/frontend/src/functions/useKeypressHook.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +// source: https://thibault.sh/react-hooks/use-key-press +export function useKeyPress(targetKey: string) { + const [isKeyPressed, setIsKeyPressed] = useState(false); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === targetKey) { + setIsKeyPressed(true); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === targetKey) { + setIsKeyPressed(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [targetKey]); + + return isKeyPressed; +} diff --git a/frontend/src/functions/watchedThreshold.ts b/frontend/src/functions/watchedThreshold.ts deleted file mode 100644 index 06b4c0bb..00000000 --- a/frontend/src/functions/watchedThreshold.ts +++ /dev/null @@ -1,21 +0,0 @@ -function watchedThreshold(currentTime: number, duration: number) { - let watched = false; - - if (duration <= 1800) { - // If video is less than 30 min - if (currentTime / duration >= 0.9) { - // Mark as watched at 90% - watched = true; - } - } else { - // If video is more than 30 min - if (currentTime >= duration - 120) { - // Mark as watched if there is two minutes left - watched = true; - } - } - - return watched; -} - -export default watchedThreshold; diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx index 5fb199b9..ab43184a 100644 --- a/frontend/src/pages/ChannelVideo.tsx +++ b/frontend/src/pages/ChannelVideo.tsx @@ -81,6 +81,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { channelId, pagination?.current_page, videoType, + showEmbeddedVideo, ]); if (!channel) { diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 55582aed..b113708d 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -60,7 +60,7 @@ const Channels = () => { const channels = channelListResponse?.data; const pagination = channelListResponse?.paginate; - const channelCount = pagination?.total_hits; + // const channelCount = pagination?.total_hits; const hasChannels = channels?.length !== 0; useEffect(() => { @@ -184,7 +184,7 @@ const Channels = () => { />
- {hasChannels &&

Total channels: {channelCount}

} + {/* {hasChannels &&

Total channels: {channelCount}

} */}
{!hasChannels &&

No channels found...

} diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx index 5a719204..d46adae6 100644 --- a/frontend/src/pages/Download.tsx +++ b/frontend/src/pages/Download.tsx @@ -182,6 +182,7 @@ const Download = () => { label="Add to queue" onClick={async () => { await updateDownloadQueue(downloadQueueText, false); + setDownloadQueueText(''); setRefresh(true); setShowHiddenForm(false); }} @@ -190,6 +191,7 @@ const Download = () => { label="Download now" onClick={async () => { await updateDownloadQueue(downloadQueueText, true); + setDownloadQueueText(''); setRefresh(true); setShowHiddenForm(false); }} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 725f64bb..11cd89cb 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -155,6 +155,7 @@ const Home = () => { userMeConfig.hide_watched, currentPage, pagination?.current_page, + showEmbeddedVideo, ]); return ( @@ -184,7 +185,7 @@ const Home = () => {

Recent Videos

- +
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ac6ac55a..0a3cab85 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -6,18 +6,20 @@ import Button from '../components/Button'; import signIn from '../api/actions/signIn'; const Login = () => { + useColours(); + + const navigate = useNavigate(); + const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [saveLogin, setSaveLogin] = useState(false); - const navigate = useNavigate(); - - useColours(); - - const form_error = false; + const [errorMessage, setErrorMessage] = useState(null); const handleSubmit = async (event: { preventDefault: () => void }) => { event.preventDefault(); + setErrorMessage(null); + const loginResponse = await signIn(username, password, saveLogin); const signedIn = loginResponse.status === 200; @@ -25,6 +27,8 @@ const Login = () => { if (signedIn) { navigate(Routes.Home); } else { + const data = await loginResponse.json(); + setErrorMessage(data?.message || 'Unknown Error'); navigate(Routes.Login); } }; @@ -37,7 +41,13 @@ const Login = () => {

Tube Archivist

Your Self Hosted YouTube Media Server

- {form_error &&

Failed to login.

} + {errorMessage !== null && ( +

+ Failed to login. +
+ {errorMessage} +

+ )}
{ value={username} onChange={event => setUsername(event.target.value)} /> +
+ { value={password} onChange={event => setPassword(event.target.value)} /> +
+

Remember me:{' '} { }} />

+ +
diff --git a/frontend/src/pages/SettingsScheduling.tsx b/frontend/src/pages/SettingsScheduling.tsx index d3f50d93..d1ebd685 100644 --- a/frontend/src/pages/SettingsScheduling.tsx +++ b/frontend/src/pages/SettingsScheduling.tsx @@ -13,6 +13,7 @@ import createAppriseNotificationUrl, { AppriseTaskNameType, } from '../api/actions/createAppriseNotificationUrl'; import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl'; +import { ApiError } from '../functions/APIClient'; const SettingsScheduling = () => { const [refresh, setRefresh] = useState(false); @@ -29,6 +30,11 @@ const SettingsScheduling = () => { const [zipBackupDays, setZipBackupDays] = useState(); const [notificationUrl, setNotificationUrl] = useState(); const [notificationTask, setNotificationTask] = useState(''); + const [checkReindexError, setCheckReindexError] = useState(null); + const [updateSubscribedError, setUpdateSubscribedError] = useState(null); + const [downloadPendingError, setDownloadPendingError] = useState(null); + const [thumnailCheckError, setThumnailCheckError] = useState(null); + const [zipBackupError, setZipBackupError] = useState(null); useEffect(() => { (async () => { @@ -144,15 +150,24 @@ const SettingsScheduling = () => {
@@ -190,15 +205,24 @@ const SettingsScheduling = () => {
@@ -237,15 +261,24 @@ const SettingsScheduling = () => {