From 887433fc6ab8cdab17806c2d028c16bf3b05d534 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 14 Oct 2024 15:23:02 -0600 Subject: [PATCH] Streaming download (#14346) * Send downloaded mp4 as a streaming response instead of a file * Add download button to UI * Formatting * Fix CSS and text Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * download video button component * use download button component in review detail dialog * better filename --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- frigate/api/media.py | 132 ++++++++---------- .../components/button/DownloadVideoButton.tsx | 75 ++++++++++ .../overlay/detail/ReviewDetailDialog.tsx | 22 ++- 3 files changed, 151 insertions(+), 78 deletions(-) create mode 100644 web/src/components/button/DownloadVideoButton.tsx diff --git a/frigate/api/media.py b/frigate/api/media.py index 5915875ab..d89774a6d 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -7,6 +7,7 @@ import os import subprocess as sp import time from datetime import datetime, timedelta, timezone +from pathlib import Path as FilePath from urllib.parse import unquote import cv2 @@ -450,8 +451,27 @@ def recording_clip( camera_name: str, start_ts: float, end_ts: float, - download: bool = False, ): + def run_download(ffmpeg_cmd: list[str], file_path: str): + with sp.Popen( + ffmpeg_cmd, + stderr=sp.PIPE, + stdout=sp.PIPE, + text=False, + ) as ffmpeg: + while True: + data = ffmpeg.stdout.read(1024) + if data is not None: + yield data + else: + if ffmpeg.returncode and ffmpeg.returncode != 0: + logger.error( + f"Failed to generate clip, ffmpeg logs: {ffmpeg.stderr.read()}" + ) + else: + FilePath(file_path).unlink(missing_ok=True) + break + recordings = ( Recordings.select( Recordings.path, @@ -467,18 +487,18 @@ def recording_clip( .order_by(Recordings.start_time.asc()) ) - playlist_lines = [] - clip: Recordings - for clip in recordings: - playlist_lines.append(f"file '{clip.path}'") - # if this is the starting clip, add an inpoint - if clip.start_time < start_ts: - playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}") - # if this is the ending clip, add an outpoint - if clip.end_time > end_ts: - playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}") - - file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") + file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") + file_path = f"/tmp/cache/{file_name}" + with open(file_path, "w") as file: + clip: Recordings + for clip in recordings: + file.write(f"file '{clip.path}'\n") + # if this is the starting clip, add an inpoint + if clip.start_time < start_ts: + file.write(f"inpoint {int(start_ts - clip.start_time)}\n") + # if this is the ending clip, add an outpoint + if clip.end_time > end_ts: + file.write(f"outpoint {int(end_ts - clip.start_time)}\n") if len(file_name) > 1000: return JSONResponse( @@ -489,67 +509,32 @@ def recording_clip( status_code=403, ) - path = os.path.join(CLIPS_DIR, f"cache/{file_name}") - config: FrigateConfig = request.app.frigate_config - if not os.path.exists(path): - ffmpeg_cmd = [ - config.ffmpeg.ffmpeg_path, - "-hide_banner", - "-y", - "-protocol_whitelist", - "pipe,file", - "-f", - "concat", - "-safe", - "0", - "-i", - "/dev/stdin", - "-c", - "copy", - "-movflags", - "+faststart", - path, - ] - p = sp.run( - ffmpeg_cmd, - input="\n".join(playlist_lines), - encoding="ascii", - capture_output=True, - ) + ffmpeg_cmd = [ + config.ffmpeg.ffmpeg_path, + "-hide_banner", + "-y", + "-protocol_whitelist", + "pipe,file", + "-f", + "concat", + "-safe", + "0", + "-i", + file_path, + "-c", + "copy", + "-movflags", + "frag_keyframe+empty_moov", + "-f", + "mp4", + "pipe:", + ] - if p.returncode != 0: - logger.error(p.stderr) - return JSONResponse( - content={ - "success": False, - "message": "Could not create clip from recordings", - }, - status_code=500, - ) - else: - logger.debug( - f"Ignoring subsequent request for {path} as it already exists in the cache." - ) - - headers = { - "Content-Description": "File Transfer", - "Cache-Control": "no-cache", - "Content-Type": "video/mp4", - "Content-Length": str(os.path.getsize(path)), - # nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers - "X-Accel-Redirect": f"/clips/cache/{file_name}", - } - - if download: - headers["Content-Disposition"] = "attachment; filename=%s" % file_name - - return FileResponse( - path, + return StreamingResponse( + run_download(ffmpeg_cmd, file_path), media_type="video/mp4", - filename=file_name, - headers=headers, ) @@ -1028,7 +1013,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False @router.get("/events/{event_id}/clip.mp4") -def event_clip(request: Request, event_id: str, download: bool = False): +def event_clip(request: Request, event_id: str): try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: @@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False): end_ts = ( datetime.now().timestamp() if event.end_time is None else event.end_time ) - return recording_clip(request, event.camera, event.start_time, end_ts, download) + return recording_clip(request, event.camera, event.start_time, end_ts) headers = { "Content-Description": "File Transfer", @@ -1059,9 +1044,6 @@ def event_clip(request: Request, event_id: str, download: bool = False): "X-Accel-Redirect": f"/clips/{file_name}", } - if download: - headers["Content-Disposition"] = "attachment; filename=%s" % file_name - return FileResponse( clip_path, media_type="video/mp4", diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx new file mode 100644 index 000000000..ffb50098e --- /dev/null +++ b/web/src/components/button/DownloadVideoButton.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { FaDownload } from "react-icons/fa"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; + +type DownloadVideoButtonProps = { + source: string; + camera: string; + startTime: number; +}; + +export function DownloadVideoButton({ + source, + camera, + startTime, +}: DownloadVideoButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + setIsDownloading(true); + const formattedDate = formatUnixTimestampToDateTime(startTime, { + strftime_fmt: "%D-%T", + time_style: "medium", + date_style: "medium", + }); + const filename = `${camera}_${formattedDate}.mp4`; + + try { + const response = await fetch(source); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + toast.success( + "Your review item video has been downloaded successfully.", + { + position: "top-center", + }, + ); + } catch (error) { + toast.error( + "There was an error downloading the review item video. Please try again.", + { + position: "top-center", + }, + ); + } finally { + setIsDownloading(false); + } + }; + + return ( +
+ +
+ ); +} diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index ae0397470..fb3a95b57 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -38,6 +38,8 @@ import { MobilePageTitle, } from "@/components/mobile/MobilePage"; import { useOverlayState } from "@/hooks/use-overlay-state"; +import { DownloadVideoButton } from "@/components/button/DownloadVideoButton"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -143,7 +145,7 @@ export default function ReviewDetailDialog({ Review item details
- Share this review item + + Share this review item + + + + + + + + Download +
@@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
-
+
Objects
{events?.map((event) => {