1
0
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:
Zafer Cesur 2024-04-01 18:39:03 +03:00 committed by GitHub
parent b27082fd6a
commit 2b73c40bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1025 additions and 158 deletions

View File

@ -1,9 +1,7 @@
import Ecto.Query import Ecto.Query
import Ecto.Changeset import Ecto.Changeset
alias Algora.Accounts alias Algora.{Accounts, Library, Repo, Storage}
alias Algora.Library
alias Algora.Repo
IEx.configure(inspect: [charlists: :as_lists]) IEx.configure(inspect: [charlists: :as_lists])

View File

@ -90,19 +90,13 @@
border-radius: 4px; border-radius: 4px;
} }
.alert-info { .alert-info {
color: #31708f; @apply text-blue-300 bg-blue-950/50 border-blue-500;
background-color: #d9edf7;
border-color: #bce8f1;
} }
.alert-warning { .alert-warning {
color: #8a6d3b; @apply text-yellow-300 bg-yellow-950/50 border-yellow-500;
background-color: #fcf8e3;
border-color: #faebcc;
} }
.alert-danger { .alert-danger {
color: #a94442; @apply text-red-300 bg-red-950/50 border-red-500;
background-color: #f2dede;
border-color: #ebccd1;
} }
.alert p { .alert p {
margin-bottom: 0; margin-bottom: 0;

View File

@ -22,6 +22,10 @@ config :algora, AlgoraWeb.Endpoint,
layout: false layout: false
] ]
config :algora, Oban,
repo: Algora.Repo.Local,
queues: [default: 10]
config :esbuild, config :esbuild,
version: "0.17.11", version: "0.17.11",
tv: [ tv: [

View File

@ -19,6 +19,8 @@ config :algora, Algora.Repo.Local,
pool_size: 10, pool_size: 10,
priv: "priv/repo" priv: "priv/repo"
config :algora, Oban, testing: :inline
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.
config :algora, AlgoraWeb.Endpoint, config :algora, AlgoraWeb.Endpoint,

View File

@ -34,6 +34,8 @@ defmodule Algora.Application do
Algora.Repo.Local, Algora.Repo.Local,
# Start the supervisor for LSN tracking # Start the supervisor for LSN tracking
{Fly.Postgres.LSN.Supervisor, repo: Algora.Repo.Local}, {Fly.Postgres.LSN.Supervisor, repo: Algora.Repo.Local},
# Start the Oban system
{Oban, Application.fetch_env!(:algora, Oban)},
# Start the Telemetry supervisor # Start the Telemetry supervisor
AlgoraWeb.Telemetry, AlgoraWeb.Telemetry,
# Start the PubSub system # Start the PubSub system

View File

@ -12,6 +12,10 @@ defmodule Algora.Library do
@pubsub Algora.PubSub @pubsub Algora.PubSub
def subscribe_to_studio() do
Phoenix.PubSub.subscribe(@pubsub, topic_studio())
end
def subscribe_to_livestreams() do def subscribe_to_livestreams() do
Phoenix.PubSub.subscribe(@pubsub, topic_livestreams()) Phoenix.PubSub.subscribe(@pubsub, topic_livestreams())
end end
@ -25,14 +29,165 @@ defmodule Algora.Library do
title: "", title: "",
duration: 0, duration: 0,
type: :livestream, type: :livestream,
format: :hls,
is_live: true, is_live: true,
visibility: :unlisted visibility: :unlisted
} }
|> change() |> change()
|> Video.put_video_path(:livestream) |> Video.put_video_url(:hls)
|> Repo.insert!() |> Repo.insert!()
end 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 def toggle_streamer_live(%Video{} = video, is_live) do
video = get_video!(video.id) video = get_video!(video.id)
user = Accounts.get_user!(video.user_id) user = Accounts.get_user!(video.user_id)
@ -97,10 +252,18 @@ defmodule Algora.Library do
end end
end end
defp get_playlist(%Video{} = video) do def toggle_visibility!(%Video{} = video) do
url = "#{video.url_root}/index.m3u8" 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, []) ExM3U8.deserialize_playlist(resp.body, [])
end end
end end
@ -120,7 +283,7 @@ defmodule Algora.Library do
end end
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 with {:ok, playlist} <- get_media_playlist(video) do
duration = duration =
playlist.timeline playlist.timeline
@ -131,8 +294,14 @@ defmodule Algora.Library do
end end
end end
def get_duration(%Video{type: :vod}) do def get_duration(%Video{local_path: nil}), do: {:error, :not_implemented}
{: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 end
def to_hhmmss(duration) when is_integer(duration) do 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)) Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id))
end end
defp create_thumbnail(%Video{} = video, contents) do defp create_thumbnail_from_file(%Video{} = video, src_path) do
input_path = Path.join(System.tmp_dir(), "#{video.uuid}.mp4") dst_path = Path.join(System.tmp_dir!(), "#{video.uuid}.jpeg")
output_path = Path.join(System.tmp_dir(), "#{video.uuid}.jpeg")
with :ok <- File.write(input_path, contents), with :ok <- Thumbnex.create_thumbnail(src_path, dst_path) do
:ok <- Thumbnex.create_thumbnail(input_path, output_path) do File.read(dst_path)
File.read(output_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
end end
def store_thumbnail(%Video{} = video, contents) do def store_thumbnail(%Video{} = video, contents) do
with {:ok, thumbnail} <- create_thumbnail(video, contents), with {:ok, thumbnail} <- create_thumbnail(video, contents),
{:ok, _} <- Storage.upload_file("#{video.uuid}/index.jpeg", thumbnail) do {:ok, _} <- Storage.upload(thumbnail, "#{video.uuid}/index.jpeg") do
:ok video
|> change()
|> put_change(:thumbnail_url, "#{video.url_root}/index.jpeg")
|> Repo.update()
end end
end end
def reconcile_livestream(%Video{} = video, stream_key) do def reconcile_livestream(%Video{} = video, stream_key) do
user = user = Accounts.get_user_by!(stream_key: stream_key)
Accounts.get_user_by!(stream_key: stream_key)
result = result =
Repo.update_all(from(v in Video, where: v.id == ^video.id), Repo.update_all(from(v in Video, where: v.id == ^video.id),
@ -179,11 +366,8 @@ defmodule Algora.Library do
) )
case result do case result do
{1, _} -> {1, _} -> {:ok, video}
{:ok, video} _ -> {:error, :invalid}
_ ->
{:error, :invalid}
end end
end end
@ -193,7 +377,9 @@ defmodule Algora.Library do
on: v.user_id == u.id, on: v.user_id == u.id,
limit: ^limit, limit: ^limit,
where: 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 is_nil(v.vertical_thumbnail_url) and
(v.is_live == true or v.duration >= 120 or v.type == :vod), (v.is_live == true or v.duration >= 120 or v.type == :vod),
select_merge: %{channel_handle: u.handle, channel_name: u.name} select_merge: %{channel_handle: u.handle, channel_name: u.name}
@ -207,7 +393,10 @@ defmodule Algora.Library do
join: u in User, join: u in User,
on: v.user_id == u.id, on: v.user_id == u.id,
limit: ^limit, 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} select_merge: %{channel_handle: u.handle, channel_name: u.name}
) )
|> order_by_inserted(:desc) |> order_by_inserted(:desc)
@ -220,7 +409,29 @@ defmodule Algora.Library do
join: u in User, join: u in User,
on: v.user_id == u.id, on: v.user_id == u.id,
select_merge: %{channel_handle: u.handle, channel_name: u.name}, 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) |> order_by_inserted(:desc)
|> Repo.all() |> Repo.all()
@ -256,34 +467,19 @@ defmodule Algora.Library do
user.id == channel.user_id user.id == channel.user_id
end end
defp youtube_id(%Video{url: url}) do def player_type(%Video{format: :mp4}), do: "video/mp4"
url = URI.parse(url) def player_type(%Video{format: :hls}), do: "application/x-mpegURL"
root = ".#{url.host}" def player_type(%Video{format: :youtube}), do: "video/youtube"
cond do def get_video!(id),
root |> String.ends_with?(".youtube.com") -> do:
%{"v" => id} = URI.decode_query(url.query) from(v in Video,
id where: v.id == ^id,
join: u in User,
root |> String.ends_with?(".youtu.be") -> on: v.user_id == u.id,
"/" <> id = url.path select_merge: %{channel_handle: u.handle, channel_name: u.name}
id )
|> Repo.one!()
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 update_video(%Video{} = video, attrs) do def update_video(%Video{} = video, attrs) do
video video
@ -291,6 +487,10 @@ defmodule Algora.Library do
|> Repo.update() |> Repo.update()
end 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 defp order_by_inserted(%Ecto.Query{} = query, direction) when direction in [:asc, :desc] do
from(s in query, order_by: [{^direction, s.inserted_at}]) from(s in query, order_by: [{^direction, s.inserted_at}])
end end
@ -299,6 +499,8 @@ defmodule Algora.Library do
def topic_livestreams(), do: "livestreams" def topic_livestreams(), do: "livestreams"
def topic_studio(), do: "studio"
def list_subtitles(%Video{} = video) do def list_subtitles(%Video{} = video) do
from(s in Subtitle, where: s.video_id == ^video.id, order_by: [asc: s.start]) from(s in Subtitle, where: s.video_id == ^video.id, order_by: [asc: s.start])
|> Repo.all() |> Repo.all()
@ -338,4 +540,24 @@ defmodule Algora.Library do
|> Enum.map(&save_subtitle/1) |> Enum.map(&save_subtitle/1)
|> length |> length
end 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 end

