[WIP] set up streaming route
This commit is contained in:
parent
45f056c6e0
commit
4e869ce0c6
6 changed files with 228 additions and 6 deletions
|
|
@ -9,7 +9,15 @@
|
|||
threshold: :low,
|
||||
# All of these are ignorable because this app is intended to be single-user and self-hosted.
|
||||
# There is an expectation that the user won't intentionally run a FS Traversal on themselves
|
||||
ignore: ["CI.System", "Traversal.FileModule", "Config.HTTPS", "Config.CSP"],
|
||||
ignore:
|
||||
[
|
||||
"CI.System",
|
||||
"Traversal.FileModule",
|
||||
"Config.HTTPS",
|
||||
"Config.CSP",
|
||||
"XSS.ContentType",
|
||||
"Traversal.SendFile"
|
||||
],
|
||||
ignore_files: [],
|
||||
version: false
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,4 +29,68 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
|||
|> put_flash(:info, flash_message)
|
||||
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
||||
end
|
||||
|
||||
# See here for details on streaming files and range requests:
|
||||
# https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
|
||||
def stream(conn, %{"id" => id}) do
|
||||
media_item = Media.get_media_item!(id)
|
||||
|
||||
# TODO: show audio vs. video element in UI depending on media type
|
||||
# TODO: consider how a podcast RSS feed would interact with HTTP basic auth
|
||||
# TODO: reconsider the sobelow changes I made
|
||||
# TODO: UUID stuff
|
||||
|
||||
if File.exists?(media_item.media_filepath) do
|
||||
file_size = File.stat!(media_item.media_filepath).size
|
||||
mime_type = MIME.from_path(media_item.media_filepath)
|
||||
|
||||
case parse_range(conn, file_size) do
|
||||
{:ok, {start_pos, end_pos}} ->
|
||||
length = end_pos - start_pos + 1
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(mime_type)
|
||||
|> put_resp_header("accept-ranges", "bytes")
|
||||
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|
||||
|> put_resp_header("content-length", to_string(length))
|
||||
|> send_file(206, media_item.media_filepath, start_pos, length)
|
||||
|
||||
{:error, :invalid_range} ->
|
||||
conn
|
||||
|> put_resp_content_type(mime_type)
|
||||
|> put_resp_header("content-length", to_string(file_size))
|
||||
|> put_resp_header("accept-ranges", "bytes")
|
||||
|> send_file(200, media_item.media_filepath)
|
||||
end
|
||||
else
|
||||
send_resp(conn, 404, "File not found")
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_range(conn, file_size) do
|
||||
with [range_header | _] <- get_req_header(conn, "range"),
|
||||
["bytes", range] <- String.split(range_header, "="),
|
||||
[start_pos, end_pos] <- String.split(range, "-") do
|
||||
validate_range(start_pos, end_pos, file_size)
|
||||
else
|
||||
_ -> {:error, :invalid_range}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_range(start_pos, end_pos, file_size) do
|
||||
case {Integer.parse(start_pos), Integer.parse(end_pos)} do
|
||||
{:error, :error} ->
|
||||
{:error, :invalid_range}
|
||||
|
||||
{{start_pos, _}, :error} ->
|
||||
{:ok, {start_pos, file_size - 1}}
|
||||
|
||||
# See RFC7233
|
||||
{{start_pos, _}, {end_pos, _}} when end_pos >= file_size ->
|
||||
{:ok, {start_pos, file_size - 1}}
|
||||
|
||||
{{start_pos, _}, {end_pos, _}} ->
|
||||
{:ok, {start_pos, end_pos}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@
|
|||
<.tabbed_layout>
|
||||
<:tab title="Attributes">
|
||||
<div class="flex flex-col gap-10 dark:text-white">
|
||||
<%!-- <audio controls class="w-full">
|
||||
<source src={~p"/media/#{@media_item}/stream"} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio> --%>
|
||||
<video controls class="w-full">
|
||||
<source src={~p"/media/#{@media_item}/stream"} type="video/mp4" />
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<h3 class="font-bold text-xl">Attributes</h3>
|
||||
<section>
|
||||
<strong>Source:</strong>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ defmodule PinchflatWeb.Router do
|
|||
end
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", PinchflatWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
# Routes in here are NOT protected by basic auth. This is necessary for
|
||||
# media streaming to work for RSS podcast feeds.
|
||||
scope "/", PinchflatWeb do
|
||||
get "/media/:id/stream", MediaItems.MediaItemController, :stream
|
||||
end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:pinchflat, :dev_routes) do
|
||||
|
|
|
|||
|
|
@ -66,6 +66,147 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "streaming media" do
|
||||
test "returns 404 if the media isn't found", %{conn: conn} do
|
||||
media_item = media_item_fixture()
|
||||
conn = get(conn, ~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.status == 404
|
||||
end
|
||||
|
||||
test "automatically sets the content type", %{conn: conn} do
|
||||
media_item = media_item_with_attachments()
|
||||
conn = get(conn, ~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert {"content-type", "video/mp4; charset=utf-8"} in conn.resp_headers
|
||||
end
|
||||
|
||||
test "sets the content length", %{conn: conn} do
|
||||
media_item = media_item_with_attachments()
|
||||
filesize = File.stat!(media_item.media_filepath).size
|
||||
|
||||
conn = get(conn, ~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert {"content-length", to_string(filesize)} in conn.resp_headers
|
||||
end
|
||||
end
|
||||
|
||||
describe "streaming media when range is valid" do
|
||||
setup do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
%{media_item: media_item}
|
||||
end
|
||||
|
||||
test "sets the correct status and headers", %{conn: conn, media_item: media_item} do
|
||||
filesize = File.stat!(media_item.media_filepath).size
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=0-100")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.status == 206
|
||||
assert {"content-range", "bytes 0-100/#{filesize}"} in conn.resp_headers
|
||||
assert {"content-length", "101"} in conn.resp_headers
|
||||
end
|
||||
|
||||
test "streams the specified range", %{conn: conn, media_item: media_item} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=0-100")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert byte_size(conn.resp_body) == 101
|
||||
end
|
||||
|
||||
test "supports range offsets", %{conn: conn, media_item: media_item} do
|
||||
contents = File.read!(media_item.media_filepath)
|
||||
expected = String.slice(contents, 100..200)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=100-200")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.resp_body == expected
|
||||
end
|
||||
|
||||
test "returns as expected if the requested range is larger than the file", %{conn: conn, media_item: media_item} do
|
||||
contents = File.read!(media_item.media_filepath)
|
||||
filesize = File.stat!(media_item.media_filepath).size
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=0-#{filesize * 10}")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.resp_body == contents
|
||||
assert {"content-range", "bytes 0-#{filesize - 1}/#{filesize}"} in conn.resp_headers
|
||||
assert {"content-length", to_string(filesize)} in conn.resp_headers
|
||||
end
|
||||
|
||||
test "supports endless ranges", %{conn: conn, media_item: media_item} do
|
||||
contents = File.read!(media_item.media_filepath)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=0-")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.resp_body == contents
|
||||
end
|
||||
|
||||
test "supports endless ranges with offsets", %{conn: conn, media_item: media_item} do
|
||||
contents = File.read!(media_item.media_filepath)
|
||||
{_, expected} = String.split_at(contents, 100)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=100-")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.resp_body == expected
|
||||
end
|
||||
end
|
||||
|
||||
describe "streaming media when range is invalid or not present" do
|
||||
setup do
|
||||
media_item = media_item_with_attachments()
|
||||
|
||||
%{media_item: media_item}
|
||||
end
|
||||
|
||||
test "sets the correct status and headers", %{conn: conn, media_item: media_item} do
|
||||
filesize = File.stat!(media_item.media_filepath).size
|
||||
|
||||
conn = get(conn, ~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.status == 200
|
||||
assert {"content-length", to_string(filesize)} in conn.resp_headers
|
||||
end
|
||||
|
||||
test "streams the entire file", %{conn: conn, media_item: media_item} do
|
||||
contents = File.read!(media_item.media_filepath)
|
||||
|
||||
conn = get(conn, ~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.resp_body == contents
|
||||
end
|
||||
|
||||
test "doesn't blow up if the range header is invalid", %{conn: conn, media_item: media_item} do
|
||||
contents = File.read!(media_item.media_filepath)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("range", "bytes=-")
|
||||
|> get(~p"/media/#{media_item.id}/stream")
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body == contents
|
||||
end
|
||||
end
|
||||
|
||||
defp create_media_item(_) do
|
||||
media_item = media_item_fixture()
|
||||
%{media_item: media_item}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ defmodule Pinchflat.MediaFixtures do
|
|||
Path.join([
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
"#{:rand.uniform(1_000_000)}",
|
||||
"#{:rand.uniform(1_000_000)}_media.mkv"
|
||||
"#{:rand.uniform(1_000_000)}_media.mp4"
|
||||
])
|
||||
|
||||
fixture_media_filepath =
|
||||
|
|
|
|||
Loading…
Reference in a new issue