Skip to content

Commit

Permalink
Merge pull request #69 from nebulabroadcast/new-proxy-player
Browse files Browse the repository at this point in the history
New proxy player
  • Loading branch information
martastain authored Sep 28, 2024
2 parents ac3081a + 9d9688f commit ddb696a
Show file tree
Hide file tree
Showing 29 changed files with 1,441 additions and 611 deletions.
69 changes: 57 additions & 12 deletions backend/api/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,44 @@
from server.dependencies import CurrentUser
from server.request import APIRequest

MAX_200_SIZE = 1024 * 1024 * 12


class ProxyResponse(Response):
content_type = "video/mp4"


def get_file_size(file_name: str) -> int:
"""Get the size of a file"""
if not os.path.exists(file_name):
raise nebula.NotFoundException("File not found")
return os.stat(file_name).st_size


async def get_bytes_range(file_name: str, start: int, end: int) -> bytes:
"""Get a range of bytes from a file"""
async with aiofiles.open(file_name, mode="rb") as f:
await f.seek(start)
pos = start
# read_size = min(CHUNK_SIZE, end - pos + 1)
read_size = end - pos + 1
return await f.read(read_size)


def _get_range_header(range_header: str, file_size: int) -> tuple[int, int]:
"""
Parse the Range header to determine the start and end byte positions.
Args:
range_header (str): The value of the Range header from the HTTP request.
file_size (int): The total size of the file in bytes.
Returns:
tuple[int, int]: A tuple containing the start and end byte positions.
Raises:
HTTPException: If the range is invalid or cannot be parsed.
"""

def _invalid_range() -> HTTPException:
return HTTPException(
status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE,
Expand All @@ -45,15 +67,24 @@ async def range_requests_response(
request: Request, file_path: str, content_type: str
) -> ProxyResponse:
"""Returns StreamingResponse using Range Requests of a given file"""
file_size = os.stat(file_path).st_size
max_chunk_size = 1024 * 1024 # 2MB

file_size = get_file_size(file_path)

max_chunk_size = 1024 * 1024 * 4
range_header = request.headers.get("range")
max_200_size = MAX_200_SIZE

# screw firefox
if ua := request.headers.get("user-agent"):
if "firefox" in ua.lower():
max_chunk_size = file_size
elif "safari" in ua.lower():
max_200_size = 0

headers = {
"content-type": content_type,
"accept-ranges": "bytes",
"content-encoding": "identity",
"content-length": str(file_size),
"accept-ranges": "bytes",
"access-control-expose-headers": (
"content-type, accept-ranges, content-length, "
"content-range, content-encoding"
Expand All @@ -63,16 +94,31 @@ async def range_requests_response(
end = file_size - 1
status_code = status.HTTP_200_OK

if range_header is not None:
if file_size <= max_200_size:
# if the file has a sane size, we return the whole thing
# in one go. That allows the browser to cache the video
# and prevent unnecessary requests.

headers["content-range"] = f"bytes 0-{end}/{file_size}"

elif range_header is not None:
start, end = _get_range_header(range_header, file_size)
end = min(end, start + max_chunk_size - 1)
end = min(end, start + max_chunk_size - 1, file_size - 1)

size = end - start + 1
headers["content-length"] = str(size)
headers["content-range"] = f"bytes {start}-{end}/{file_size}"
status_code = status.HTTP_206_PARTIAL_CONTENT

if size == file_size:
status_code = status.HTTP_200_OK
else:
status_code = status.HTTP_206_PARTIAL_CONTENT

payload = await get_bytes_range(file_path, start, end)

if status_code == status.HTTP_200_OK:
headers["cache-control"] = "private, max-age=600"

return ProxyResponse(
content=payload,
headers=headers,
Expand All @@ -87,9 +133,9 @@ class ServeProxy(APIRequest):
the file in media players that support HTTPS pseudo-streaming.
"""

name: str = "proxy"
path: str = "/proxy/{id_asset}"
title: str = "Serve proxy"
name = "proxy"
path = "/proxy/{id_asset}"
title = "Serve proxy"
methods = ["GET"]

async def handle(
Expand All @@ -98,7 +144,6 @@ async def handle(
id_asset: int,
user: CurrentUser,
) -> ProxyResponse:
assert user
sys_settings = nebula.settings.system
proxy_storage_path = nebula.storages[sys_settings.proxy_storage].local_path
proxy_path_template = os.path.join(proxy_storage_path, sys_settings.proxy_path)
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/BaseInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ const BaseInput = styled.input`
}
&.timecode {
min-width: 96px;
max-width: 96px;
padding-right: 14px !important;
min-width: 92px;
max-width: 92px;
padding-right: 10px !important;
text-align: right;
font-family: monospace;
}
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/Canvas.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect, forwardRef } from 'react'
import styled from 'styled-components'

const CanvasContainer = styled.div`
position: relative;
padding: 0;
margin: 0;
canvas {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
`

const Canvas = forwardRef(({ style, onDraw, ...props }, ref) => {
useEffect(() => {
if (!ref.current) return
const canvas = ref.current

const handleResize = () => {
canvas.width = canvas.parentElement.clientWidth
canvas.height = canvas.parentElement.clientHeight
if (onDraw) {
onDraw({ target: canvas })
}
}

handleResize()

const parentElement = canvas.parentElement
const resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(parentElement)

return () => resizeObserver.unobserve(parentElement)
}, [ref])

return (
<CanvasContainer style={style}>
<canvas ref={ref} {...props} />
</CanvasContainer>
)
})
Canvas.displayName = 'Canvas'

export default Canvas
2 changes: 1 addition & 1 deletion frontend/src/components/Dropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const Dropdown = ({
if (align === 'right') contentStyle['right'] = 0

return (
<DropdownContainer className={clsx({disabled})}>
<DropdownContainer className={clsx({ disabled })}>
<Button
className="dropbtn"
style={buttonStyle}
Expand Down
10 changes: 2 additions & 8 deletions frontend/src/components/InputDatetime.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,7 @@ const CalendarDialog = ({ value, onChange, onClose }) => {
)
}

const InputDatetime = ({
value,
onChange,
placeholder,
mode,
className,
}) => {
const InputDatetime = ({ value, onChange, placeholder, mode, className }) => {
const [time, setTime] = useState()
const [isFocused, setIsFocused] = useState(false)
const [showCalendar, setShowCalendar] = useState(false)
Expand Down Expand Up @@ -155,7 +149,7 @@ const InputDatetime = ({
value={time || ''}
onChange={handleChange}
style={{ flexGrow: 1 }}
className={clsx(className, {error: !isValidTime(time)})}
className={clsx(className, { error: !isValidTime(time) })}
placeholder={isFocused ? timestampFormat : placeholder}
title={`Please enter a valid time in the format ${timestampFormat}`}
onBlur={onSubmit}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/InputTimecode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const InputTimecode = ({

useEffect(() => {
setInvalid(false)
if (value === null || value === undefined) {
if (value === null || value === undefined || isNaN(value)) {
setText('')
return
}
Expand Down Expand Up @@ -71,13 +71,14 @@ const InputTimecode = ({
onSubmit()
inputRef.current.blur()
}
e.stopPropagation()
}

return (
<BaseInput
type="text"
ref={inputRef}
className={clsx('timecode', className, {error: invalid})}
className={clsx('timecode', className, { error: invalid })}
value={text}
onChange={onChangeHandler}
onKeyDown={onKeyDown}
Expand Down
Loading

0 comments on commit ddb696a

Please sign in to comment.