[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,
|
threshold: :low,
|
||||||
# All of these are ignorable because this app is intended to be single-user and self-hosted.
|
# 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
|
# 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: [],
|
ignore_files: [],
|
||||||
version: false
|
version: false
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,68 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, flash_message)
|
||||||
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
|> redirect(to: ~p"/sources/#{media_item.source_id}")
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@
|
||||||
<.tabbed_layout>
|
<.tabbed_layout>
|
||||||
<:tab title="Attributes">
|
<:tab title="Attributes">
|
||||||
<div class="flex flex-col gap-10 dark:text-white">
|
<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>
|
<h3 class="font-bold text-xl">Attributes</h3>
|
||||||
<section>
|
<section>
|
||||||
<strong>Source:</strong>
|
<strong>Source:</strong>
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,11 @@ defmodule PinchflatWeb.Router do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# Routes in here are NOT protected by basic auth. This is necessary for
|
||||||
# scope "/api", PinchflatWeb do
|
# media streaming to work for RSS podcast feeds.
|
||||||
# pipe_through :api
|
scope "/", PinchflatWeb do
|
||||||
# end
|
get "/media/:id/stream", MediaItems.MediaItemController, :stream
|
||||||
|
end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
if Application.compile_env(:pinchflat, :dev_routes) do
|
if Application.compile_env(:pinchflat, :dev_routes) do
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,147 @@ defmodule PinchflatWeb.MediaItemControllerTest do
|
||||||
end
|
end
|
||||||
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
|
defp create_media_item(_) do
|
||||||
media_item = media_item_fixture()
|
media_item = media_item_fixture()
|
||||||
%{media_item: media_item}
|
%{media_item: media_item}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ defmodule Pinchflat.MediaFixtures do
|
||||||
Path.join([
|
Path.join([
|
||||||
Application.get_env(:pinchflat, :media_directory),
|
Application.get_env(:pinchflat, :media_directory),
|
||||||
"#{:rand.uniform(1_000_000)}",
|
"#{:rand.uniform(1_000_000)}",
|
||||||
"#{:rand.uniform(1_000_000)}_media.mkv"
|
"#{:rand.uniform(1_000_000)}_media.mp4"
|
||||||
])
|
])
|
||||||
|
|
||||||
fixture_media_filepath =
|
fixture_media_filepath =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue