Files
self f8e2bfaada refactor: use chat capability surfaces
Move Sender modules under TribeOne.TribesPlugin.Sender and replace the Aether-specific chat integration with the public chat@1 surface contract.
2026-05-26 01:13:38 +02:00

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