From 8cdcb6620fcacd5ea861ad77674d68a0d430992e Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Tue, 27 Feb 2024 22:39:05 +0700 Subject: [PATCH] add downloading record & event add search add events props decompose events accordion --- package.json | 1 + src/App.tsx | 2 + src/pages/HostConfigPage.tsx | 4 +- src/pages/MainPage.tsx | 78 +++++---- src/pages/RecordingsPage.tsx | 12 +- src/services/frigate.proxy/frigate.api.ts | 47 +++-- src/services/frigate.proxy/frigate.schema.ts | 10 +- .../components/accordion/CameraAccordion.tsx | 4 +- .../components/accordion/DayAccordion.tsx | 25 ++- .../components/accordion/EventPanel.tsx | 58 ++++++ .../components/accordion/EventsAccordion.tsx | 35 ++-- .../components/images/AutoUpdatedImage.tsx | 33 ++-- src/shared/components/inputs/HeadSearch.tsx | 25 ++- .../components/menu/HostSettingsMenu.tsx | 3 +- src/shared/strings/strings.ts | 1 + src/shared/utils/dateUtil.ts | 43 ++++- src/widgets/CameraCard.tsx | 3 +- ...SelecteDayList.tsx => SelectedDayList.tsx} | 14 +- src/widgets/VideoDownloader.tsx | 165 ++++++++++++++++++ yarn.lock | 30 +++- 20 files changed, 476 insertions(+), 117 deletions(-) create mode 100644 src/shared/components/accordion/EventPanel.tsx rename src/widgets/{SelecteDayList.tsx => SelectedDayList.tsx} (81%) create mode 100644 src/widgets/VideoDownloader.tsx diff --git a/package.json b/package.json index 95d0385..1a074ad 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@mantine/core": "^6.0.16", "@mantine/dates": "^6.0.16", "@mantine/hooks": "^6.0.16", + "@mantine/notifications": "6.0.16", "@monaco-editor/react": "^4.6.0", "@tabler/icons-react": "^2.24.0", "@tanstack/react-query": "^5.21.2", diff --git a/src/App.tsx b/src/App.tsx index 4a30846..cbb0162 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { BrowserRouter } from 'react-router-dom'; import AppBody from './AppBody'; import Forbidden from './pages/403'; import { QueryClient } from '@tanstack/react-query'; +import { Notifications } from '@mantine/notifications'; function App() { @@ -62,6 +63,7 @@ function App() { }} > + diff --git a/src/pages/HostConfigPage.tsx b/src/pages/HostConfigPage.tsx index 8e2d55f..700c0c3 100644 --- a/src/pages/HostConfigPage.tsx +++ b/src/pages/HostConfigPage.tsx @@ -21,7 +21,9 @@ const HostConfigPage = () => { queryFn: async () => { const host = await frigateApi.getHost(id || '') const hostName = mapHostToHostname(host) - return proxyApi.getHostConfigRaw(hostName) + if (hostName) + return proxyApi.getHostConfigRaw(hostName) + return null }, }) diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 1b07b4b..5473ac0 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,22 +1,33 @@ -import { Flex, Grid, Group } from '@mantine/core'; -import HeadSearch from '../shared/components/inputs/HeadSearch'; -import ViewSelector, { SelectorViewState } from '../shared/components/TableGridViewSelector'; -import { useContext, useState, useEffect } from 'react'; -import { getCookie, setCookie } from 'cookies-next'; +import { Flex, Grid, Group, TextInput } from '@mantine/core'; +import { useContext, useState, useEffect, useMemo } from 'react'; import { Context } from '..'; import { observer } from 'mobx-react-lite' import CenterLoader from '../shared/components/loaders/CenterLoader'; import { useQuery } from '@tanstack/react-query'; import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import RetryErrorPage from './RetryErrorPage'; -import { useMediaQuery } from '@mantine/hooks'; -import { dimensions } from '../shared/dimensions/dimensions'; import CameraCard from '../widgets/CameraCard'; +import { IconSearch } from '@tabler/icons-react'; +import React from 'react'; +import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema'; const MainPage = () => { const { sideBarsStore } = useContext(Context) - const isMobile = useMediaQuery(dimensions.mobileSize) + const [searchQuery, setSearchQuery] = useState() + const [filteredCameras, setFilteredCameras] = useState() + const { data: cameras, isPending, isError, refetch } = useQuery({ + queryKey: [frigateQueryKeys.getCamerasWHost], + queryFn: frigateApi.getCamerasWHost + }) + + useEffect(() => { + if (searchQuery && cameras) { + setFilteredCameras(cameras.filter(camera => camera.name.toLowerCase().includes(searchQuery.toLowerCase()))) + } else { + setFilteredCameras(undefined) + } + }, [searchQuery, cameras]) useEffect(() => { sideBarsStore.rightVisible = false @@ -24,31 +35,28 @@ const MainPage = () => { sideBarsStore.setRightChildren(null) }, []) - const [viewState, setTableState] = useState(getCookie('aps-main-view') as SelectorViewState || SelectorViewState.GRID) - const handleToggleState = (state: SelectorViewState) => { - setCookie('aps-main-view', state, { maxAge: 60 * 60 * 24 * 30 }); - setTableState(state) - } - - const { data: cameras, isPending, isError, refetch } = useQuery({ - queryKey: [frigateQueryKeys.getCamerasWHost], - queryFn: frigateApi.getCamerasWHost - }) + const cards = useMemo(() => { + if (filteredCameras) + return filteredCameras.map(camera => ( + ) + ) + else + return cameras?.map( + camera => ( + ) + ) + }, [cameras, filteredCameras, searchQuery]) if (isPending) return if (isError) return - const cards = () => { - // return cameras.filter(cam => cam.frigateHost?.host.includes('5000')).slice(0, 25).map(camera => ( - return cameras.map(camera => ( - ) - ) - } - return ( @@ -56,12 +64,20 @@ const MainPage = () => { style={{ justifyContent: 'center', }} - > - {/* */} + > + } + value={searchQuery} + onChange={(event) => setSearchQuery(event.currentTarget.value)} + /> + - {cards()} + {cards} diff --git a/src/pages/RecordingsPage.tsx b/src/pages/RecordingsPage.tsx index a9fa7c0..8ac3122 100644 --- a/src/pages/RecordingsPage.tsx +++ b/src/pages/RecordingsPage.tsx @@ -1,18 +1,14 @@ -import { useState, useContext, useEffect, lazy, Suspense } from 'react'; -import { Accordion, Flex, Text } from '@mantine/core'; +import { useState, useContext, useEffect } from 'react'; +import { Flex, Text } from '@mantine/core'; import { useLocation, useNavigate } from 'react-router-dom'; import { observer } from 'mobx-react-lite'; import { Context } from '..'; import RecordingsFiltersRightSide from '../widgets/RecordingsFiltersRightSide'; import SelectedCameraList from '../widgets/SelectedCameraList'; import SelectedHostList from '../widgets/SelectedHostList'; -import { useQuery } from '@tanstack/react-query'; -import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; import { dateToQueryString, parseQueryDateToDate } from '../shared/utils/dateUtil'; -import SelecteDayList from '../widgets/SelecteDayList'; -import { useDebouncedValue } from '@mantine/hooks'; -import CogwheelLoader from '../shared/components/loaders/CogwheelLoader'; +import SelectedDayList from '../widgets/SelectedDayList'; import CenterLoader from '../shared/components/loaders/CenterLoader'; @@ -102,7 +98,7 @@ const RecordingsPage = observer(() => { const [startDay, endDay] = period if (startDay && endDay) { if (startDay.getDate() === endDay.getDate()) { // if select only one day - return + return } } diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts index 00d3e77..616be0e 100644 --- a/src/services/frigate.proxy/frigate.api.ts +++ b/src/services/frigate.proxy/frigate.api.ts @@ -4,7 +4,7 @@ import { z } from "zod" import { GetConfig, DeleteFrigateHost, GetFrigateHost, PutConfig, PutFrigateHost, GetFrigateHostWithCameras, GetCameraWHost, GetCameraWHostWConfig, GetRole, - GetUserByRole, GetRoleWCameras + GetUserByRole, GetRoleWCameras, GetExportedFile } from "./frigate.schema"; import { FrigateConfig } from "../../types/frigateConfig"; import { url } from "inspector"; @@ -46,15 +46,28 @@ export const frigateApi = { }).then(res => res.data) } +export const proxyPrefix = `${proxyURL.protocol}//${proxyURL.host}/proxy/` + export const proxyApi = { getHostConfigRaw: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config/raw`).then(res => res.data), getHostConfig: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/config`).then(res => res.data), getImageFrigate: async (imageUrl: string) => { - const response = await fetch(imageUrl); - if (!response.ok) { - throw new Error('Network response was not ok'); - } - return response.blob(); + const response = await axios.get(imageUrl, { + responseType: 'blob' + }) + return response.data + }, + getVideoFrigate: async (videoUrl: string, onProgress: (percentage: number | undefined) => void) => { + const response = await axios.get(videoUrl, { + responseType: 'blob', + onDownloadProgress: (progressEvent) => { + const total = progressEvent.total + const current = progressEvent.loaded; + const percentage = total ? (current / total) * 100 : undefined + onProgress(percentage); + }, + }) + return response.data }, getHostRestart: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/restart`).then(res => res.data), @@ -108,23 +121,35 @@ export const proxyApi = { cameraWsURL: (hostName: string, cameraName: string) => `ws://${proxyURL.host}/proxy-ws/${hostName}/live/jsmpeg/${cameraName}`, cameraImageURL: (hostName: string, cameraName: string) => - `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/api/${cameraName}/latest.jpg`, + `${proxyPrefix}${hostName}/api/${cameraName}/latest.jpg`, eventURL: (hostName: string, event: string) => - `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/vod/event/${event}/master.m3u8`, + `${proxyPrefix}${hostName}/vod/event/${event}/master.m3u8`, + eventThumbnailUrl: (hostName: string, eventId: string) => `${proxyPrefix}${hostName}/api/events/${eventId}/thumbnail.jpg`, + eventDownloadURL: (hostName: string, eventId: string) => `${proxyPrefix}${hostName}/api/events/${eventId}/clip.mp4?download=true`, // http://127.0.0.1:5000/vod/2024-02/23/19/CameraName/Asia,Krasnoyarsk/master.m3u8 recordingURL: (hostName: string, cameraName: string, timezone: string, day: string, hour: string) => {// day:2024-02-23 hour:19 const parts = day.split('-') const date = `${parts[0]}-${parts[1]}/${parts[2]}/${hour}` - return `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}/vod/${date}/${cameraName}/${timezone}/master.m3u8` + return `${proxyPrefix}${hostName}/vod/${date}/${cameraName}/${timezone}/master.m3u8` }, - // linkURL: (hostName: string, link: string) => `${proxyURL.protocol}//${proxyURL.host}/proxy/${hostName}${link}`, TODO delete + postExportVideoTask: (hostName: string, cameraName: string, startUnixTime: number, endUnixTime: number) => { + const url = `proxy/${hostName}/api/export/${cameraName}/start/${startUnixTime}/end/${endUnixTime}` + return instanceApi.post(url, { playback: 'realtime' }).then(res => res.data) // Payload: {"playback":"realtime"} + }, + getExportedVideoList: (hostName: string) => instanceApi.get(`proxy/${hostName}/exports/`).then(res => res.data), + getVideoUrl: (hostName: string, fileName: string) => `${proxyPrefix}${hostName}/exports/${fileName}`, + deleteExportedVideo: (hostName: string, videoName: string) => instanceApi.delete(`proxy/${hostName}/api/export/${videoName}`).then(res => res.data) + + // filename example Home_1_Backyard_2024_02_26_16_25__2024_02_26_16_26.mp4 + } export const mapCamerasFromConfig = (config: FrigateConfig): string[] => { return Object.keys(config.cameras) } -export const mapHostToHostname = (host: GetFrigateHost): string => { +export const mapHostToHostname = (host?: GetFrigateHost): string | undefined => { + if (!host) return const url = new URL(host.host) const hostName = url.host return hostName diff --git a/src/services/frigate.proxy/frigate.schema.ts b/src/services/frigate.proxy/frigate.schema.ts index cadc73b..ef2e9bd 100644 --- a/src/services/frigate.proxy/frigate.schema.ts +++ b/src/services/frigate.proxy/frigate.schema.ts @@ -98,6 +98,13 @@ export const getUserByRoleSchema = z.object({ email: z.string(), }) +export const getExpotedFile = z.object({ + name: z.string(), + type: z.string(), + mtime: z.string(), + size: z.number(), +}) + export type GetConfig = z.infer export type PutConfig = z.infer export type GetFrigateHost = z.infer @@ -109,4 +116,5 @@ export type PutFrigateHost = z.infer export type DeleteFrigateHost = z.infer export type GetRole = z.infer export type GetRoleWCameras = z.infer -export type GetUserByRole = z.infer \ No newline at end of file +export type GetUserByRole = z.infer +export type GetExportedFile = z.infer \ No newline at end of file diff --git a/src/shared/components/accordion/CameraAccordion.tsx b/src/shared/components/accordion/CameraAccordion.tsx index f1961df..fd8eb10 100644 --- a/src/shared/components/accordion/CameraAccordion.tsx +++ b/src/shared/components/accordion/CameraAccordion.tsx @@ -24,12 +24,12 @@ const CameraAccordion = ({ const camera = recStore.openedCamera || recStore.filteredCamera const host = recStore.filteredHost + const hostName = mapHostToHostname(host) const { data, isPending, isError, refetch } = useQuery({ queryKey: [frigateQueryKeys.getRecordingsSummary, camera?.id], queryFn: () => { - if (camera && host) { - const hostName = mapHostToHostname(host) + if (camera && hostName) { return proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone()) } return null diff --git a/src/shared/components/accordion/DayAccordion.tsx b/src/shared/components/accordion/DayAccordion.tsx index 50fdf39..82a3e1f 100644 --- a/src/shared/components/accordion/DayAccordion.tsx +++ b/src/shared/components/accordion/DayAccordion.tsx @@ -1,4 +1,4 @@ -import { Accordion, Center, Flex, Group, NavLink, Text, UnstyledButton } from '@mantine/core'; +import { Accordion, Center, Flex, Group, NavLink, Progress, Text, UnstyledButton } from '@mantine/core'; import React, { useContext, useEffect, useState } from 'react'; import { RecordSummary } from '../../../types/record'; import { observer } from 'mobx-react-lite'; @@ -6,7 +6,7 @@ import PlayControl from '../buttons/PlayControl'; import { mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api'; import { Context } from '../../..'; import VideoPlayer from '../players/VideoPlayer'; -import { getResolvedTimeZone } from '../../utils/dateUtil'; +import { getResolvedTimeZone, mapDateHourToUnixTime } from '../../utils/dateUtil'; import DayEventsAccordion from './DayEventsAccordion'; import { strings } from '../../strings/strings'; import { useNavigate } from 'react-router-dom'; @@ -14,6 +14,7 @@ import AccordionControlButton from '../buttons/AccordionControlButton'; import { IconExternalLink, IconShare } from '@tabler/icons-react'; import { routesPath } from '../../../router/routes.path'; import AccordionShareButton from '../buttons/AccordionShareButton'; +import VideoDownloader from '../../../widgets/VideoDownloader'; interface RecordingAccordionProps { recordSummary?: RecordSummary @@ -26,14 +27,14 @@ const DayAccordion = ({ const [playedValue, setVideoPlayerState] = useState() const [openedValue, setOpenedValue] = useState() const [playerUrl, setPlayerUrl] = useState() - const [link, setLink] = useState() const navigate = useNavigate() const camera = recStore.openedCamera || recStore.filteredCamera + const hostName = mapHostToHostname(recStore.filteredHost) const createRecordURL = (recordId: string): string | undefined => { const record = { - hostName: recStore.filteredHost ? mapHostToHostname(recStore.filteredHost) : '', + hostName: hostName ? hostName : '', cameraName: camera?.name, day: recordSummary?.day, hour: recordId, @@ -119,7 +120,7 @@ const DayAccordion = ({ {hourLabel(hour.hour, hour.events)} - + hanleOpenNewLink(hour.hour)}> @@ -129,10 +130,20 @@ const DayAccordion = ({ onClick={handleOpenPlayer} /> - {playedValue === hour.hour && playerUrl ? : <>} + { } + {recStore.filteredHost && camera && hostName ? + + + + : ''} {hour.events > 0 ? : @@ -141,7 +152,9 @@ const DayAccordion = ({ ))} + + ) } diff --git a/src/shared/components/accordion/EventPanel.tsx b/src/shared/components/accordion/EventPanel.tsx new file mode 100644 index 0000000..d91078a --- /dev/null +++ b/src/shared/components/accordion/EventPanel.tsx @@ -0,0 +1,58 @@ +import { Flex, Group, Button, Text, Image } from '@mantine/core'; +import { IconExternalLink } from '@tabler/icons-react'; +import React from 'react'; +import { proxyApi } from '../../../services/frigate.proxy/frigate.api'; +import { strings } from '../../strings/strings'; +import { unixTimeToDate, getDurationFromTimestamps } from '../../utils/dateUtil'; +import VideoPlayer from '../players/VideoPlayer'; +import { EventFrigate } from '../../../types/event'; + +interface EventPanelProps { + event: EventFrigate + playedValue?: string + playerUrl?: string + hostName?: string +} + +const EventPanel = ({ + event, + playedValue, + playerUrl, + hostName, +}: EventPanelProps) => { + return ( + <> + {playedValue === event.id && playerUrl ? : <>} + + {!hostName ? <> : + + } + + {!hostName ? '' : + + + + } + {strings.camera}: {event.camera} + {strings.player.object}: {event.label} + {strings.player.startTime}: {unixTimeToDate(event.start_time)} + {strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)} + {strings.player.rating}: {(event.data.score * 100).toFixed(2)}% + + + + ) +} + +export default EventPanel; \ No newline at end of file diff --git a/src/shared/components/accordion/EventsAccordion.tsx b/src/shared/components/accordion/EventsAccordion.tsx index 50170e2..d499281 100644 --- a/src/shared/components/accordion/EventsAccordion.tsx +++ b/src/shared/components/accordion/EventsAccordion.tsx @@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query'; import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api'; import { getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema'; import PlayControl from '../buttons/PlayControl'; -import VideoPlayer from '../players/VideoPlayer'; import { getDurationFromTimestamps, getUnixTime, unixTimeToDate } from '../../utils/dateUtil'; import RetryError from '../RetryError'; import { strings } from '../../strings/strings'; @@ -16,6 +15,7 @@ import { routesPath } from '../../../router/routes.path'; import AccordionControlButton from '../buttons/AccordionControlButton'; import AccordionShareButton from '../buttons/AccordionShareButton'; import { useNavigate } from 'react-router-dom'; +import EventPanel from './EventPanel'; /** * @param day frigate format, e.g day: 2024-02-23 @@ -47,18 +47,19 @@ const EventsAccordion = observer(({ const [playerUrl, setPlayerUrl] = useState() const navigate = useNavigate() - const inHost = recStore.filteredHost - const inCamera = recStore.openedCamera || recStore.filteredCamera - const isRequiredParams = inHost && inCamera + const host = recStore.filteredHost + const hostName = mapHostToHostname(host) + const camera = recStore.openedCamera || recStore.filteredCamera + const isRequiredParams = host && camera const { data, isPending, isError, refetch } = useQuery({ - queryKey: [frigateQueryKeys.getEvents, inHost, inCamera, day, hour], + queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour], queryFn: () => { if (!isRequiredParams) return null const [startTime, endTime] = getUnixTime(day, hour) const parsed = getEventsQuerySchema.safeParse({ - hostName: mapHostToHostname(inHost), - camerasName: [inCamera.name], + hostName: mapHostToHostname(host), + camerasName: [camera.name], after: startTime, before: endTime, hasClip: true, @@ -84,15 +85,15 @@ const EventsAccordion = observer(({ }) const createEventUrl = (eventId: string) => { - if (inHost) - return proxyApi.eventURL(mapHostToHostname(inHost), eventId) + if (hostName) + return proxyApi.eventURL(hostName, eventId) return undefined } useEffect(() => { if (playedValue) { // console.log('openVideoPlayer', playedValue) - if (playedValue && inHost) { + if (playedValue && host) { const url = createEventUrl(playedValue) console.log('GET EVENT URL: ', url) setPlayerUrl(url) @@ -173,15 +174,11 @@ const EventsAccordion = observer(({ - {playedValue === event.id && playerUrl ? : <>} - - {strings.camera}: {event.camera} - {strings.player.object}: {event.label} - - - {strings.player.startTime}: {unixTimeToDate(event.start_time)} - {strings.player.duration}: {getDurationFromTimestamps(event.start_time, event.end_time)} - + ))} diff --git a/src/shared/components/images/AutoUpdatedImage.tsx b/src/shared/components/images/AutoUpdatedImage.tsx index 979a079..5878810 100644 --- a/src/shared/components/images/AutoUpdatedImage.tsx +++ b/src/shared/components/images/AutoUpdatedImage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { CameraConfig } from "../../../types/frigateConfig"; import { Flex, Text, Image } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { frigateApi, proxyApi } from "../../../services/frigate.proxy/frigate.api"; import { useIntersection } from "@mantine/hooks"; import CogwheelLoader from "../loaders/CogwheelLoader"; +import RetryError from "../RetryError"; interface AutoUpdatedImageProps extends React.ImgHTMLAttributes { className?: string; @@ -21,9 +22,10 @@ const AutoUpdatedImage = ({ }: AutoUpdatedImageProps) => { const { ref, entry } = useIntersection({ threshold: 0.1, }) const isVisible = entry?.isIntersecting + const [imageSrc, setImageSrc] = useState(null); const { data: imageBlob, refetch, isPending, isError } = useQuery({ - queryKey: ['image', imageUrl], + queryKey: [imageUrl], queryFn: () => proxyApi.getImageFrigate(imageUrl), staleTime: 60 * 1000, gcTime: Infinity, @@ -33,27 +35,38 @@ const AutoUpdatedImage = ({ useEffect(() => { if (isVisible) { const intervalId = setInterval(() => { - refetch(); - }, 60 * 1000); + refetch() + }, 60 * 1000) return () => clearInterval(intervalId); } - }, [refetch, isVisible]); + }, [refetch, isVisible]) + + useEffect(() => { + if (imageBlob && imageBlob instanceof Blob) { + const objectURL = URL.createObjectURL(imageBlob); + setImageSrc(objectURL); + + return () => { + if (objectURL) { + URL.revokeObjectURL(objectURL); + } + } + } + }, [imageBlob]) if (isPending) return if (isError) return ( - Error loading! + ) - if (!imageBlob || !(imageBlob instanceof Blob)) console.error('imageBlob not Blob object:', imageBlob) - - const image = URL.createObjectURL(imageBlob!) + if (!imageSrc) return null return ( - {enabled ? Dynamic Content + {enabled ? Dynamic Content : Camera is disabled in config, no stream or snapshot available! } diff --git a/src/shared/components/inputs/HeadSearch.tsx b/src/shared/components/inputs/HeadSearch.tsx index b3648fb..1dc5c95 100644 --- a/src/shared/components/inputs/HeadSearch.tsx +++ b/src/shared/components/inputs/HeadSearch.tsx @@ -1,25 +1,22 @@ import { Flex, TextInput } from '@mantine/core'; import { IconSearch } from '@tabler/icons-react'; -import React from 'react'; -import ViewSelector from '../TableGridViewSelector'; +import React, { useState } from 'react'; interface HeadSearchProps { search?: string - handleSearchChange?(): void + onChange?: (event: React.ChangeEvent) => void } -const HeadSearch = ({ search, handleSearchChange }: HeadSearchProps) => { +const HeadSearch = ({ search, onChange }: HeadSearchProps) => { return ( - <> - } - value={search} - onChange={handleSearchChange} - /> - + } + value={search} + onChange={onChange} + /> ); }; diff --git a/src/shared/components/menu/HostSettingsMenu.tsx b/src/shared/components/menu/HostSettingsMenu.tsx index 2d68324..8f322f4 100644 --- a/src/shared/components/menu/HostSettingsMenu.tsx +++ b/src/shared/components/menu/HostSettingsMenu.tsx @@ -31,7 +31,8 @@ const HostSettingsMenu = ({ host }: HostSettingsMenuProps) => { } const handleRestart = () => { - mutation.mutate(mapHostToHostname(host)) + const hostName = mapHostToHostname(host) + if (hostName) mutation.mutate(hostName) } return ( diff --git a/src/shared/strings/strings.ts b/src/shared/strings/strings.ts index d8c4b26..a50431a 100644 --- a/src/shared/strings/strings.ts +++ b/src/shared/strings/strings.ts @@ -13,6 +13,7 @@ export const strings = { startTime: 'Начало', endTime: 'Конец', doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра', + rating: 'Рейтинг', }, empty: 'Пусто', diff --git a/src/shared/utils/dateUtil.ts b/src/shared/utils/dateUtil.ts index 61a6761..2653917 100644 --- a/src/shared/utils/dateUtil.ts +++ b/src/shared/utils/dateUtil.ts @@ -4,6 +4,41 @@ export const longToDate = (long: number): Date => new Date(long * 1000); export const epochToLong = (date: number): number => date / 1000; export const dateToLong = (date: Date): number => epochToLong(date.getTime()); + +export const formatFileTimestamps = (startUnixTime: number, endUnixTime: number, cameraName: string) => { + const formatTime = (time: number) => { + const date = new Date(time * 1000); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${year}_${month}_${day}_${hours}_${minutes}`; + }; + + const startTimeFormatted = formatTime(startUnixTime); + const endTimeFormatted = formatTime(endUnixTime); + const fileName = `${cameraName}_${startTimeFormatted}__${endTimeFormatted}.mp4`; + + return fileName; +} + +/** + * + * @param date e.g. "2024-02-27" + * @param hour e.g. "20" + * @returns [startUnixTime, endUnixTime] + */ +export const mapDateHourToUnixTime = (date: string, hour: string) => { + const startDateTime = new Date(`${date}T${hour}:00:00`) + const startUnixTime = startDateTime.getTime() / 1000 + const endDateTime = new Date(startDateTime); + endDateTime.setMinutes(59) + endDateTime.setSeconds(59) + const endUnixTime = endDateTime.getTime() / 1000 + return [startUnixTime, endUnixTime] +} + const getDateTimeYesterday = (dateTime: Date): Date => { const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000; return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds); @@ -21,7 +56,7 @@ export const unixTimeToDate = (unixTime: number) => { const date = new Date(unixTime * 1000); const formattedDate = date.getFullYear() + - '-' + ('0' + (date.getMonth() + 1)).slice(-2) + + '-' + ('0' + (date.getMonth() + 1)).slice(-2) + '-' + ('0' + date.getDate()).slice(-2) + ' ' + ('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2) + @@ -49,7 +84,7 @@ export const parseQueryDateToDate = (dateQuery: string): Date | null => { if (match) { const year = parseInt(match[1], 10); - const month = parseInt(match[2], 10) - 1; + const month = parseInt(match[2], 10) - 1; const day = parseInt(match[3], 10); return new Date(year, month, day); } @@ -239,12 +274,12 @@ interface DurationToken { */ export const getDurationFromTimestamps = (start_time: number, end_time: number | undefined): string | undefined => { if (isNaN(start_time)) { - return + return } let duration = 'In Progress'; if (end_time) { if (isNaN(end_time)) { - return + return } const start = fromUnixTime(start_time); const end = fromUnixTime(end_time); diff --git a/src/widgets/CameraCard.tsx b/src/widgets/CameraCard.tsx index 19d7d35..26d6882 100644 --- a/src/widgets/CameraCard.tsx +++ b/src/widgets/CameraCard.tsx @@ -44,7 +44,8 @@ const CameraCard = ({ }: CameraCardProps) => { const { classes } = useStyles(); const navigate = useNavigate() - const imageUrl = camera.frigateHost ? proxyApi.cameraImageURL(mapHostToHostname(camera.frigateHost), camera.name) : '' //todo implement get URL from live cameras + const hostName = mapHostToHostname(camera.frigateHost) + const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras const handleOpenLiveView = () => { const url = routesPath.LIVE_PATH.replace(':id', camera.id) diff --git a/src/widgets/SelecteDayList.tsx b/src/widgets/SelectedDayList.tsx similarity index 81% rename from src/widgets/SelecteDayList.tsx rename to src/widgets/SelectedDayList.tsx index 4a057c2..71272db 100644 --- a/src/widgets/SelecteDayList.tsx +++ b/src/widgets/SelectedDayList.tsx @@ -9,14 +9,14 @@ import CenterLoader from '../shared/components/loaders/CenterLoader'; import { observer } from 'mobx-react-lite'; import DayAccordion from '../shared/components/accordion/DayAccordion'; -interface SelecteDayListProps { +interface SelectedDayListProps { day: Date } -const SelecteDayList = ({ +const SelectedDayList = ({ day -}: SelecteDayListProps) => { +}: SelectedDayListProps) => { const { recordingsStore: recStore } = useContext(Context) const camera = recStore.filteredCamera const host = recStore.filteredHost @@ -27,8 +27,10 @@ const SelecteDayList = ({ if (camera && host) { const stringDay = dateToQueryString(day) const hostName = mapHostToHostname(host) - const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone()) - return res.find(record => record.day === stringDay) + if (hostName){ + const res = await proxyApi.getRecordingsSummary(hostName, camera.name, getResolvedTimeZone()) + return res.find(record => record.day === stringDay) + } } return null } @@ -50,4 +52,4 @@ const SelecteDayList = ({ ); }; -export default observer(SelecteDayList); \ No newline at end of file +export default observer(SelectedDayList); \ No newline at end of file diff --git a/src/widgets/VideoDownloader.tsx b/src/widgets/VideoDownloader.tsx new file mode 100644 index 0000000..f64bd4a --- /dev/null +++ b/src/widgets/VideoDownloader.tsx @@ -0,0 +1,165 @@ +import { Button, Loader, Text, Notification, Progress } from '@mantine/core'; +import { useMutation } from '@tanstack/react-query'; +import { frigateApi, proxyApi } from '../services/frigate.proxy/frigate.api'; +import { IconAlertCircle, IconExternalLink } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import RetryError from '../shared/components/RetryError'; +import { formatFileTimestamps, unixTimeToDate } from '../shared/utils/dateUtil'; +import { notifications } from '@mantine/notifications'; + +interface VideoDownloaderProps { + cameraName: string + hostName: string + startUnixTime: number + endUnixTime: number +} + +const VideoDownloader = ({ + cameraName, + hostName, + startUnixTime, + endUnixTime, +}: VideoDownloaderProps) => { + const maxVideoTime = 70 * 60 + const [createName, setCreateName] = useState() + const [link, setLink] = useState() + const [error, setError] = useState() + const [timer, setTimer] = useState() + const [videoBlob, setVideoBlob] = useState() + const [videoSrc, setVideoSrc] = useState() + const [progress, setProgress] = useState() + + const createVideo = useMutation({ + mutationFn: () => { + setError(false) + return proxyApi.postExportVideoTask(hostName, cameraName, startUnixTime, endUnixTime) + }, + onSuccess: () => { + const fileName = formatFileTimestamps(startUnixTime, endUnixTime, cameraName) + setCreateName(fileName) + }, + onError: () => setError(true) + }) + + const checkVideo = useMutation({ mutationFn: () => proxyApi.getExportedVideoList(hostName) }) + + const getVideBlob = useMutation({ + mutationKey: [link], + mutationFn: (inlink: string) => { + return proxyApi.getVideoFrigate(inlink, (progress) => { + setProgress(progress) + }) + }, + onSuccess: (data) => { + setVideoBlob(data) + }, + }) + + const deleteVideo = useMutation({ + mutationFn: (videoName: string) => proxyApi.deleteExportedVideo(hostName, videoName), + onSuccess: () => setLink(undefined) + }) + + useEffect(() => { + if (createName && !link && !videoBlob) { + const intervalId = setInterval(() => { + checkVideo.mutate(undefined, { + onSuccess: (data) => { + const createdFile = data.find(file => file.name === createName); + if (createdFile && !link && !videoBlob) { + const link = proxyApi.getVideoUrl(hostName, createdFile.name) + setLink(link); + getVideBlob.mutateAsync(link) + clearInterval(intervalId) + setTimer(undefined) + } + } + }) + }, 5 * 1000) + + if (intervalId) setTimer(intervalId) + + setTimeout(() => { + clearInterval(timer) + setTimer(undefined) + }, 5 * 60 * 1000) + } + }, [createName, link, videoBlob]) + + useEffect(() => { + if (videoBlob && videoBlob instanceof Blob && createName) { + if (link) deleteVideo.mutateAsync(createName) + const objectURL = URL.createObjectURL(videoBlob); + setVideoSrc(objectURL); + return () => { + if (objectURL) { + URL.revokeObjectURL(objectURL); + } + } + } + }, [videoBlob, createName, link]) + + const checkTime = () => { + const duration = endUnixTime - startUnixTime + if (duration > maxVideoTime) { + notifications.show({ + id: 'too-much-time', + withCloseButton: true, + onClose: () => console.log('unmounted'), + onOpen: () => console.log('mounted'), + autoClose: 5000, + title: "Max duration", + message: `Time can not be higher than ${maxVideoTime / 60} hour`, + color: 'red', + icon: , + }) + return false + } + return true + } + + const handleDownload = () => { + if (!checkTime()) return + createVideo.mutate() + + } + + const handleCancel = () => { + clearTimeout(timer) + setTimer(undefined) + setCreateName(undefined) + setLink(undefined) + } + + + if (startUnixTime === 0 || endUnixTime === 0) return null + if (error) return createVideo.mutate()} /> + if (link && progress && !videoSrc) return ( + + ) + + const getReadyDownload = ( + // link example http://my-proxy-server/hostname:4000/exports/HOME_1_Backyard_2024_02_26_16_25__2024_02_26_16_26.mp4 + + ) + if (videoSrc) return getReadyDownload + + const preparingVideo = ( + <> + Preparing video... + + ) + if (createName) return preparingVideo + + return ( + + ); +}; + +export default VideoDownloader; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4e95efd..45adf69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1140,7 +1140,7 @@ dependencies: regenerator-runtime "^0.13.11" -"@babel/runtime@^7.5.5": +"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.23.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw== @@ -1802,6 +1802,14 @@ resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-6.0.16.tgz#507c322347659424a915ec6b541d848f53abcf6a" integrity sha512-DnfMYSTSjYxbQJ80TzKHO5gRXGTIQKxBnRQVc+n4RANTwgWMiwEmxIwqRjbulfLzIhEvplskhqGgElunIAfw7g== +"@mantine/notifications@6.0.16": + version "6.0.16" + resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-6.0.16.tgz#e3259f9bea564ae58d34810096b9288be47ca815" + integrity sha512-KqlPW51sxgQoJmIC2lEWMVlwPqy04D35iRMkCSget8aNgzk0K5csJppXo6qwMFn2GHKVGXFKJMBUp06IXQbiig== + dependencies: + "@mantine/utils" "6.0.16" + react-transition-group "4.4.2" + "@mantine/styles@6.0.16": version "6.0.16" resolved "https://registry.yarnpkg.com/@mantine/styles/-/styles-6.0.16.tgz#625e5be80fc964fa6634f10e798c19f5a4c265ce" @@ -4327,6 +4335,14 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + dom-serializer@0: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -8204,7 +8220,7 @@ prompts@^2.0.1, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -8481,6 +8497,16 @@ react-textarea-autosize@8.3.4: use-composed-ref "^1.3.0" use-latest "^1.2.1" +react-transition-group@4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" + integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-use-websocket@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/react-use-websocket/-/react-use-websocket-4.7.0.tgz#e45545ed48eb60171bf6401d1884cc80c700a0ea"