You've already forked tribes-plugin-sender
forked from tribes/tribes-plugin-template
f8e2bfaada
Move Sender modules under TribeOne.TribesPlugin.Sender and replace the Aether-specific chat integration with the public chat@1 surface contract.
124 lines
3.4 KiB
Elixir
124 lines
3.4 KiB
Elixir
defmodule TribeOne.TribesPlugin.Sender.HLS do
|
|
@moduledoc """
|
|
Local HLS file serving helpers.
|
|
"""
|
|
|
|
alias TribeOne.TribesPlugin.Sender.Streaming.StreamGeneration
|
|
|
|
@playlist_mime "application/vnd.apple.mpegurl"
|
|
@mpegts_mime "video/mp2t"
|
|
@fmp4_mime "video/iso.segment"
|
|
@default_hls_time 2
|
|
@default_hls_list_size 6
|
|
@default_hls_delete_threshold 3
|
|
@default_ended_retention_ms 300_000
|
|
|
|
@spec spool_root() :: String.t()
|
|
def spool_root(opts \\ []) do
|
|
Keyword.get_lazy(opts, :spool_root, fn ->
|
|
config(:spool_root) ||
|
|
Path.join(System.tmp_dir!(), "sender-hls")
|
|
end)
|
|
end
|
|
|
|
@spec hls_time(keyword()) :: pos_integer()
|
|
def hls_time(opts \\ []), do: positive_integer(opts, :hls_time, @default_hls_time)
|
|
|
|
@spec hls_list_size(keyword()) :: pos_integer()
|
|
def hls_list_size(opts \\ []),
|
|
do: positive_integer(opts, :hls_list_size, @default_hls_list_size)
|
|
|
|
@spec hls_delete_threshold(keyword()) :: non_neg_integer()
|
|
def hls_delete_threshold(opts \\ []) do
|
|
non_negative_integer(opts, :hls_delete_threshold, @default_hls_delete_threshold)
|
|
end
|
|
|
|
@spec ended_retention_ms(keyword()) :: non_neg_integer()
|
|
def ended_retention_ms(opts \\ []) do
|
|
non_negative_integer(opts, :ended_retention_ms, @default_ended_retention_ms)
|
|
end
|
|
|
|
@spec generation_dir(StreamGeneration.t(), keyword()) :: String.t()
|
|
def generation_dir(%StreamGeneration{} = generation, opts \\ []) do
|
|
Path.join([
|
|
spool_root(opts),
|
|
"streams",
|
|
to_string(generation.stream_id),
|
|
to_string(generation.id)
|
|
])
|
|
end
|
|
|
|
@spec resolve_path([String.t()]) :: {:ok, String.t()} | {:error, :unsafe_path | :not_found}
|
|
def resolve_path(path_segments) when is_list(path_segments) do
|
|
root = Path.expand(spool_root())
|
|
path = Path.expand(Path.join([root | path_segments]))
|
|
|
|
cond do
|
|
not String.starts_with?(path, root <> "/") and path != root ->
|
|
{:error, :unsafe_path}
|
|
|
|
File.regular?(path) ->
|
|
{:ok, path}
|
|
|
|
true ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@spec content_type(String.t()) :: String.t()
|
|
def content_type(path) do
|
|
case Path.extname(path) do
|
|
".m3u8" -> @playlist_mime
|
|
".ts" -> @mpegts_mime
|
|
".m4s" -> @fmp4_mime
|
|
".mp4" -> "video/mp4"
|
|
_other -> "application/octet-stream"
|
|
end
|
|
end
|
|
|
|
@spec cache_control(String.t()) :: String.t()
|
|
def cache_control(path) do
|
|
case Path.extname(path) do
|
|
".m3u8" -> "no-store"
|
|
".ts" -> "public, max-age=31536000, immutable"
|
|
".m4s" -> "public, max-age=30"
|
|
_other -> "no-store"
|
|
end
|
|
end
|
|
|
|
defp positive_integer(opts, key, default) do
|
|
opts
|
|
|> configured_value(key, default)
|
|
|> normalize_integer(default)
|
|
|> max(1)
|
|
end
|
|
|
|
defp non_negative_integer(opts, key, default) do
|
|
opts
|
|
|> configured_value(key, default)
|
|
|> normalize_integer(default)
|
|
|> max(0)
|
|
end
|
|
|
|
defp configured_value(opts, key, default) do
|
|
Keyword.get_lazy(opts, key, fn -> config(key, default) end)
|
|
end
|
|
|
|
defp normalize_integer(value, _default) when is_integer(value), do: value
|
|
|
|
defp normalize_integer(value, default) when is_binary(value) do
|
|
case Integer.parse(value) do
|
|
{integer, ""} -> integer
|
|
_other -> default
|
|
end
|
|
end
|
|
|
|
defp normalize_integer(_value, default), do: default
|
|
|
|
defp config(key, default \\ nil) do
|
|
:sender
|
|
|> Application.get_env(__MODULE__, [])
|
|
|> Keyword.get(key, default)
|
|
end
|
|
end
|