View File

@ -10,4 +10,20 @@ defmodule Algora.Library.Events do
defmodule ThumbnailsGenerated do defmodule ThumbnailsGenerated do
defstruct video: nil defstruct video: nil
end 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 end

View File

@ -3,24 +3,36 @@ defmodule Algora.Library.Video do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias Algora.Accounts alias Algora.Accounts.User
alias Algora.Library.Video
alias Algora.Chat.Message
@type t() :: %__MODULE__{} @type t() :: %__MODULE__{}
schema "videos" do schema "videos" do
field :duration, :integer field :duration, :integer
field :title, :string field :title, :string
field :description, :string
field :type, Ecto.Enum, values: [vod: 1, livestream: 2] 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 :is_live, :boolean, default: false
field :thumbnail_url, :string field :thumbnail_url, :string
field :vertical_thumbnail_url, :string field :vertical_thumbnail_url, :string
field :url, :string field :url, :string
field :url_root, :string field :url_root, :string
field :uuid, :string field :uuid, :string
field :filename, :string
field :channel_handle, :string, virtual: true field :channel_handle, :string, virtual: true
field :channel_name, :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] 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() timestamps()
end end
@ -32,27 +44,41 @@ defmodule Algora.Library.Video do
|> validate_required([:title]) |> validate_required([:title])
end 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) put_assoc(changeset, :user, user)
end end
def put_video_path(%Ecto.Changeset{} = changeset, type) def put_video_meta(%Ecto.Changeset{} = changeset, format, basename \\ "index")
when type in [:vod, :livestream] do when format in [:mp4, :hls] do
if changeset.valid? do if changeset.valid? do
uuid = Ecto.UUID.generate() uuid = Ecto.UUID.generate()
filename = "index#{fileext(type)}" filename = "#{basename}#{fileext(format)}"
changeset changeset
|> put_change(:filename, filename)
|> put_change(:uuid, uuid) |> put_change(:uuid, uuid)
|> put_change(:url, url(uuid, filename))
|> put_change(:url_root, url_root(uuid))
else else
changeset changeset
end end
end end
defp fileext(:vod), do: ".mp4" def put_video_url(%Ecto.Changeset{} = changeset, format, basename \\ "index")
defp fileext(:livestream), do: ".m3u8" 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 defp url_root(uuid) do
bucket = Algora.config([:files, :bucket]) bucket = Algora.config([:files, :bucket])

