2024-02-29 22:31:42 +03:00
|
|
|
defmodule Algora.Library do
|
|
|
|
@moduledoc """
|
|
|
|
The Library context.
|
|
|
|
"""
|
|
|
|
|
|
|
|
require Logger
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
import Ecto.Changeset
|
|
|
|
alias Algora.Accounts.User
|
2024-03-05 06:30:45 +03:00
|
|
|
alias Algora.{Repo, Accounts, Storage}
|
|
|
|
alias Algora.Library.{Channel, Video, Events, Subtitle}
|
2024-02-29 22:31:42 +03:00
|
|
|
|
|
|
|
@pubsub Algora.PubSub
|
|
|
|
|
|
|
|
def subscribe_to_livestreams() do
|
|
|
|
Phoenix.PubSub.subscribe(@pubsub, topic_livestreams())
|
|
|
|
end
|
|
|
|
|
|
|
|
def subscribe_to_channel(%Channel{} = channel) do
|
|
|
|
Phoenix.PubSub.subscribe(@pubsub, topic(channel.user_id))
|
|
|
|
end
|
|
|
|
|
|
|
|
def init_livestream!() do
|
|
|
|
%Video{
|
|
|
|
title: "",
|
|
|
|
duration: 0,
|
|
|
|
type: :livestream,
|
|
|
|
is_live: true,
|
|
|
|
visibility: :unlisted
|
|
|
|
}
|
|
|
|
|> change()
|
|
|
|
|> Video.put_video_path(:livestream)
|
|
|
|
|> Repo.insert!()
|
|
|
|
end
|
|
|
|
|
|
|
|
def toggle_streamer_live(%Video{} = video, is_live) do
|
|
|
|
video = get_video!(video.id)
|
|
|
|
user = Accounts.get_user!(video.user_id)
|
|
|
|
|
|
|
|
if user.visibility == :public do
|
|
|
|
Repo.update_all(from(u in Accounts.User, where: u.id == ^video.user_id),
|
|
|
|
set: [is_live: is_live]
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
Repo.update_all(
|
|
|
|
from(v in Video,
|
|
|
|
where: v.user_id == ^video.user_id and (v.id != ^video.id or not (^is_live))
|
|
|
|
),
|
|
|
|
set: [is_live: false]
|
|
|
|
)
|
|
|
|
|
|
|
|
video = get_video!(video.id)
|
|
|
|
|
|
|
|
video =
|
|
|
|
with false <- is_live,
|
|
|
|
{:ok, duration} <- get_duration(video),
|
|
|
|
{:ok, video} <- video |> change() |> put_change(:duration, duration) |> Repo.update() do
|
|
|
|
video
|
|
|
|
else
|
|
|
|
_ -> video
|
|
|
|
end
|
|
|
|
|
|
|
|
msg =
|
|
|
|
case is_live do
|
|
|
|
true -> %Events.LivestreamStarted{video: video}
|
|
|
|
false -> %Events.LivestreamEnded{video: video}
|
|
|
|
end
|
|
|
|
|
|
|
|
Phoenix.PubSub.broadcast!(@pubsub, topic_livestreams(), {__MODULE__, msg})
|
|
|
|
|
|
|
|
sink_url = Algora.config([:event_sink, :url])
|
|
|
|
|
|
|
|
if sink_url && user.visibility == :public do
|
|
|
|
identity =
|
|
|
|
from(i in Algora.Accounts.Identity,
|
|
|
|
join: u in assoc(i, :user),
|
|
|
|
where: u.id == ^video.user_id and i.provider == "github",
|
|
|
|
order_by: [asc: i.inserted_at]
|
|
|
|
)
|
|
|
|
|> Repo.one()
|
|
|
|
|> Repo.preload(:user)
|
|
|
|
|
|
|
|
body =
|
|
|
|
Jason.encode_to_iodata!(%{
|
|
|
|
event_kind: if(is_live, do: :livestream_started, else: :livestream_ended),
|
|
|
|
stream_id: video.uuid,
|
|
|
|
url: "#{AlgoraWeb.Endpoint.url()}/#{identity.user.handle}",
|
|
|
|
github_user: %{
|
|
|
|
id: String.to_integer(identity.provider_id),
|
|
|
|
login: identity.provider_login
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
{:ok, _} =
|
|
|
|
Finch.build(:post, sink_url, [{"content-type", "application/json"}], body)
|
|
|
|
|> Finch.request(Algora.Finch)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp get_playlist(%Video{} = video) do
|
|
|
|
url = "#{video.url_root}/index.m3u8"
|
|
|
|
|
|
|
|
with {:ok, resp} <- Finch.build(:get, url) |> Finch.request(Algora.Finch) do
|
|
|
|
ExM3U8.deserialize_playlist(resp.body, [])
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp get_media_playlist(%Video{} = video, uri) do
|
|
|
|
url = "#{video.url_root}/#{uri}"
|
|
|
|
|
|
|
|
with {:ok, resp} <- Finch.build(:get, url) |> Finch.request(Algora.Finch) do
|
|
|
|
ExM3U8.deserialize_media_playlist(resp.body, [])
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
defp get_media_playlist(%Video{} = video) do
|
|
|
|
with {:ok, playlist} <- get_playlist(video) do
|
|
|
|
uri = playlist.items |> Enum.find(&match?(%{uri: _}, &1)) |> then(& &1.uri)
|
|
|
|
get_media_playlist(video, uri)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_duration(%Video{type: :livestream} = video) do
|
|
|
|
with {:ok, playlist} <- get_media_playlist(video) do
|
|
|
|
duration =
|
|
|
|
playlist.timeline
|
|
|
|
|> Enum.filter(&match?(%{duration: _}, &1))
|
|
|
|
|> Enum.reduce(0, fn x, acc -> acc + x.duration end)
|
|
|
|
|
|
|
|
{:ok, round(duration)}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_duration(%Video{type: :vod}) do
|
|
|
|
{:error, :not_implemented}
|
|
|
|
end
|
|
|
|
|
|
|
|
def to_hhmmss(duration) when is_integer(duration) do
|
|
|
|
hours = div(duration, 60 * 60)
|
|
|
|
minutes = div(duration - hours * 60 * 60, 60)
|
|
|
|
seconds = rem(duration - hours * 60 * 60 - minutes * 60, 60)
|
|
|
|
|
|
|
|
if(hours == 0, do: [minutes, seconds], else: [hours, minutes, seconds])
|
|
|
|
|> Enum.map_join(":", fn count -> String.pad_leading("#{count}", 2, ["0"]) end)
|
|
|
|
end
|
|
|
|
|
2024-03-10 16:51:18 +03:00
|
|
|
def to_hhmmss(duration) when is_float(duration) do
|
|
|
|
to_hhmmss(trunc(duration))
|
|
|
|
end
|
|
|
|
|
2024-02-29 22:31:42 +03:00
|
|
|
def unsubscribe_to_channel(%Channel{} = channel) do
|
|
|
|
Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id))
|
|
|
|
end
|
|
|
|
|
|
|
|
defp create_thumbnail(%Video{} = video, contents) do
|
|
|
|
input_path = Path.join(System.tmp_dir(), "#{video.uuid}.mp4")
|
|
|
|
output_path = Path.join(System.tmp_dir(), "#{video.uuid}.jpeg")
|
|
|
|
|
|
|
|
with :ok <- File.write(input_path, contents),
|
|
|
|
:ok <- Thumbnex.create_thumbnail(input_path, output_path) do
|
|
|
|
File.read(output_path)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def store_thumbnail(%Video{} = video, contents) do
|
|
|
|
with {:ok, thumbnail} <- create_thumbnail(video, contents),
|
|
|
|
{:ok, _} <- Storage.upload_file("#{video.uuid}/index.jpeg", thumbnail) do
|
|
|
|
:ok
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def reconcile_livestream(%Video{} = video, stream_key) do
|
|
|
|
user =
|
|
|
|
Accounts.get_user_by!(stream_key: stream_key)
|
|
|
|
|
|
|
|
result =
|
|
|
|
Repo.update_all(from(v in Video, where: v.id == ^video.id),
|
|
|
|
set: [user_id: user.id, title: user.channel_tagline, visibility: user.visibility]
|
|
|
|
)
|
|
|
|
|
|
|
|
case result do
|
|
|
|
{1, _} ->
|
|
|
|
{:ok, video}
|
|
|
|
|
|
|
|
_ ->
|
|
|
|
{:error, :invalid}
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def list_videos(limit \\ 100) do
|
|
|
|
from(v in Video,
|
|
|
|
join: u in User,
|
|
|
|
on: v.user_id == u.id,
|
|
|
|
limit: ^limit,
|
|
|
|
where:
|
|
|
|
v.visibility == :public and
|
2024-03-07 02:43:11 +03:00
|
|
|
is_nil(v.vertical_thumbnail_url) and
|
2024-02-29 22:31:42 +03:00
|
|
|
(v.is_live == true or v.duration >= 120 or v.type == :vod),
|
2024-03-11 23:40:26 +03:00
|
|
|
select_merge: %{channel_handle: u.handle, channel_name: u.name}
|
2024-02-29 22:31:42 +03:00
|
|
|
)
|
|
|
|
|> order_by_inserted(:desc)
|
2024-03-11 21:55:17 +03:00
|
|
|
|> Repo.all()
|
2024-02-29 22:31:42 +03:00
|
|
|
end
|
|
|
|
|
2024-03-07 02:43:11 +03:00
|
|
|
def list_shorts(limit \\ 100) do
|
|
|
|
from(v in Video,
|
|
|
|
join: u in User,
|
|
|
|
on: v.user_id == u.id,
|
|
|
|
limit: ^limit,
|
|
|
|
where: v.visibility == :public and not is_nil(v.vertical_thumbnail_url),
|
2024-03-11 23:40:26 +03:00
|
|
|
select_merge: %{channel_handle: u.handle, channel_name: u.name}
|
2024-03-07 02:43:11 +03:00
|
|
|
)
|
|
|
|
|> order_by_inserted(:desc)
|
2024-03-11 21:55:17 +03:00
|
|
|
|> Repo.all()
|
2024-03-07 02:43:11 +03:00
|
|
|
end
|
|
|
|
|
2024-02-29 22:31:42 +03:00
|
|
|
def list_channel_videos(%Channel{} = channel, limit \\ 100) do
|
|
|
|
from(v in Video,
|
|
|
|
limit: ^limit,
|
|
|
|
join: u in User,
|
|
|
|
on: v.user_id == u.id,
|
2024-03-11 23:40:26 +03:00
|
|
|
select_merge: %{channel_handle: u.handle, channel_name: u.name},
|
2024-02-29 22:31:42 +03:00
|
|
|
where: v.user_id == ^channel.user_id
|
|
|
|
)
|
|
|
|
|> order_by_inserted(:desc)
|
2024-03-11 21:55:17 +03:00
|
|
|
|> Repo.all()
|
2024-02-29 22:31:42 +03:00
|
|
|
end
|
|
|
|
|
|
|
|
def list_active_channels(opts) do
|
|
|
|
from(u in Algora.Accounts.User,
|
|
|
|
where: u.is_live,
|
|
|
|
limit: ^Keyword.fetch!(opts, :limit),
|
|
|
|
order_by: [desc: u.updated_at],
|
|
|
|
select: struct(u, [:id, :handle, :channel_tagline, :avatar_url, :external_homepage_url])
|
|
|
|
)
|
2024-03-11 21:55:17 +03:00
|
|
|
|> Repo.all()
|
2024-02-29 22:31:42 +03:00
|
|
|
|> Enum.map(&get_channel!/1)
|
|
|
|
end
|
|
|
|
|
|
|
|
def get_channel!(%Accounts.User{} = user) do
|
|
|
|
%Channel{
|
|
|
|
user_id: user.id,
|
|
|
|
handle: user.handle,
|
|
|
|
name: user.name || user.handle,
|
|
|
|
tagline: user.channel_tagline,
|
|
|
|
avatar_url: user.avatar_url,
|
|
|
|
external_homepage_url: user.external_homepage_url,
|
|
|
|
is_live: user.is_live,
|
|
|
|
bounties_count: user.bounties_count,
|
|
|
|
orgs_contributed: user.orgs_contributed,
|
|
|
|
tech: user.tech
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def owns_channel?(%Accounts.User{} = user, %Channel{} = channel) do
|
|
|
|
user.id == channel.user_id
|
|
|
|
end
|
|
|
|
|
|
|
|
defp youtube_id(%Video{url: url}) do
|
|
|
|
url = URI.parse(url)
|
|
|
|
root = ".#{url.host}"
|
|
|
|
|
|
|
|
cond do
|
|
|
|
root |> String.ends_with?(".youtube.com") ->
|
|
|
|
%{"v" => id} = URI.decode_query(url.query)
|
|
|
|
id
|
|
|
|
|
|
|
|
root |> String.ends_with?(".youtu.be") ->
|
|
|
|
"/" <> id = url.path
|
|
|
|
id
|
|
|
|
|
|
|
|
true ->
|
|
|
|
:not_found
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def player_type(%Video{type: :livestream}), do: "application/x-mpegURL"
|
|
|
|
|
|
|
|
def player_type(%Video{} = video) do
|
|
|
|
case youtube_id(video) do
|
|
|
|
:not_found -> "video/mp4"
|
|
|
|
_ -> "video/youtube"
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-03-11 21:55:17 +03:00
|
|
|
def get_video!(id), do: Repo.get!(Video, id)
|
2024-02-29 22:31:42 +03:00
|
|
|
|
|
|
|
def update_video(%Video{} = video, attrs) do
|
|
|
|
video
|
|
|
|
|> Video.changeset(attrs)
|
|
|
|
|> Repo.update()
|
|
|
|
end
|
|
|
|
|
|
|
|
defp order_by_inserted(%Ecto.Query{} = query, direction) when direction in [:asc, :desc] do
|
|
|
|
from(s in query, order_by: [{^direction, s.inserted_at}])
|
|
|
|
end
|
|
|
|
|
|
|
|
defp topic(user_id) when is_integer(user_id), do: "channel:#{user_id}"
|
|
|
|
|
|
|
|
def topic_livestreams(), do: "livestreams"
|
2024-03-05 06:30:45 +03:00
|
|
|
|
|
|
|
def list_subtitles(%Video{} = video) do
|
|
|
|
from(s in Subtitle, where: s.video_id == ^video.id, order_by: [asc: s.start])
|
2024-03-11 21:55:17 +03:00
|
|
|
|> Repo.all()
|
2024-03-05 06:30:45 +03:00
|
|
|
end
|
|
|
|
|
|
|
|
def get_subtitle!(id), do: Repo.get!(Subtitle, id)
|
|
|
|
|
|
|
|
def create_subtitle(%Video{} = video, attrs \\ %{}) do
|
|
|
|
%Subtitle{video_id: video.id}
|
|
|
|
|> Subtitle.changeset(attrs)
|
|
|
|
|> Repo.insert()
|
|
|
|
end
|
|
|
|
|
|
|
|
def update_subtitle(%Subtitle{} = subtitle, attrs) do
|
|
|
|
subtitle
|
|
|
|
|> Subtitle.changeset(attrs)
|
|
|
|
|> Repo.update()
|
|
|
|
end
|
|
|
|
|
|
|
|
def delete_subtitle(%Subtitle{} = subtitle) do
|
|
|
|
Repo.delete(subtitle)
|
|
|
|
end
|
|
|
|
|
|
|
|
def change_subtitle(%Subtitle{} = subtitle, attrs \\ %{}) do
|
|
|
|
Subtitle.changeset(subtitle, attrs)
|
|
|
|
end
|
2024-03-11 21:57:20 +03:00
|
|
|
|
|
|
|
def save_subtitles(data) do
|
|
|
|
{:ok, subs} = Jason.decode(data)
|
|
|
|
|
|
|
|
Enum.each(subs, fn sub ->
|
|
|
|
%Subtitle{id: sub["id"]}
|
|
|
|
|> Subtitle.changeset(%{
|
|
|
|
start: sub["start"],
|
|
|
|
end: sub["end"],
|
|
|
|
body: sub["body"]
|
|
|
|
})
|
|
|
|
|> Repo.update!()
|
|
|
|
end)
|
|
|
|
end
|
2024-02-29 22:31:42 +03:00
|
|
|
end
|