mirror of
https://github.com/algora-io/tv.git
synced 2024-11-26 01:00:20 +02:00
add creator studio (#14)
This commit is contained in:
parent
b27082fd6a
commit
2b73c40bc5
4
.iex.exs
4
.iex.exs
@ -1,9 +1,7 @@
|
||||
import Ecto.Query
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Algora.Accounts
|
||||
alias Algora.Library
|
||||
alias Algora.Repo
|
||||
alias Algora.{Accounts, Library, Repo, Storage}
|
||||
|
||||
IEx.configure(inspect: [charlists: :as_lists])
|
||||
|
||||
|
@ -90,19 +90,13 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
.alert-info {
|
||||
color: #31708f;
|
||||
background-color: #d9edf7;
|
||||
border-color: #bce8f1;
|
||||
@apply text-blue-300 bg-blue-950/50 border-blue-500;
|
||||
}
|
||||
.alert-warning {
|
||||
color: #8a6d3b;
|
||||
background-color: #fcf8e3;
|
||||
border-color: #faebcc;
|
||||
@apply text-yellow-300 bg-yellow-950/50 border-yellow-500;
|
||||
}
|
||||
.alert-danger {
|
||||
color: #a94442;
|
||||
background-color: #f2dede;
|
||||
border-color: #ebccd1;
|
||||
@apply text-red-300 bg-red-950/50 border-red-500;
|
||||
}
|
||||
.alert p {
|
||||
margin-bottom: 0;
|
||||
|
@ -22,6 +22,10 @@ config :algora, AlgoraWeb.Endpoint,
|
||||
layout: false
|
||||
]
|
||||
|
||||
config :algora, Oban,
|
||||
repo: Algora.Repo.Local,
|
||||
queues: [default: 10]
|
||||
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
tv: [
|
||||
|
@ -19,6 +19,8 @@ config :algora, Algora.Repo.Local,
|
||||
pool_size: 10,
|
||||
priv: "priv/repo"
|
||||
|
||||
config :algora, Oban, testing: :inline
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :algora, AlgoraWeb.Endpoint,
|
||||
|
@ -34,6 +34,8 @@ defmodule Algora.Application do
|
||||
Algora.Repo.Local,
|
||||
# Start the supervisor for LSN tracking
|
||||
{Fly.Postgres.LSN.Supervisor, repo: Algora.Repo.Local},
|
||||
# Start the Oban system
|
||||
{Oban, Application.fetch_env!(:algora, Oban)},
|
||||
# Start the Telemetry supervisor
|
||||
AlgoraWeb.Telemetry,
|
||||
# Start the PubSub system
|
||||
|
@ -12,6 +12,10 @@ defmodule Algora.Library do
|
||||
|
||||
@pubsub Algora.PubSub
|
||||
|
||||
def subscribe_to_studio() do
|
||||
Phoenix.PubSub.subscribe(@pubsub, topic_studio())
|
||||
end
|
||||
|
||||
def subscribe_to_livestreams() do
|
||||
Phoenix.PubSub.subscribe(@pubsub, topic_livestreams())
|
||||
end
|
||||
@ -25,14 +29,165 @@ defmodule Algora.Library do
|
||||
title: "",
|
||||
duration: 0,
|
||||
type: :livestream,
|
||||
format: :hls,
|
||||
is_live: true,
|
||||
visibility: :unlisted
|
||||
}
|
||||
|> change()
|
||||
|> Video.put_video_path(:livestream)
|
||||
|> Video.put_video_url(:hls)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def init_mp4!(%Phoenix.LiveView.UploadEntry{} = entry, tmp_path, %User{} = user) do
|
||||
title = Path.basename(entry.client_name, ".mp4")
|
||||
basename = Slug.slugify(title)
|
||||
|
||||
video =
|
||||
%Video{
|
||||
title: title,
|
||||
duration: 0,
|
||||
type: :vod,
|
||||
format: :mp4,
|
||||
is_live: false,
|
||||
visibility: :unlisted,
|
||||
user_id: user.id,
|
||||
local_path: tmp_path,
|
||||
channel_handle: user.handle,
|
||||
channel_name: user.name
|
||||
}
|
||||
|> change()
|
||||
|> Video.put_video_meta(:mp4, basename)
|
||||
|
||||
dir = Path.join(System.tmp_dir!(), video.changes.uuid)
|
||||
File.mkdir_p!(dir)
|
||||
local_path = Path.join(dir, video.changes.filename)
|
||||
File.cp!(tmp_path, local_path)
|
||||
|
||||
video
|
||||
|> put_change(:local_path, local_path)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def transmux_to_mp4(%Video{} = video, cb) do
|
||||
mp4_basename = Slug.slugify("#{Date.to_string(video.inserted_at)}-#{video.title}")
|
||||
|
||||
mp4_video =
|
||||
%Video{
|
||||
title: video.title,
|
||||
duration: video.duration,
|
||||
type: :vod,
|
||||
format: :mp4,
|
||||
is_live: false,
|
||||
visibility: :unlisted,
|
||||
user_id: video.user_id,
|
||||
transmuxed_from_id: video.id,
|
||||
thumbnail_url: video.thumbnail_url
|
||||
}
|
||||
|> change()
|
||||
|> Video.put_video_url(:mp4, mp4_basename)
|
||||
|
||||
%{uuid: mp4_uuid, filename: mp4_filename, remote_path: mp4_remote_path} = mp4_video.changes
|
||||
|
||||
dir = Path.join(System.tmp_dir!(), mp4_uuid)
|
||||
File.mkdir_p!(dir)
|
||||
mp4_local_path = Path.join(dir, mp4_filename)
|
||||
|
||||
cb.(%{stage: :transmuxing, done: 1, total: 1})
|
||||
System.cmd("ffmpeg", ["-i", video.url, "-c", "copy", mp4_local_path])
|
||||
|
||||
Storage.upload_from_filename(mp4_local_path, mp4_remote_path, cb)
|
||||
mp4_video = Repo.insert!(mp4_video)
|
||||
|
||||
File.rm!(mp4_local_path)
|
||||
|
||||
mp4_video
|
||||
end
|
||||
|
||||
def transmux_to_hls(%Video{} = video, cb) do
|
||||
duration =
|
||||
case get_duration(video) do
|
||||
{:ok, duration} -> duration
|
||||
{:error, _} -> 0
|
||||
end
|
||||
|
||||
hls_video =
|
||||
%Video{
|
||||
title: video.title,
|
||||
duration: duration,
|
||||
type: :vod,
|
||||
format: :hls,
|
||||
is_live: false,
|
||||
visibility: video.visibility,
|
||||
user_id: video.user_id
|
||||
}
|
||||
|> change()
|
||||
|> Video.put_video_url(:hls)
|
||||
|
||||
%{uuid: hls_uuid, filename: hls_filename} = hls_video.changes
|
||||
|
||||
dir = Path.join(System.tmp_dir!(), hls_uuid)
|
||||
File.mkdir_p!(dir)
|
||||
hls_local_path = Path.join(dir, hls_filename)
|
||||
|
||||
cb.(%{stage: :transmuxing, done: 1, total: 1})
|
||||
|
||||
System.cmd("ffmpeg", [
|
||||
"-i",
|
||||
video.local_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-start_number",
|
||||
"0",
|
||||
"-hls_time",
|
||||
"2",
|
||||
"-hls_list_size",
|
||||
"0",
|
||||
"-f",
|
||||
"hls",
|
||||
hls_local_path
|
||||
])
|
||||
|
||||
files = Path.wildcard("#{dir}/*")
|
||||
|
||||
files
|
||||
|> Stream.map(fn hls_local_path ->
|
||||
cb.(%{stage: :persisting, done: 1, total: length(files)})
|
||||
hls_local_path
|
||||
end)
|
||||
|> Enum.each(fn hls_local_path ->
|
||||
Storage.upload_from_filename(
|
||||
hls_local_path,
|
||||
"#{hls_uuid}/#{Path.basename(hls_local_path)}"
|
||||
)
|
||||
end)
|
||||
|
||||
hls_video = Repo.insert!(hls_video)
|
||||
|
||||
cb.(%{stage: :generating_thumbnail, done: 1, total: 1})
|
||||
{:ok, hls_video} = store_thumbnail_from_file(hls_video, video.local_path)
|
||||
|
||||
# TODO: should probably keep the file around for a while for any additional processing
|
||||
# requests from user?
|
||||
File.rm!(video.local_path)
|
||||
|
||||
Repo.delete!(video)
|
||||
|
||||
hls_video
|
||||
|> change()
|
||||
|> put_change(:id, video.id)
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
def get_mp4_video(id) do
|
||||
from(v in Video,
|
||||
where: v.format == :mp4 and (v.transmuxed_from_id == ^id or v.id == ^id),
|
||||
join: u in User,
|
||||
on: v.user_id == u.id,
|
||||
select_merge: %{channel_handle: u.handle, channel_name: u.name}
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def toggle_streamer_live(%Video{} = video, is_live) do
|
||||
video = get_video!(video.id)
|
||||
user = Accounts.get_user!(video.user_id)
|
||||
@ -97,10 +252,18 @@ defmodule Algora.Library do
|
||||
end
|
||||
end
|
||||
|
||||
defp get_playlist(%Video{} = video) do
|
||||
url = "#{video.url_root}/index.m3u8"
|
||||
def toggle_visibility!(%Video{} = video) do
|
||||
new_visibility =
|
||||
case video.visibility do
|
||||
:public -> :unlisted
|
||||
_ -> :public
|
||||
end
|
||||
|
||||
with {:ok, resp} <- Finch.build(:get, url) |> Finch.request(Algora.Finch) do
|
||||
video |> change() |> put_change(:visibility, new_visibility) |> Repo.update!()
|
||||
end
|
||||
|
||||
defp get_playlist(%Video{} = video) do
|
||||
with {:ok, resp} <- Finch.build(:get, video.url) |> Finch.request(Algora.Finch) do
|
||||
ExM3U8.deserialize_playlist(resp.body, [])
|
||||
end
|
||||
end
|
||||
@ -120,7 +283,7 @@ defmodule Algora.Library do
|
||||
end
|
||||
end
|
||||
|
||||
def get_duration(%Video{type: :livestream} = video) do
|
||||
def get_duration(%Video{format: :hls} = video) do
|
||||
with {:ok, playlist} <- get_media_playlist(video) do
|
||||
duration =
|
||||
playlist.timeline
|
||||
@ -131,8 +294,14 @@ defmodule Algora.Library do
|
||||
end
|
||||
end
|
||||
|
||||
def get_duration(%Video{type: :vod}) do
|
||||
{:error, :not_implemented}
|
||||
def get_duration(%Video{local_path: nil}), do: {:error, :not_implemented}
|
||||
|
||||
def get_duration(%Video{local_path: local_path}) do
|
||||
case FFprobe.duration(local_path) do
|
||||
:no_duration -> {:error, :no_duration}
|
||||
{:error, error} -> {:error, error}
|
||||
duration -> {:ok, round(duration)}
|
||||
end
|
||||
end
|
||||
|
||||
def to_hhmmss(duration) when is_integer(duration) do
|
||||
@ -152,26 +321,44 @@ defmodule Algora.Library do
|
||||
Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id))
|
||||
end
|
||||
|
||||
defp create_thumbnail(%Video{} = video, contents) do
|
||||
input_path = Path.join(System.tmp_dir(), "#{video.uuid}.mp4")
|
||||
output_path = Path.join(System.tmp_dir(), "#{video.uuid}.jpeg")
|
||||
defp create_thumbnail_from_file(%Video{} = video, src_path) do
|
||||
dst_path = Path.join(System.tmp_dir!(), "#{video.uuid}.jpeg")
|
||||
|
||||
with :ok <- File.write(input_path, contents),
|
||||
:ok <- Thumbnex.create_thumbnail(input_path, output_path) do
|
||||
File.read(output_path)
|
||||
with :ok <- Thumbnex.create_thumbnail(src_path, dst_path) do
|
||||
File.read(dst_path)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_thumbnail(%Video{} = video, contents) do
|
||||
src_path = Path.join(System.tmp_dir!(), "#{video.uuid}.mp4")
|
||||
|
||||
with :ok <- File.write(src_path, contents) do
|
||||
create_thumbnail_from_file(video, src_path)
|
||||
end
|
||||
end
|
||||
|
||||
def store_thumbnail_from_file(%Video{} = video, src_path) do
|
||||
with {:ok, thumbnail} <- create_thumbnail_from_file(video, src_path),
|
||||
{:ok, _} <- Storage.upload(thumbnail, "#{video.uuid}/index.jpeg") do
|
||||
video
|
||||
|> change()
|
||||
|> put_change(:thumbnail_url, "#{video.url_root}/index.jpeg")
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
def store_thumbnail(%Video{} = video, contents) do
|
||||
with {:ok, thumbnail} <- create_thumbnail(video, contents),
|
||||
{:ok, _} <- Storage.upload_file("#{video.uuid}/index.jpeg", thumbnail) do
|
||||
:ok
|
||||
{:ok, _} <- Storage.upload(thumbnail, "#{video.uuid}/index.jpeg") do
|
||||
video
|
||||
|> change()
|
||||
|> put_change(:thumbnail_url, "#{video.url_root}/index.jpeg")
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
def reconcile_livestream(%Video{} = video, stream_key) do
|
||||
user =
|
||||
Accounts.get_user_by!(stream_key: stream_key)
|
||||
user = Accounts.get_user_by!(stream_key: stream_key)
|
||||
|
||||
result =
|
||||
Repo.update_all(from(v in Video, where: v.id == ^video.id),
|
||||
@ -179,11 +366,8 @@ defmodule Algora.Library do
|
||||
)
|
||||
|
||||
case result do
|
||||
{1, _} ->
|
||||
{:ok, video}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid}
|
||||
{1, _} -> {:ok, video}
|
||||
_ -> {:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
@ -193,7 +377,9 @@ defmodule Algora.Library do
|
||||
on: v.user_id == u.id,
|
||||
limit: ^limit,
|
||||
where:
|
||||
v.visibility == :public and
|
||||
not is_nil(v.url) and
|
||||
is_nil(v.transmuxed_from_id) and
|
||||
v.visibility == :public and
|
||||
is_nil(v.vertical_thumbnail_url) and
|
||||
(v.is_live == true or v.duration >= 120 or v.type == :vod),
|
||||
select_merge: %{channel_handle: u.handle, channel_name: u.name}
|
||||
@ -207,7 +393,10 @@ defmodule Algora.Library do
|
||||
join: u in User,
|
||||
on: v.user_id == u.id,
|
||||
limit: ^limit,
|
||||
where: v.visibility == :public and not is_nil(v.vertical_thumbnail_url),
|
||||
where:
|
||||
not is_nil(v.url) and
|
||||
is_nil(v.transmuxed_from_id) and v.visibility == :public and
|
||||
not is_nil(v.vertical_thumbnail_url),
|
||||
select_merge: %{channel_handle: u.handle, channel_name: u.name}
|
||||
)
|
||||
|> order_by_inserted(:desc)
|
||||
@ -220,7 +409,29 @@ defmodule Algora.Library do
|
||||
join: u in User,
|
||||
on: v.user_id == u.id,
|
||||
select_merge: %{channel_handle: u.handle, channel_name: u.name},
|
||||
where: v.user_id == ^channel.user_id
|
||||
where:
|
||||
not is_nil(v.url) and
|
||||
is_nil(v.transmuxed_from_id) and
|
||||
v.user_id == ^channel.user_id
|
||||
)
|
||||
|> order_by_inserted(:desc)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_studio_videos(%Channel{} = channel, limit \\ 100) do
|
||||
from(v in Video,
|
||||
limit: ^limit,
|
||||
join: u in assoc(v, :user),
|
||||
left_join: m in assoc(v, :messages),
|
||||
group_by: [v.id, u.handle, u.name],
|
||||
select_merge: %{
|
||||
channel_handle: u.handle,
|
||||
channel_name: u.name,
|
||||
messages_count: count(m.id)
|
||||
},
|
||||
where:
|
||||
is_nil(v.transmuxed_from_id) and
|
||||
v.user_id == ^channel.user_id
|
||||
)
|
||||
|> order_by_inserted(:desc)
|
||||
|> Repo.all()
|
||||
@ -256,34 +467,19 @@ defmodule Algora.Library do
|
||||
user.id == channel.user_id
|
||||
end
|
||||
|
||||
defp youtube_id(%Video{url: url}) do
|
||||
url = URI.parse(url)
|
||||
root = ".#{url.host}"
|
||||
def player_type(%Video{format: :mp4}), do: "video/mp4"
|
||||
def player_type(%Video{format: :hls}), do: "application/x-mpegURL"
|
||||
def player_type(%Video{format: :youtube}), do: "video/youtube"
|
||||
|
||||
cond do
|
||||
root |> String.ends_with?(".youtube.com") ->
|
||||
%{"v" => id} = URI.decode_query(url.query)
|
||||
id
|
||||
|
||||
root |> String.ends_with?(".youtu.be") ->
|
||||
"/" <> id = url.path
|
||||
id
|
||||
|
||||
true ->
|
||||
:not_found
|
||||
end
|
||||
end
|
||||
|
||||
def player_type(%Video{type: :livestream}), do: "application/x-mpegURL"
|
||||
|
||||
def player_type(%Video{} = video) do
|
||||
case youtube_id(video) do
|
||||
:not_found -> "video/mp4"
|
||||
_ -> "video/youtube"
|
||||
end
|
||||
end
|
||||
|
||||
def get_video!(id), do: Repo.get!(Video, id)
|
||||
def get_video!(id),
|
||||
do:
|
||||
from(v in Video,
|
||||
where: v.id == ^id,
|
||||
join: u in User,
|
||||
on: v.user_id == u.id,
|
||||
select_merge: %{channel_handle: u.handle, channel_name: u.name}
|
||||
)
|
||||
|> Repo.one!()
|
||||
|
||||
def update_video(%Video{} = video, attrs) do
|
||||
video
|
||||
@ -291,6 +487,10 @@ defmodule Algora.Library do
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_video(%Video{} = video) do
|
||||
Repo.delete(video)
|
||||
end
|
||||
|
||||
defp order_by_inserted(%Ecto.Query{} = query, direction) when direction in [:asc, :desc] do
|
||||
from(s in query, order_by: [{^direction, s.inserted_at}])
|
||||
end
|
||||
@ -299,6 +499,8 @@ defmodule Algora.Library do
|
||||
|
||||
def topic_livestreams(), do: "livestreams"
|
||||
|
||||
def topic_studio(), do: "studio"
|
||||
|
||||
def list_subtitles(%Video{} = video) do
|
||||
from(s in Subtitle, where: s.video_id == ^video.id, order_by: [asc: s.start])
|
||||
|> Repo.all()
|
||||
@ -338,4 +540,24 @@ defmodule Algora.Library do
|
||||
|> Enum.map(&save_subtitle/1)
|
||||
|> length
|
||||
end
|
||||
|
||||
defp broadcast!(topic, msg) do
|
||||
Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg})
|
||||
end
|
||||
|
||||
def broadcast_processing_progressed!(stage, video, pct) do
|
||||
broadcast!(topic_studio(), %Events.ProcessingProgressed{video: video, stage: stage, pct: pct})
|
||||
end
|
||||
|
||||
def broadcast_processing_completed!(action, video, url) do
|
||||
broadcast!(topic_studio(), %Events.ProcessingCompleted{action: action, video: video, url: url})
|
||||
end
|
||||
|
||||
def broadcast_processing_failed!(video, attempt, max_attempts) do
|
||||
broadcast!(topic_studio(), %Events.ProcessingFailed{
|
||||
video: video,
|
||||
attempt: attempt,
|
||||
max_attempts: max_attempts
|
||||
})
|
||||
end
|
||||
end
|
||||
|
@ -10,4 +10,20 @@ defmodule Algora.Library.Events do
|
||||
defmodule ThumbnailsGenerated do
|
||||
defstruct video: nil
|
||||
end
|
||||
|
||||
defmodule ProcessingQueued do
|
||||
defstruct video: nil
|
||||
end
|
||||
|
||||
defmodule ProcessingProgressed do
|
||||
defstruct video: nil, stage: nil, pct: nil
|
||||
end
|
||||
|
||||
defmodule ProcessingCompleted do
|
||||
defstruct video: nil, action: nil, url: nil
|
||||
end
|
||||
|
||||
defmodule ProcessingFailed do
|
||||
defstruct video: nil, attempt: nil, max_attempts: nil
|
||||
end
|
||||
end
|
||||
|
@ -3,24 +3,36 @@ defmodule Algora.Library.Video do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Algora.Accounts
|
||||
alias Algora.Accounts.User
|
||||
alias Algora.Library.Video
|
||||
alias Algora.Chat.Message
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
schema "videos" do
|
||||
field :duration, :integer
|
||||
field :title, :string
|
||||
field :description, :string
|
||||
field :type, Ecto.Enum, values: [vod: 1, livestream: 2]
|
||||
field :format, Ecto.Enum, values: [mp4: 1, hls: 2, youtube: 3]
|
||||
field :is_live, :boolean, default: false
|
||||
field :thumbnail_url, :string
|
||||
field :vertical_thumbnail_url, :string
|
||||
field :url, :string
|
||||
field :url_root, :string
|
||||
field :uuid, :string
|
||||
field :filename, :string
|
||||
field :channel_handle, :string, virtual: true
|
||||
field :channel_name, :string, virtual: true
|
||||
field :messages_count, :integer, virtual: true, default: 0
|
||||
field :visibility, Ecto.Enum, values: [public: 1, unlisted: 2]
|
||||
belongs_to :user, Accounts.User
|
||||
field :remote_path, :string
|
||||
field :local_path, :string
|
||||
|
||||
belongs_to :user, User
|
||||
belongs_to :transmuxed_from, Video
|
||||
|
||||
has_many :messages, Message
|
||||
|
||||
timestamps()
|
||||
end
|
||||
@ -32,27 +44,41 @@ defmodule Algora.Library.Video do
|
||||
|> validate_required([:title])
|
||||
end
|
||||
|
||||
def put_user(%Ecto.Changeset{} = changeset, %Accounts.User{} = user) do
|
||||
def put_user(%Ecto.Changeset{} = changeset, %User{} = user) do
|
||||
put_assoc(changeset, :user, user)
|
||||
end
|
||||
|
||||
def put_video_path(%Ecto.Changeset{} = changeset, type)
|
||||
when type in [:vod, :livestream] do
|
||||
def put_video_meta(%Ecto.Changeset{} = changeset, format, basename \\ "index")
|
||||
when format in [:mp4, :hls] do
|
||||
if changeset.valid? do
|
||||
uuid = Ecto.UUID.generate()
|
||||
filename = "index#{fileext(type)}"
|
||||
filename = "#{basename}#{fileext(format)}"
|
||||
|
||||
changeset
|
||||
|> put_change(:filename, filename)
|
||||
|> put_change(:uuid, uuid)
|
||||
|> put_change(:url, url(uuid, filename))
|
||||
|> put_change(:url_root, url_root(uuid))
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp fileext(:vod), do: ".mp4"
|
||||
defp fileext(:livestream), do: ".m3u8"
|
||||
def put_video_url(%Ecto.Changeset{} = changeset, format, basename \\ "index")
|
||||
when format in [:mp4, :hls] do
|
||||
if changeset.valid? do
|
||||
changeset = changeset |> put_video_meta(format, basename)
|
||||
%{uuid: uuid, filename: filename} = changeset.changes
|
||||
|
||||
changeset
|
||||
|> put_change(:url, url(uuid, filename))
|
||||
|> put_change(:url_root, url_root(uuid))
|
||||
|> put_change(:remote_path, "#{uuid}/#{filename}")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp fileext(:mp4), do: ".mp4"
|
||||
defp fileext(:hls), do: ".m3u8"
|
||||
|
||||
defp url_root(uuid) do
|
||||
bucket = Algora.config([:files, :bucket])
|
||||
|
@ -1,9 +1,10 @@
|
||||
defmodule Algora.Storage do
|
||||
@behaviour Membrane.HTTPAdaptiveStream.Storage
|
||||
|
||||
import Ecto.Changeset
|
||||
require Membrane.Logger
|
||||
alias Algora.{Repo, Library}
|
||||
alias Algora.Library
|
||||
|
||||
@pubsub Algora.PubSub
|
||||
|
||||
@enforce_keys [:video]
|
||||
defstruct @enforce_keys ++ [video_header: <<>>]
|
||||
@ -27,7 +28,7 @@ defmodule Algora.Storage do
|
||||
) do
|
||||
path = "#{video.uuid}/#{name}"
|
||||
|
||||
with {:ok, _} <- upload_file(path, contents, upload_opts(ctx)),
|
||||
with {:ok, _} <- upload(contents, path, upload_opts(ctx)),
|
||||
{:ok, state} <- process_contents(parent_id, name, contents, metadata, ctx, state) do
|
||||
{:ok, state}
|
||||
else
|
||||
@ -67,13 +68,8 @@ defmodule Algora.Storage do
|
||||
%{type: :segment, mode: :binary},
|
||||
%{video: %{thumbnail_url: nil} = video, video_header: video_header} = state
|
||||
) do
|
||||
with :ok <- Library.store_thumbnail(video, video_header <> contents),
|
||||
{:ok, video} =
|
||||
video
|
||||
|> change()
|
||||
|> put_change(:thumbnail_url, "#{video.url_root}/index.jpeg")
|
||||
|> Repo.update(),
|
||||
:ok <- broadcast_thumbnails_generated(video) do
|
||||
with {:ok, video} <- Library.store_thumbnail(video, video_header <> contents) do
|
||||
broadcast_thumbnails_generated!(video)
|
||||
{:ok, %{state | video: video}}
|
||||
end
|
||||
end
|
||||
@ -82,17 +78,31 @@ defmodule Algora.Storage do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
def upload_file(path, contents, opts \\ []) do
|
||||
def upload(contents, remote_path, opts \\ []) do
|
||||
Algora.config([:files, :bucket])
|
||||
|> ExAws.S3.put_object(path, contents, opts)
|
||||
|> ExAws.S3.put_object(remote_path, contents, opts)
|
||||
|> ExAws.request([])
|
||||
end
|
||||
|
||||
defp broadcast_thumbnails_generated(video) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Algora.PubSub,
|
||||
Library.topic_livestreams(),
|
||||
{__MODULE__, %Library.Events.ThumbnailsGenerated{video: video}}
|
||||
)
|
||||
def upload_from_filename(local_path, remote_path, cb \\ fn _ -> nil end, opts \\ []) do
|
||||
%{size: size} = File.stat!(local_path)
|
||||
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
|
||||
ExAws.S3.Upload.stream_file(local_path, [{:chunk_size, chunk_size}])
|
||||
|> Stream.map(fn chunk ->
|
||||
cb.(%{stage: :persisting, done: chunk_size, total: size})
|
||||
chunk
|
||||
end)
|
||||
|> ExAws.S3.upload(Algora.config([:files, :bucket]), remote_path, opts)
|
||||
|> ExAws.request([])
|
||||
end
|
||||
|
||||
defp broadcast!(topic, msg) do
|
||||
Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg})
|
||||
end
|
||||
|
||||
defp broadcast_thumbnails_generated!(video) do
|
||||
broadcast!(Library.topic_livestreams(), %Library.Events.ThumbnailsGenerated{video: video})
|
||||
end
|
||||
end
|
||||
|
52
lib/algora/workers/hls_transmuxer.ex
Normal file
52
lib/algora/workers/hls_transmuxer.ex
Normal file
@ -0,0 +1,52 @@
|
||||
defmodule Algora.Workers.HLSTransmuxer do
|
||||
use Oban.Worker, queue: :default, max_attempts: 3, unique: [period: 86_400]
|
||||
|
||||
alias Algora.Library
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"video_id" => video_id}} = job) do
|
||||
video = Library.get_video!(video_id)
|
||||
build_transmuxer(job, video)
|
||||
await_transmuxer(video)
|
||||
end
|
||||
|
||||
defp build_transmuxer(job, %Library.Video{} = video) do
|
||||
job_pid = self()
|
||||
|
||||
Task.async(fn ->
|
||||
try do
|
||||
hls_video =
|
||||
Library.transmux_to_hls(video, fn progress ->
|
||||
send(job_pid, {:progress, progress})
|
||||
end)
|
||||
|
||||
send(job_pid, {:complete, hls_video})
|
||||
rescue
|
||||
e ->
|
||||
send(job_pid, {:error, e, job})
|
||||
reraise e, __STACKTRACE__
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp await_transmuxer(video, stage \\ :retrieving, done \\ 0) do
|
||||
receive do
|
||||
{:progress, %{stage: stage_now, done: done_now, total: total}} ->
|
||||
Library.broadcast_processing_progressed!(stage, video, min(1, done / total))
|
||||
done_total = if(stage == stage_now, do: done, else: 0)
|
||||
await_transmuxer(video, stage_now, done_total + done_now)
|
||||
|
||||
{:complete, video} ->
|
||||
Library.broadcast_processing_progressed!(stage, video, 1)
|
||||
Library.broadcast_processing_completed!(:upload, video, video.url)
|
||||
{:ok, video.url}
|
||||
|
||||
{:error, e, %Oban.Job{attempt: attempt, max_attempts: max_attempts}} ->
|
||||
Library.broadcast_processing_failed!(video, attempt, max_attempts)
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
end
|
52
lib/algora/workers/mp4_transmuxer.ex
Normal file
52
lib/algora/workers/mp4_transmuxer.ex
Normal file
@ -0,0 +1,52 @@
|
||||
defmodule Algora.Workers.MP4Transmuxer do
|
||||
use Oban.Worker, queue: :default, max_attempts: 3, unique: [period: 86_400]
|
||||
|
||||
alias Algora.Library
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"video_id" => video_id}} = job) do
|
||||
video = Library.get_video!(video_id)
|
||||
build_transmuxer(job, video)
|
||||
await_transmuxer(video)
|
||||
end
|
||||
|
||||
defp build_transmuxer(job, %Library.Video{} = video) do
|
||||
job_pid = self()
|
||||
|
||||
Task.async(fn ->
|
||||
try do
|
||||
mp4_video =
|
||||
Library.transmux_to_mp4(video, fn progress ->
|
||||
send(job_pid, {:progress, progress})
|
||||
end)
|
||||
|
||||
send(job_pid, {:complete, mp4_video})
|
||||
rescue
|
||||
e ->
|
||||
send(job_pid, {:error, e, job})
|
||||
reraise e, __STACKTRACE__
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp await_transmuxer(video, stage \\ :retrieving, done \\ 0) do
|
||||
receive do
|
||||
{:progress, %{stage: stage_now, done: done_now, total: total}} ->
|
||||
Library.broadcast_processing_progressed!(stage, video, min(1, done / total))
|
||||
done_total = if(stage == stage_now, do: done, else: 0)
|
||||
await_transmuxer(video, stage_now, done_total + done_now)
|
||||
|
||||
{:complete, %Library.Video{url: url}} ->
|
||||
Library.broadcast_processing_progressed!(stage, video, 1)
|
||||
Library.broadcast_processing_completed!(:download, video, url)
|
||||
{:ok, url}
|
||||
|
||||
{:error, e, %Oban.Job{attempt: attempt, max_attempts: max_attempts}} ->
|
||||
Library.broadcast_processing_failed!(video, attempt, max_attempts)
|
||||
{:error, e}
|
||||
end
|
||||
end
|
||||
end
|
@ -70,31 +70,71 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :video, :any, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def short_thumbnail(assigns) do
|
||||
~H"""
|
||||
<div class={[
|
||||
"relative flex items-center justify-center overflow-hidden aspect-[9/16] bg-gray-800",
|
||||
@class
|
||||
]}>
|
||||
<Heroicons.play :if={!@video.thumbnail_url} solid class="h-12 w-12 text-gray-500" />
|
||||
<img
|
||||
:if={@video.vertical_thumbnail_url}
|
||||
src={@video.vertical_thumbnail_url}
|
||||
alt={@video.title}
|
||||
class="absolute w-full h-full object-cover transition-transform duration-200 hover:scale-105 z-10"
|
||||
/>
|
||||
<div
|
||||
:if={@video.duration != 0}
|
||||
class="absolute font-medium text-xs px-2 py-0.5 rounded-xl bottom-1 bg-gray-950/90 text-white right-1 z-20"
|
||||
>
|
||||
<%= Library.to_hhmmss(@video.duration) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :video, :any, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def video_thumbnail(assigns) do
|
||||
~H"""
|
||||
<div class={[
|
||||
"relative flex items-center justify-center overflow-hidden aspect-[16/9] bg-gray-800",
|
||||
@class
|
||||
]}>
|
||||
<Heroicons.play :if={!@video.thumbnail_url} solid class="h-12 w-12 text-gray-500" />
|
||||
<img
|
||||
:if={@video.thumbnail_url}
|
||||
src={@video.thumbnail_url}
|
||||
alt={@video.title}
|
||||
class="absolute w-full h-full object-cover transition-transform duration-200 hover:scale-105 z-10"
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@video.is_live}
|
||||
class="absolute font-medium text-xs px-2 py-0.5 rounded-xl bottom-1 bg-gray-950/90 text-white right-1 z-20"
|
||||
>
|
||||
🔴 LIVE
|
||||
</div>
|
||||
<div
|
||||
:if={not @video.is_live and @video.duration != 0}
|
||||
class="absolute font-medium text-xs px-2 py-0.5 rounded-xl bottom-1 bg-gray-950/90 text-white right-1 z-20"
|
||||
>
|
||||
<%= Library.to_hhmmss(@video.duration) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :video, :any, required: true
|
||||
|
||||
def short_entry(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
id={@id}
|
||||
class="cursor-pointer truncate"
|
||||
navigate={~p"/#{@video.channel_handle}/#{@video.id}"}
|
||||
>
|
||||
<div class="relative flex items-center justify-center overflow-hidden rounded-2xl aspect-[9/16] bg-gray-800">
|
||||
<Heroicons.play :if={!@video.thumbnail_url} solid class="h-12 w-12 text-gray-500" />
|
||||
<img
|
||||
:if={@video.vertical_thumbnail_url}
|
||||
src={@video.vertical_thumbnail_url}
|
||||
alt={@video.title}
|
||||
class="absolute w-full h-full object-cover transition-transform duration-200 hover:scale-105 z-10"
|
||||
/>
|
||||
<div
|
||||
:if={@video.duration != 0}
|
||||
class="absolute font-medium text-xs px-2 py-0.5 rounded-xl bottom-1 bg-gray-950/90 text-white right-1 z-20"
|
||||
>
|
||||
<%= Library.to_hhmmss(@video.duration) %>
|
||||
</div>
|
||||
</div>
|
||||
<.link class="cursor-pointer truncate" navigate={~p"/#{@video.channel_handle}/#{@video.id}"}>
|
||||
<.short_thumbnail video={@video} class="rounded-2xl" />
|
||||
<div class="pt-2 text-base font-semibold truncate"><%= @video.title %></div>
|
||||
<div class="text-gray-300 text-sm font-medium"><%= @video.channel_name %></div>
|
||||
<div class="text-gray-300 text-sm"><%= Timex.from_now(@video.inserted_at) %></div>
|
||||
@ -102,38 +142,12 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :video, :any, required: true
|
||||
|
||||
def video_entry(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
id={@id}
|
||||
class="cursor-pointer truncate"
|
||||
navigate={~p"/#{@video.channel_handle}/#{@video.id}"}
|
||||
>
|
||||
<div class="relative flex items-center justify-center overflow-hidden rounded-2xl aspect-[16/9] bg-gray-800">
|
||||
<Heroicons.play :if={!@video.thumbnail_url} solid class="h-12 w-12 text-gray-500" />
|
||||
<img
|
||||
:if={@video.thumbnail_url}
|
||||
src={@video.thumbnail_url}
|
||||
alt={@video.title}
|
||||
class="absolute w-full h-full object-cover transition-transform duration-200 hover:scale-105 z-10"
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@video.is_live}
|
||||
class="absolute font-medium text-xs px-2 py-0.5 rounded-xl bottom-1 bg-gray-950/90 text-white right-1 z-20"
|
||||
>
|
||||
🔴 LIVE
|
||||
</div>
|
||||
<div
|
||||
:if={not @video.is_live and @video.duration != 0}
|
||||
class="absolute font-medium text-xs px-2 py-0.5 rounded-xl bottom-1 bg-gray-950/90 text-white right-1 z-20"
|
||||
>
|
||||
<%= Library.to_hhmmss(@video.duration) %>
|
||||
</div>
|
||||
</div>
|
||||
<.link class="cursor-pointer truncate" navigate={~p"/#{@video.channel_handle}/#{@video.id}"}>
|
||||
<.video_thumbnail video={@video} class="rounded-2xl" />
|
||||
<div class="pt-2 text-base font-semibold truncate"><%= @video.title %></div>
|
||||
<div class="text-gray-300 text-sm font-medium"><%= @video.channel_name %></div>
|
||||
<div class="text-gray-300 text-sm"><%= Timex.from_now(@video.inserted_at) %></div>
|
||||
@ -156,7 +170,7 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
class="mt-3 gap-8 grid sm:grid-cols-2 lg:grid-cols-3"
|
||||
phx-update="stream"
|
||||
>
|
||||
<.video_entry :for={{id, video} <- @videos} id={id} video={video} />
|
||||
<.video_entry :for={{_id, video} <- @videos} video={video} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -474,7 +488,7 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
phx-window-keydown={hide_modal(@on_cancel, @id)}
|
||||
phx-key="escape"
|
||||
phx-click-away={hide_modal(@on_cancel, @id)}
|
||||
class="hidden relative rounded-2xl bg-gray-900 p-14 shadow-lg shadow-gray-200/10 ring-1 ring-gray-200/10 transition"
|
||||
class="hidden relative rounded-2xl bg-gray-900 py-6 px-10 shadow-lg shadow-gray-200/10 ring-1 ring-gray-200/10 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
@ -658,7 +672,7 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"phx-submit-loading:opacity-75 rounded-lg bg-gray-50 hover:bg-gray-200 py-2 px-3",
|
||||
"phx-submit-loading:opacity-75 disabled:opacity-75 rounded-lg bg-gray-50 hover:bg-gray-200 py-2 px-3",
|
||||
"text-sm font-semibold leading-6 text-gray-950 active:text-gray-950/80",
|
||||
@class
|
||||
]}
|
||||
@ -876,6 +890,7 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
attr :align, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
@ -893,7 +908,11 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
<tr>
|
||||
<th
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
class={["p-0 pb-4 pr-6 font-normal", i == 0 && "pl-4 sm:pl-6 lg:pl-8"]}
|
||||
class={[
|
||||
"p-0 pb-4 pr-4 font-medium text-sm text-gray-300",
|
||||
i == 0 && "pl-4",
|
||||
col[:align] == "right" && "text-right"
|
||||
]}
|
||||
>
|
||||
<%= col[:label] %>
|
||||
</th>
|
||||
@ -915,7 +934,7 @@ defmodule AlgoraWeb.CoreComponents do
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div class={["block py-4 pr-6", i == 0 && "pl-4 sm:pl-6 lg:pl-8"]}>
|
||||
<div class={["block py-4 pr-4", i == 0 && "pl-4"]}>
|
||||
<span class={["relative", i == 0 && "font-semibold text-gray-50"]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</span>
|
||||
|
@ -89,7 +89,30 @@ defmodule AlgoraWeb.Layouts do
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 13a3 3 0 1 0 0 -6a3 3 0 0 0 0 6z" /><path d="M12 3c7.2 0 9 1.8 9 9s-1.8 9 -9 9s-9 -1.8 -9 -9s1.8 -9 9 -9z" /><path d="M6 20.05v-.05a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v.05" />
|
||||
</svg>
|
||||
Your channel
|
||||
Channel
|
||||
</.link>
|
||||
<.link
|
||||
navigate="/channel/studio"
|
||||
class={
|
||||
"text-gray-200 hover:text-gray-50 group flex items-center px-2 py-2 text-sm font-medium rounded-md #{if @active_tab == :studio, do: "bg-gray-800", else: "hover:bg-gray-900"}"
|
||||
}
|
||||
aria-current={if @active_tab == :studio, do: "true", else: "false"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-gray-400 group-hover:text-gray-300 mr-3 flex-shrink-0 h-6 w-6"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" />
|
||||
</svg>
|
||||
Studio
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/channel/settings"}
|
||||
|
@ -50,13 +50,22 @@
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="fixed inset-x-0 -top-[25%] -left-[10%] z-0 flex transform justify-center overflow-hidden blur-3xl"
|
||||
class="fixed inset-x-0 -top-[69%] z-0 flex transform justify-center overflow-hidden blur-3xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
id="video-backdrop"
|
||||
class="w-screen h-[150vh] flex-none bg-gradient-to-r from-[#a78bfa] to-[#4f46e5] opacity-10 transition-opacity"
|
||||
style="clip-path: polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)"
|
||||
class="w-screen h-[150vh] flex-none bg-gradient-to-b from-gray-900 to-gray-950 opacity-75 transition-opacity"
|
||||
style="clip-path: polygon(70.71% 100%, 100% 70.71%, 100% 0%, 70.71% 0%, 29.29% 0%, 0% 0%, 0% 70.71%, 29.29% 100%)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixed inset-x-0 -top-[5%] z-0 flex transform justify-center overflow-hidden blur-3xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="w-screen h-[100vh] flex-none bg-gradient-to-r from-purple-900 to-violet-950 opacity-10 transition-opacity"
|
||||
style="clip-path: polygon(5.5% 13%, 52.75% 0%, 100% 13%, 100% 100%, 0% 100%)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,7 +4,7 @@ defmodule AlgoraWeb.ChannelLive do
|
||||
|
||||
alias Algora.{Accounts, Library, Storage}
|
||||
alias AlgoraWeb.{LayoutComponent, Presence}
|
||||
alias AlgoraWeb.ChannelLive.{StreamFormComponent}
|
||||
alias AlgoraWeb.ChannelLive.StreamFormComponent
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
@ -13,7 +13,7 @@ defmodule AlgoraWeb.HomeLive do
|
||||
Videos
|
||||
</h2>
|
||||
<div class="pt-8 gap-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<.video_entry :for={video <- videos} id={"video-#{video.id}"} video={video} />
|
||||
<.video_entry :for={video <- videos} video={video} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,7 @@ defmodule AlgoraWeb.HomeLive do
|
||||
><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" /></svg>Shorts
|
||||
</h2>
|
||||
<div class="pt-4 gap-8 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<.short_entry :for={video <- shorts} id={"short-#{video.id}"} video={video} />
|
||||
<.short_entry :for={video <- shorts} video={video} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -43,7 +43,7 @@ defmodule AlgoraWeb.HomeLive do
|
||||
Videos
|
||||
</h2>
|
||||
<div class="pt-8 gap-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<.video_entry :for={video <- @leftover_videos} id={"video-#{video.id}"} video={video} />
|
||||
<.video_entry :for={video <- @leftover_videos} video={video} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -63,7 +63,7 @@ defmodule AlgoraWeb.HomeLive do
|
||||
><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" /></svg>Shorts
|
||||
</h2>
|
||||
<div class="pt-4 gap-8 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<.short_entry :for={video <- @leftover_shorts} id={"short-#{video.id}"} video={video} />
|
||||
<.short_entry :for={video <- @leftover_shorts} video={video} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ defmodule AlgoraWeb.SettingsLive do
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-3xl mx-auto bg-gray-800/50 rounded-lg p-4 sm:p-6 lg:p-8">
|
||||
<div class="max-w-3xl mx-auto bg-gray-800/50 rounded-lg p-4">
|
||||
<.header class="pb-6">
|
||||
Settings
|
||||
<:subtitle>
|
||||
|
344
lib/algora_web/live/studio_live.ex
Normal file
344
lib/algora_web/live/studio_live.ex
Normal file
@ -0,0 +1,344 @@
|
||||
defmodule AlgoraWeb.StudioLive do
|
||||
use AlgoraWeb, :live_view
|
||||
|
||||
alias Algora.{Library, Workers}
|
||||
|
||||
@max_entries 5
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header class="p-4">
|
||||
<h2 class="text-3xl font-semibold">Studio</h2>
|
||||
<p class="text-base font-medium text-gray-200">Manage your content</p>
|
||||
<:actions>
|
||||
<.link patch={~p"/channel/studio/upload"}>
|
||||
<.button>Upload videos</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.modal
|
||||
:if={@live_action in [:upload]}
|
||||
id="upload-modal"
|
||||
show
|
||||
on_cancel={JS.navigate(~p"/channel/studio")}
|
||||
>
|
||||
<:title>Upload videos</:title>
|
||||
<div>
|
||||
<form
|
||||
id="upload-form"
|
||||
phx-submit="upload_videos"
|
||||
phx-change="validate_uploads"
|
||||
class="min-h-[8rem] pt-4"
|
||||
>
|
||||
<div class="col-span-full">
|
||||
<div class="mt-2 flex justify-center rounded-lg border border-dashed border-white/25 px-6 py-10">
|
||||
<div class="text-center">
|
||||
<Heroicons.film class="mx-auto h-12 w-12 text-gray-500" aria-hidden="true" />
|
||||
<div class="mt-4 flex text-sm leading-6 text-gray-400">
|
||||
<label
|
||||
html-for="file-upload"
|
||||
class="mx-auto relative cursor-pointer rounded-md bg-gray-900 font-semibold text-white focus-within:outline-none focus-within:ring-2 focus-within:ring-purple-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-purple-500"
|
||||
>
|
||||
<span>Upload files</span>
|
||||
<.live_file_input id="file-upload" upload={@uploads.video} class="sr-only" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-gray-400">MP4 up to <%= max_file_size() %>GB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section phx-drop-target={@uploads.video.ref} class="mt-4">
|
||||
<%= for entry <- @uploads.video.entries do %>
|
||||
<article class="upload-entry">
|
||||
<div><%= entry.client_name %></div>
|
||||
<progress class="w-full" value={entry.progress} max="100">
|
||||
<%= entry.progress %>%
|
||||
</progress>
|
||||
<%= for err <- upload_errors(@uploads.video, entry) do %>
|
||||
<p class="alert alert-danger"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
<%= for err <- upload_errors(@uploads.video) do %>
|
||||
<p class="alert alert-danger"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
<.button type="submit" class="ml-auto mt-4 block">Submit</.button>
|
||||
</form>
|
||||
</div>
|
||||
</.modal>
|
||||
|
||||
<.table id="videos" rows={@streams.videos}>
|
||||
<:col :let={{_id, video}} label="Video">
|
||||
<div class="flex items-center gap-4">
|
||||
<.video_thumbnail
|
||||
video={video}
|
||||
class="shrink-0 rounded-lg w-full max-w-[12rem] pointer-events-none"
|
||||
/>
|
||||
<div class="max-w-2xl truncate">
|
||||
<.link
|
||||
class="font-medium text-white text-lg truncate hover:underline"
|
||||
navigate={~p"/#{video.channel_handle}/#{video.id}"}
|
||||
>
|
||||
<%= video.title %>
|
||||
</.link>
|
||||
<div :if={!@status[video.id]} class="h-10">
|
||||
<div class={[
|
||||
"group-hover:hidden pt-1 font-medium text-base truncate",
|
||||
video.description && "text-gray-300",
|
||||
!video.description && "italic text-gray-500"
|
||||
]}>
|
||||
<%= video.description || "No description" %>
|
||||
</div>
|
||||
<div class="hidden group-hover:flex items-center gap-1 -ml-1">
|
||||
<button
|
||||
phx-click="download_video"
|
||||
phx-value-id={video.id}
|
||||
class="text-gray-200 hover:text-white p-1 font-medium text-base"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
•
|
||||
<button
|
||||
phx-click="toggle_visibility"
|
||||
phx-value-id={video.id}
|
||||
class="text-gray-200 hover:text-white p-1 font-medium text-base"
|
||||
>
|
||||
Toggle visibility
|
||||
</button>
|
||||
•
|
||||
<button
|
||||
phx-click="delete_video"
|
||||
phx-value-id={video.id}
|
||||
class="text-red-300 hover:text-white p-1 font-medium text-base"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :if={@status[video.id]} class="pt-1 text-base font-mono">
|
||||
<AlgoraWeb.StudioLive.Status.info status={@status[video.id]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</:col>
|
||||
<:col :let={{_id, video}} label="Visibility">
|
||||
<div class="flex items-center gap-2 min-w-[6rem]">
|
||||
<Heroicons.globe_alt :if={video.visibility == :public} class="h-6 w-6 text-gray-300" />
|
||||
<Heroicons.link :if={video.visibility == :unlisted} class="h-6 w-6 text-gray-300" />
|
||||
<div class="text-gray-100 font-medium">
|
||||
<%= String.capitalize(to_string(video.visibility)) %>
|
||||
</div>
|
||||
</div>
|
||||
</:col>
|
||||
<:col :let={{_id, video}} label="Date">
|
||||
<div class="font-medium text-gray-100">
|
||||
<%= video.inserted_at |> Calendar.strftime("%b %d, %Y") %>
|
||||
</div>
|
||||
<div class="text-gray-400">
|
||||
<div :if={video.type == :vod}>Uploaded</div>
|
||||
<div :if={video.type == :livestream}>Streamed</div>
|
||||
</div>
|
||||
</:col>
|
||||
<:col :let={{_id, video}} label="Messages" align="right">
|
||||
<div class="text-gray-100 text-base font-medium text-right">
|
||||
<%= video.messages_count %>
|
||||
</div>
|
||||
</:col>
|
||||
</.table>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
channel = Library.get_channel!(socket.assigns.current_user)
|
||||
|
||||
if connected?(socket), do: Library.subscribe_to_studio()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:status, %{})
|
||||
|> stream(:videos, Library.list_studio_videos(channel))
|
||||
|> allow_upload(:video,
|
||||
accept: ~w(.mp4),
|
||||
max_entries: @max_entries,
|
||||
max_file_size: max_file_size() * 1_000_000_000
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{Library, %Library.Events.ProcessingQueued{video: video} = status},
|
||||
socket
|
||||
) do
|
||||
{
|
||||
:noreply,
|
||||
socket
|
||||
|> assign(:status, socket.assigns.status |> Map.put(video.id, status))
|
||||
|> stream_insert(:videos, video)
|
||||
}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{Library, %Library.Events.ProcessingProgressed{video: video} = status},
|
||||
socket
|
||||
) do
|
||||
{
|
||||
:noreply,
|
||||
socket
|
||||
|> assign(:status, socket.assigns.status |> Map.put(video.id, status))
|
||||
|> stream_insert(:videos, video)
|
||||
}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{Library,
|
||||
%Library.Events.ProcessingCompleted{action: action, video: video, url: url} = status},
|
||||
socket
|
||||
) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:status, socket.assigns.status |> Map.put(video.id, status))
|
||||
|> stream_insert(:videos, video)
|
||||
|
||||
socket =
|
||||
if action == :download do
|
||||
socket |> redirect(external: url)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{Library, %Library.Events.ProcessingFailed{video: video} = status},
|
||||
socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:status, socket.assigns.status |> Map.put(video.id, status))
|
||||
|> stream_insert(:videos, video)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
||||
video = Library.get_video!(id) |> Library.toggle_visibility!()
|
||||
{:noreply, socket |> stream_insert(:videos, video)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete_video", %{"id" => id}, socket) do
|
||||
# TODO: schedule deletion from bucket
|
||||
video = Library.get_video!(id)
|
||||
:ok = video |> Library.delete_video()
|
||||
{:noreply, socket |> stream_delete(:videos, video)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("download_video", %{"id" => id}, socket) do
|
||||
mp4_video = Library.get_mp4_video(id)
|
||||
|
||||
if mp4_video do
|
||||
{:noreply, redirect(socket, external: mp4_video.url)}
|
||||
else
|
||||
video = Library.get_video!(id)
|
||||
send(self(), {Library, %Library.Events.ProcessingQueued{video: video}})
|
||||
|
||||
%{video_id: id}
|
||||
|> Workers.MP4Transmuxer.new()
|
||||
|> Oban.insert()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("upload_videos", _params, socket) do
|
||||
_videos =
|
||||
consume_uploaded_entries(socket, :video, fn %{path: path}, entry ->
|
||||
video = Library.init_mp4!(entry, path, socket.assigns.current_user)
|
||||
send(self(), {Library, %Library.Events.ProcessingQueued{video: video}})
|
||||
|
||||
%{video_id: video.id}
|
||||
|> Workers.HLSTransmuxer.new()
|
||||
|> Oban.insert()
|
||||
|
||||
{:ok, video}
|
||||
end)
|
||||
|
||||
{:noreply, socket |> redirect(to: ~p"/channel/studio")}
|
||||
end
|
||||
|
||||
def handle_event("validate_uploads", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, _params) do
|
||||
socket |> assign(:page_title, "Studio")
|
||||
end
|
||||
|
||||
defp apply_action(socket, :upload, _params) do
|
||||
socket |> assign(:page_title, "Upload Videos")
|
||||
end
|
||||
|
||||
defp error_to_string(:too_large), do: "Too large"
|
||||
defp error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
|
||||
defp error_to_string(:too_many_files), do: "You have selected too many files"
|
||||
|
||||
defp max_file_size(), do: 1
|
||||
|
||||
defmodule Status do
|
||||
use Phoenix.Component
|
||||
|
||||
def info(%{status: %Library.Events.ProcessingQueued{}} = assigns) do
|
||||
~H"""
|
||||
<div class="text-yellow-400">
|
||||
Queued for processing...
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def info(%{status: %Library.Events.ProcessingProgressed{}} = assigns) do
|
||||
~H"""
|
||||
<div class="text-blue-400">
|
||||
Processing your video: <%= @status.stage %>
|
||||
</div>
|
||||
<div>
|
||||
[<%= :erlang.float_to_binary(@status.pct * 100.0, decimals: 0) %>%]
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def info(%{status: %Library.Events.ProcessingCompleted{}} = assigns) do
|
||||
~H"""
|
||||
<div class="text-green-400">
|
||||
Processing completed!
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def info(%{status: %Library.Events.ProcessingFailed{}} = assigns) do
|
||||
~H"""
|
||||
<div class={
|
||||
if(@status.attempt == @status.max_attempts, do: "text-red-400", else: "text-orange-400")
|
||||
}>
|
||||
<div>
|
||||
Processing failed
|
||||
</div>
|
||||
<div>
|
||||
Attempt: <%= @status.attempt %>/<%= @status.max_attempts %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
end
|
@ -1,4 +1,4 @@
|
||||
<.header class="p-4 sm:p-6 lg:p-8">
|
||||
<.header class="p-4">
|
||||
Subtitles
|
||||
<:actions>
|
||||
<.link patch={~p"/videos/#{@video.id}/subtitles/new"}>
|
||||
|
@ -49,6 +49,8 @@ defmodule AlgoraWeb.Router do
|
||||
live_session :authenticated,
|
||||
on_mount: [{AlgoraWeb.UserAuth, :ensure_authenticated}, AlgoraWeb.Nav] do
|
||||
live "/channel/settings", SettingsLive, :edit
|
||||
live "/channel/studio", StudioLive, :show
|
||||
live "/channel/studio/upload", StudioLive, :upload
|
||||
live "/:channel_handle/stream", ChannelLive, :stream
|
||||
|
||||
live "/videos/:video_id/subtitles", SubtitleLive.Index, :index
|
||||
|
1
mix.exs
1
mix.exs
@ -62,6 +62,7 @@ defmodule Algora.MixProject do
|
||||
{:phoenix_live_view, "~> 0.20.2"},
|
||||
{:phoenix, "~> 1.7.11"},
|
||||
{:plug_cowboy, "~> 2.5"},
|
||||
{:slugify, "~> 1.3"},
|
||||
{:swoosh, "~> 1.3"},
|
||||
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
|
||||
{:telemetry_metrics, "~> 0.6"},
|
||||
|
1
mix.lock
1
mix.lock
@ -97,6 +97,7 @@
|
||||
"ratio": {:hex, :ratio, "3.0.2", "60a5976872a4dc3d873ecc57eed1738589e99d1094834b9c935b118231297cfb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:numbers, "~> 5.2.0", [hex: :numbers, repo: "hexpm", optional: false]}], "hexpm", "3a13ed5a30ad0bfd7e4a86bf86d93d2b5a06f5904417d38d3f3ea6406cdfc7bb"},
|
||||
"req": {:hex, :req, "0.4.8", "2b754a3925ddbf4ad78c56f30208ced6aefe111a7ea07fb56c23dccc13eb87ae", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7146e51d52593bb7f20d00b5308a5d7d17d663d6e85cd071452b613a8277100c"},
|
||||
"shmex": {:hex, :shmex, "0.5.0", "7dc4fb1a8bd851085a652605d690bdd070628717864b442f53d3447326bcd3e8", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "b67bb1e22734758397c84458dbb746519e28eac210423c267c7248e59fc97bdc"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||
"stream_split": {:hex, :stream_split, "0.1.7", "2d3fd1fd21697da7f91926768d65f79409086052c9ec7ae593987388f52425f8", [:mix], [], "hexpm", "1dc072ff507a64404a0ad7af90df97096183fee8eeac7b300320cea7c4679147"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"},
|
||||
|
25
priv/repo/migrations/20240330185918_add_format_to_video.exs
Normal file
25
priv/repo/migrations/20240330185918_add_format_to_video.exs
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddFormatToVideo do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table("videos") do
|
||||
add :format, :integer
|
||||
end
|
||||
|
||||
execute "update videos set format = 1 where type = 1 and url not like 'https://youtube.com/watch?v=%'"
|
||||
|
||||
execute "update videos set format = 2 where type = 2"
|
||||
|
||||
execute "update videos set format = 3 where type = 1 and url like 'https://youtube.com/watch?v=%'"
|
||||
|
||||
alter table("videos") do
|
||||
modify :format, :integer, null: false
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table("videos") do
|
||||
remove :format
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,11 @@
|
||||
defmodule Algora.Repo.Migrations.AddTransmuxedFromToVideo do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:videos) do
|
||||
add :transmuxed_from_id, references(:videos, on_delete: :nothing)
|
||||
end
|
||||
|
||||
create index(:videos, [:transmuxed_from_id])
|
||||
end
|
||||
end
|
@ -0,0 +1,17 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddFilenameToVideo do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table("videos") do
|
||||
add :filename, :string
|
||||
end
|
||||
|
||||
execute "update videos set filename = replace(url, format('%s/', url_root), '') where url_root is not null"
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table("videos") do
|
||||
remove :filename
|
||||
end
|
||||
end
|
||||
end
|
19
priv/repo/migrations/20240331181838_add_paths_to_video.exs
Normal file
19
priv/repo/migrations/20240331181838_add_paths_to_video.exs
Normal file
@ -0,0 +1,19 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddPathsToVideo do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table("videos") do
|
||||
add :remote_path, :string
|
||||
add :local_path, :string
|
||||
end
|
||||
|
||||
execute "update videos set remote_path = format('%s/%s', uuid, filename) where filename is not null"
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table("videos") do
|
||||
remove :remote_path
|
||||
remove :local_path
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
defmodule Algora.Repo.Local.Migrations.MakeVideoUrlNullable do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table("videos") do
|
||||
modify :url, :string, null: true
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddDescriptionToVideo do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table("videos") do
|
||||
add :description, :string
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user