View File

@ -1,9 +1,10 @@
defmodule Algora.Storage do defmodule Algora.Storage do
@behaviour Membrane.HTTPAdaptiveStream.Storage @behaviour Membrane.HTTPAdaptiveStream.Storage
import Ecto.Changeset
require Membrane.Logger require Membrane.Logger
alias Algora.{Repo, Library} alias Algora.Library
@pubsub Algora.PubSub
@enforce_keys [:video] @enforce_keys [:video]
defstruct @enforce_keys ++ [video_header: <<>>] defstruct @enforce_keys ++ [video_header: <<>>]
@ -27,7 +28,7 @@ defmodule Algora.Storage do
) do ) do
path = "#{video.uuid}/#{name}" 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} <- process_contents(parent_id, name, contents, metadata, ctx, state) do
{:ok, state} {:ok, state}
else else
@ -67,13 +68,8 @@ defmodule Algora.Storage do
%{type: :segment, mode: :binary}, %{type: :segment, mode: :binary},
%{video: %{thumbnail_url: nil} = video, video_header: video_header} = state %{video: %{thumbnail_url: nil} = video, video_header: video_header} = state
) do ) do
with :ok <- Library.store_thumbnail(video, video_header <> contents), with {:ok, video} <- Library.store_thumbnail(video, video_header <> contents) do
{:ok, video} = broadcast_thumbnails_generated!(video)
video
|> change()
|> put_change(:thumbnail_url, "#{video.url_root}/index.jpeg")
|> Repo.update(),
:ok <- broadcast_thumbnails_generated(video) do
{:ok, %{state | video: video}} {:ok, %{state | video: video}}
end end
end end
@ -82,17 +78,31 @@ defmodule Algora.Storage do
{:ok, state} {:ok, state}
end end
def upload_file(path, contents, opts \\ []) do def upload(contents, remote_path, opts \\ []) do
Algora.config([:files, :bucket]) Algora.config([:files, :bucket])
|> ExAws.S3.put_object(path, contents, opts) |> ExAws.S3.put_object(remote_path, contents, opts)
|> ExAws.request([]) |> ExAws.request([])
end end
defp broadcast_thumbnails_generated(video) do def upload_from_filename(local_path, remote_path, cb \\ fn _ -> nil end, opts \\ []) do
Phoenix.PubSub.broadcast( %{size: size} = File.stat!(local_path)
Algora.PubSub,
Library.topic_livestreams(), chunk_size = 5 * 1024 * 1024
{__MODULE__, %Library.Events.ThumbnailsGenerated{video: video}}
) 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
end end

