[WIP] set up streaming route

This commit is contained in:
Kieran Eglin 2024-03-23 09:52:40 -07:00
parent 45f056c6e0
commit 4e869ce0c6
No known key found for this signature in database
GPG key ID: 193984967FCF432D
6 changed files with 228 additions and 6 deletions

View file

@ -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
] ]

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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}

View file

@ -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 =