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.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])
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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: [
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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,6 +377,8 @@ defmodule Algora.Library do
|
|||||||
on: v.user_id == u.id,
|
on: v.user_id == u.id,
|
||||||
limit: ^limit,
|
limit: ^limit,
|
||||||
where:
|
where:
|
||||||
|
not is_nil(v.url) and
|
||||||
|
is_nil(v.transmuxed_from_id) and
|
||||||
v.visibility == :public 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),
|
||||||
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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])
|
||||||
|
@ -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
|
||||||
|
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,17 +70,15 @@ defmodule AlgoraWeb.CoreComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
attr :id, :string, required: true
|
|
||||||
attr :video, :any, required: true
|
attr :video, :any, required: true
|
||||||
|
attr :class, :string, default: nil
|
||||||
|
|
||||||
def short_entry(assigns) do
|
def short_thumbnail(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.link
|
<div class={[
|
||||||
id={@id}
|
"relative flex items-center justify-center overflow-hidden aspect-[9/16] bg-gray-800",
|
||||||
class="cursor-pointer truncate"
|
@class
|
||||||
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" />
|
<Heroicons.play :if={!@video.thumbnail_url} solid class="h-12 w-12 text-gray-500" />
|
||||||
<img
|
<img
|
||||||
:if={@video.vertical_thumbnail_url}
|
:if={@video.vertical_thumbnail_url}
|
||||||
@ -95,24 +93,18 @@ defmodule AlgoraWeb.CoreComponents do
|
|||||||
<%= Library.to_hhmmss(@video.duration) %>
|
<%= Library.to_hhmmss(@video.duration) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"><%= Timex.from_now(@video.inserted_at) %></div>
|
|
||||||
</.link>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
attr :id, :string, required: true
|
|
||||||
attr :video, :any, required: true
|
attr :video, :any, required: true
|
||||||
|
attr :class, :string, default: nil
|
||||||
|
|
||||||
def video_entry(assigns) do
|
def video_thumbnail(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.link
|
<div class={[
|
||||||
id={@id}
|
"relative flex items-center justify-center overflow-hidden aspect-[16/9] bg-gray-800",
|
||||||
class="cursor-pointer truncate"
|
@class
|
||||||
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" />
|
<Heroicons.play :if={!@video.thumbnail_url} solid class="h-12 w-12 text-gray-500" />
|
||||||
<img
|
<img
|
||||||
:if={@video.thumbnail_url}
|
:if={@video.thumbnail_url}
|
||||||
@ -134,6 +126,28 @@ defmodule AlgoraWeb.CoreComponents do
|
|||||||
<%= Library.to_hhmmss(@video.duration) %>
|
<%= Library.to_hhmmss(@video.duration) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :video, :any, required: true
|
||||||
|
|
||||||
|
def short_entry(assigns) do
|
||||||
|
~H"""
|
||||||
|
<.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>
|
||||||
|
</.link>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :video, :any, required: true
|
||||||
|
|
||||||
|
def video_entry(assigns) do
|
||||||
|
~H"""
|
||||||
|
<.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="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>
|
||||||
|
@ -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"}
|
||||||
|
@ -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>
|
||||||
|
@ -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"""
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
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
|
Subtitles
|
||||||
<:actions>
|
<:actions>
|
||||||
<.link patch={~p"/videos/#{@video.id}/subtitles/new"}>
|
<.link patch={~p"/videos/#{@video.id}/subtitles/new"}>
|
||||||
|
@ -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
|
||||||
|
1
mix.exs
1
mix.exs
@ -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"},
|
||||||
|
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"},
|
"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"},
|
||||||
|
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