View 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

View 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

View File

@ -70,31 +70,71 @@ defmodule AlgoraWeb.CoreComponents do
""" """
end 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 attr :video, :any, required: true
def short_entry(assigns) do def short_entry(assigns) do
~H""" ~H"""
<.link <.link class="cursor-pointer truncate" navigate={~p"/#{@video.channel_handle}/#{@video.id}"}>
id={@id} <.short_thumbnail video={@video} class="rounded-2xl" />
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>
<div class="pt-2 text-base font-semibold truncate"><%= @video.title %></div> <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 font-medium"><%= @video.channel_name %></div>
<div class="text-gray-300 text-sm"><%= Timex.from_now(@video.inserted_at) %></div> <div class="text-gray-300 text-sm"><%= Timex.from_now(@video.inserted_at) %></div>
@ -102,38 +142,12 @@ defmodule AlgoraWeb.CoreComponents do
""" """
end end
attr :id, :string, required: true
attr :video, :any, required: true attr :video, :any, required: true
def video_entry(assigns) do def video_entry(assigns) do
~H""" ~H"""
<.link <.link class="cursor-pointer truncate" navigate={~p"/#{@video.channel_handle}/#{@video.id}"}>
id={@id} <.video_thumbnail video={@video} class="rounded-2xl" />
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>
<div class="pt-2 text-base font-semibold truncate"><%= @video.title %></div> <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 font-medium"><%= @video.channel_name %></div>
<div class="text-gray-300 text-sm"><%= Timex.from_now(@video.inserted_at) %></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" class="mt-3 gap-8 grid sm:grid-cols-2 lg:grid-cols-3"
phx-update="stream" phx-update="stream"
> >
<.video_entry :for={{id, video} <- @videos} id={id} video={video} /> <.video_entry :for={{_id, video} <- @videos} video={video} />
</div> </div>
</div> </div>
</div> </div>
@ -474,7 +488,7 @@ defmodule AlgoraWeb.CoreComponents do
phx-window-keydown={hide_modal(@on_cancel, @id)} phx-window-keydown={hide_modal(@on_cancel, @id)}
phx-key="escape" phx-key="escape"
phx-click-away={hide_modal(@on_cancel, @id)} 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"> <div class="absolute top-6 right-5">
<button <button
@ -658,7 +672,7 @@ defmodule AlgoraWeb.CoreComponents do
<button <button
type={@type} type={@type}
class={[ 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", "text-sm font-semibold leading-6 text-gray-950 active:text-gray-950/80",
@class @class
]} ]}
@ -876,6 +890,7 @@ defmodule AlgoraWeb.CoreComponents do
slot :col, required: true do slot :col, required: true do
attr :label, :string attr :label, :string
attr :align, :string
end end
slot :action, doc: "the slot for showing user actions in the last table column" slot :action, doc: "the slot for showing user actions in the last table column"
@ -893,7 +908,11 @@ defmodule AlgoraWeb.CoreComponents do
<tr> <tr>
<th <th
:for={{col, i} <- Enum.with_index(@col)} :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] %> <%= col[:label] %>
</th> </th>
@ -915,7 +934,7 @@ defmodule AlgoraWeb.CoreComponents do
phx-click={@row_click && @row_click.(row)} phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]} 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"]}> <span class={["relative", i == 0 && "font-semibold text-gray-50"]}>
<%= render_slot(col, @row_item.(row)) %> <%= render_slot(col, @row_item.(row)) %>
</span> </span>

