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>
This commit is contained in:
Nicolas Mowen 2024-10-14 15:23:02 -06:00 committed by GitHub
parent dd7a07bd0d
commit 887433fc6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 151 additions and 78 deletions

View File

@ -7,6 +7,7 @@ import os
import subprocess as sp import subprocess as sp
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path as FilePath
from urllib.parse import unquote from urllib.parse import unquote
import cv2 import cv2
@ -450,8 +451,27 @@ def recording_clip(
camera_name: str, camera_name: str,
start_ts: float, start_ts: float,
end_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 = (
Recordings.select( Recordings.select(
Recordings.path, Recordings.path,
@ -467,18 +487,18 @@ def recording_clip(
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
) )
playlist_lines = [] file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt")
clip: Recordings file_path = f"/tmp/cache/{file_name}"
for clip in recordings: with open(file_path, "w") as file:
playlist_lines.append(f"file '{clip.path}'") clip: Recordings
# if this is the starting clip, add an inpoint for clip in recordings:
if clip.start_time < start_ts: file.write(f"file '{clip.path}'\n")
playlist_lines.append(f"inpoint {int(start_ts - clip.start_time)}") # if this is the starting clip, add an inpoint
# if this is the ending clip, add an outpoint if clip.start_time < start_ts:
if clip.end_time > end_ts: file.write(f"inpoint {int(start_ts - clip.start_time)}\n")
playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}") # if this is the ending clip, add an outpoint
if clip.end_time > end_ts:
file_name = sanitize_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") file.write(f"outpoint {int(end_ts - clip.start_time)}\n")
if len(file_name) > 1000: if len(file_name) > 1000:
return JSONResponse( return JSONResponse(
@ -489,67 +509,32 @@ def recording_clip(
status_code=403, status_code=403,
) )
path = os.path.join(CLIPS_DIR, f"cache/{file_name}")
config: FrigateConfig = request.app.frigate_config config: FrigateConfig = request.app.frigate_config
if not os.path.exists(path): ffmpeg_cmd = [
ffmpeg_cmd = [ config.ffmpeg.ffmpeg_path,
config.ffmpeg.ffmpeg_path, "-hide_banner",
"-hide_banner", "-y",
"-y", "-protocol_whitelist",
"-protocol_whitelist", "pipe,file",
"pipe,file", "-f",
"-f", "concat",
"concat", "-safe",
"-safe", "0",
"0", "-i",
"-i", file_path,
"/dev/stdin", "-c",
"-c", "copy",
"copy", "-movflags",
"-movflags", "frag_keyframe+empty_moov",
"+faststart", "-f",
path, "mp4",
] "pipe:",
p = sp.run( ]
ffmpeg_cmd,
input="\n".join(playlist_lines),
encoding="ascii",
capture_output=True,
)
if p.returncode != 0: return StreamingResponse(
logger.error(p.stderr) run_download(ffmpeg_cmd, file_path),
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,
media_type="video/mp4", 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") @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: try:
event: Event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
except DoesNotExist: except DoesNotExist:
@ -1048,7 +1033,7 @@ def event_clip(request: Request, event_id: str, download: bool = False):
end_ts = ( end_ts = (
datetime.now().timestamp() if event.end_time is None else event.end_time 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 = { headers = {
"Content-Description": "File Transfer", "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}", "X-Accel-Redirect": f"/clips/{file_name}",
} }
if download:
headers["Content-Disposition"] = "attachment; filename=%s" % file_name
return FileResponse( return FileResponse(
clip_path, clip_path,
media_type="video/mp4", media_type="video/mp4",

View File

@ -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 (
<div className="flex justify-center">
<Button
onClick={handleDownload}
disabled={isDownloading}
className="flex items-center gap-2"
size="sm"
>
{isDownloading ? (
<ActivityIndicator className="h-4 w-4" />
) : (
<FaDownload className="h-4 w-4" />
)}
</Button>
</div>
);
}

View File

@ -38,6 +38,8 @@ import {
MobilePageTitle, MobilePageTitle,
} from "@/components/mobile/MobilePage"; } from "@/components/mobile/MobilePage";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type ReviewDetailDialogProps = { type ReviewDetailDialogProps = {
review?: ReviewSegment; review?: ReviewSegment;
@ -143,7 +145,7 @@ export default function ReviewDetailDialog({
<Description className="sr-only">Review item details</Description> <Description className="sr-only">Review item details</Description>
<div <div
className={cn( className={cn(
"absolute", "absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8", isDesktop && "right-1 top-8",
isMobile && "right-0 top-3", isMobile && "right-0 top-3",
)} )}
@ -159,7 +161,21 @@ export default function ReviewDetailDialog({
<FaShareAlt className="size-4 text-secondary-foreground" /> <FaShareAlt className="size-4 text-secondary-foreground" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Share this review item</TooltipContent> <TooltipPortal>
<TooltipContent>Share this review item</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Download</TooltipContent>
</TooltipPortal>
</Tooltip> </Tooltip>
</div> </div>
</Header> </Header>
@ -180,7 +196,7 @@ export default function ReviewDetailDialog({
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-center gap-2"> <div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5"> <div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">Objects</div> <div className="text-sm text-primary/40">Objects</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize"> <div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm capitalize">
{events?.map((event) => { {events?.map((event) => {