View File

@ -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" /> <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> </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>
<.link <.link
navigate={~p"/channel/settings"} navigate={~p"/channel/settings"}

View File

@ -50,13 +50,22 @@
</head> </head>
<body> <body>
<div <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" aria-hidden="true"
> >
<div <div
id="video-backdrop" class="w-screen h-[150vh] flex-none bg-gradient-to-b from-gray-900 to-gray-950 opacity-75 transition-opacity"
class="w-screen h-[150vh] flex-none bg-gradient-to-r from-[#a78bfa] to-[#4f46e5] opacity-10 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%)"
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%)" >
</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>
</div> </div>

View File

@ -4,7 +4,7 @@ defmodule AlgoraWeb.ChannelLive do
alias Algora.{Accounts, Library, Storage} alias Algora.{Accounts, Library, Storage}
alias AlgoraWeb.{LayoutComponent, Presence} alias AlgoraWeb.{LayoutComponent, Presence}
alias AlgoraWeb.ChannelLive.{StreamFormComponent} alias AlgoraWeb.ChannelLive.StreamFormComponent
def render(assigns) do def render(assigns) do
~H""" ~H"""

View File

@ -13,7 +13,7 @@ defmodule AlgoraWeb.HomeLive do
Videos Videos
</h2> </h2>
<div class="pt-8 gap-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> <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>
</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 ><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" /></svg>Shorts
</h2> </h2>
<div class="pt-4 gap-8 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6"> <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> </div>
</div> </div>
@ -43,7 +43,7 @@ defmodule AlgoraWeb.HomeLive do
Videos Videos
</h2> </h2>
<div class="pt-8 gap-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> <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>
</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 ><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" /></svg>Shorts
</h2> </h2>
<div class="pt-4 gap-8 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6"> <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> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ defmodule AlgoraWeb.SettingsLive do
def render(assigns) do def render(assigns) do
~H""" ~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"> <.header class="pb-6">
Settings Settings
<:subtitle> <:subtitle>

View 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>
&bull;
<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>
&bull;
<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

View File

@ -1,4 +1,4 @@
<.header class="p-4 sm:p-6 lg:p-8"> <.header class="p-4">
Subtitles Subtitles
<:actions> <:actions>
<.link patch={~p"/videos/#{@video.id}/subtitles/new"}> <.link patch={~p"/videos/#{@video.id}/subtitles/new"}>

View File

@ -49,6 +49,8 @@ defmodule AlgoraWeb.Router do
live_session :authenticated, live_session :authenticated,
on_mount: [{AlgoraWeb.UserAuth, :ensure_authenticated}, AlgoraWeb.Nav] do on_mount: [{AlgoraWeb.UserAuth, :ensure_authenticated}, AlgoraWeb.Nav] do
live "/channel/settings", SettingsLive, :edit live "/channel/settings", SettingsLive, :edit
live "/channel/studio", StudioLive, :show
live "/channel/studio/upload", StudioLive, :upload
live "/:channel_handle/stream", ChannelLive, :stream live "/:channel_handle/stream", ChannelLive, :stream
live "/videos/:video_id/subtitles", SubtitleLive.Index, :index live "/videos/:video_id/subtitles", SubtitleLive.Index, :index

View File

@ -62,6 +62,7 @@ defmodule Algora.MixProject do
{:phoenix_live_view, "~> 0.20.2"}, {:phoenix_live_view, "~> 0.20.2"},
{:phoenix, "~> 1.7.11"}, {:phoenix, "~> 1.7.11"},
{:plug_cowboy, "~> 2.5"}, {:plug_cowboy, "~> 2.5"},
{:slugify, "~> 1.3"},
{:swoosh, "~> 1.3"}, {:swoosh, "~> 1.3"},
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
{:telemetry_metrics, "~> 0.6"}, {:telemetry_metrics, "~> 0.6"},

View File

@ -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"}, "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"}, "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"}, "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"}, "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"}, "stream_split": {:hex, :stream_split, "0.1.7", "2d3fd1fd21697da7f91926768d65f79409086052c9ec7ae593987388f52425f8", [:mix], [], "hexpm", "1dc072ff507a64404a0ad7af90df97096183fee8eeac7b300320cea7c4679147"},
"sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"},

View 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

View File

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

View File

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

View 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

View File

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

View File

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