diff --git a/.dockerignore b/.dockerignore index d2a3330..fd3592b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,4 +29,5 @@ node_modules /.local /priv/cache *.patch -/.fly \ No newline at end of file +/.fly +/xref* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 19982f7..28ece9f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ npm-debug.log /.local /priv/cache *.patch -/.fly \ No newline at end of file +/.fly +/xref* \ No newline at end of file diff --git a/.iex.exs b/.iex.exs index 536ad2e..39dc2c8 100644 --- a/.iex.exs +++ b/.iex.exs @@ -4,7 +4,3 @@ import Ecto.Changeset alias Algora.{Admin, Accounts, Library, Repo, Storage, Cache, ML, Shows} IEx.configure(inspect: [charlists: :as_lists]) - -if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do - ExSync.register_group_leader() -end diff --git a/assets/css/app.css b/assets/css/app.css index ed0fd2a..b79627e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -20,6 +20,13 @@ } } +.font-display { + font-family: "Space Grotesk", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; +} + .scrollbar-thin::-webkit-scrollbar { width: 0.25em; height: 0.25em; diff --git a/assets/js/app.ts b/assets/js/app.ts index 93e62ab..72191d1 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -152,8 +152,11 @@ const Hooks = { this.playerId = this.el.id; + // TODO: remove this once we have a better way to handle autoplay + const autoplay = this.el.id.startsWith("analytics-") ? false : "any"; + this.player = videojs(this.el, { - autoplay: "any", + autoplay: autoplay, liveui: true, html5: { vhs: { diff --git a/config/test.exs b/config/test.exs index 4eeaf15..1e0b271 100644 --- a/config/test.exs +++ b/config/test.exs @@ -6,16 +6,15 @@ import Config # to provide built-in test partitioning in CI environment. # Run `mix help test` for more information. config :algora, Algora.Repo, - username: "postgres", - password: "postgres", - database: "algora_test#{System.get_env("MIX_TEST_PARTITION")}", - hostname: "localhost", + url: System.get_env("TEST_DATABASE_URL"), + show_sensitive_data_on_connection_error: true, pool: Ecto.Adapters.SQL.Sandbox, pool_size: 10 config :algora, Algora.Repo.Local, - url: System.get_env("DATABASE_URL"), + url: System.get_env("TEST_DATABASE_URL"), show_sensitive_data_on_connection_error: true, + pool: Ecto.Adapters.SQL.Sandbox, pool_size: 10, priv: "priv/repo" @@ -27,7 +26,7 @@ config :algora, AlgoraWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], server: false - config :algora, AlgoraWeb.Embed.Endpoint, +config :algora, AlgoraWeb.Embed.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4003], server: false diff --git a/lib/algora/admin/generate_blurb_thumbnails.ex b/lib/algora/admin/generate_blurb_thumbnails.ex new file mode 100644 index 0000000..495d1ed --- /dev/null +++ b/lib/algora/admin/generate_blurb_thumbnails.ex @@ -0,0 +1,132 @@ +defmodule Algora.Admin.GenerateBlurbThumbnails do + import Ecto.Query + alias Algora.Ads.ProductReview + alias Algora.{Clipper, Repo} + + def run do + for product_review <- fetch_product_reviews() do + :ok = process_product_review(product_review) + end + end + + defp fetch_product_reviews do + Repo.all( + from pr in ProductReview, + join: v in assoc(pr, :video), + preload: [video: v] + ) + end + + defp process_product_review(product_review) do + with {:ok, thumbnail_path} <- create_thumbnail(product_review), + {:ok, thumbnail_url} <- upload_thumbnail(thumbnail_path), + {:ok, _updated_review} <- update_product_review(product_review, thumbnail_url) do + IO.puts("Successfully processed ProductReview #{product_review.id}") + else + {:error, step, reason} -> + IO.puts( + "Error processing ProductReview #{product_review.id} at step #{step}: #{inspect(reason)}" + ) + + :error + end + end + + defp create_thumbnail(product_review) do + video_path = generate_thumbnails(product_review) + output_path = "/tmp/thumbnail_#{product_review.id}.jpg" + + case Thumbnex.create_thumbnail(video_path, output_path, time: product_review.clip_from) do + :ok -> {:ok, output_path} + error -> {:error, :create_thumbnail, error} + end + end + + defp upload_thumbnail(file_path) do + uuid = Ecto.UUID.generate() + remote_path = "blurbs/#{uuid}.jpg" + + case Algora.Storage.upload_from_filename(file_path, remote_path, fn _ -> nil end, + content_type: "image/jpeg" + ) do + {:ok, _} -> + bucket = Algora.config([:buckets, :media]) + %{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{}) + thumbnail_url = "#{scheme}#{host}/#{bucket}/#{remote_path}" + {:ok, thumbnail_url} + + error -> + {:error, :upload_thumbnail, error} + end + end + + defp update_product_review(product_review, thumbnail_url) do + product_review + |> Ecto.Changeset.change(thumbnail_url: thumbnail_url) + |> Repo.update() + |> case do + {:ok, updated_review} -> {:ok, updated_review} + error -> {:error, :update_product_review, error} + end + end + + def generate_thumbnails(product_review) do + # Generate clipped manifest + %{playlist: playlist, ss: _ss} = + Clipper.clip(product_review.video, product_review.clip_from, product_review.clip_to) + + # Find MediaInit and first Segment + {init_tag, segment_tag} = find_init_and_segment(playlist.timeline) + + # Download and concatenate files + video_path = download_and_concatenate(init_tag, segment_tag, product_review.video) + + video_path + end + + defp find_init_and_segment(timeline) do + init_tag = Enum.find(timeline, &match?(%ExM3U8.Tags.MediaInit{}, &1)) + segments = Enum.filter(timeline, &match?(%ExM3U8.Tags.Segment{}, &1)) + + segment_tag = + case segments do + [_, second | _] -> second + [first | _] -> first + _ -> nil + end + + {init_tag, segment_tag} + end + + defp download_and_concatenate(init_tag, segment_tag, video) do + temp_dir = Path.join(System.tmp_dir!(), video.uuid) + File.mkdir_p!(temp_dir) + + output_path = Path.join(temp_dir, "output.mp4") + + init_url = maybe_to_absolute(init_tag.uri, video) + segment_url = maybe_to_absolute(segment_tag.uri, video) + + init_path = download_file(init_url, Path.join(temp_dir, "init.mp4")) + segment_path = download_file(segment_url, Path.join(temp_dir, "segment.m4s")) + + ffmpeg_command = "ffmpeg -y -i \"concat:#{init_path}|#{segment_path}\" -c copy #{output_path}" + System.cmd("sh", ["-c", ffmpeg_command]) + + output_path + end + + defp download_file(url, path) do + {:ok, %{body: body}} = HTTPoison.get(url) + File.write!(path, body) + path + end + + defp maybe_to_absolute(uri, video) do + if URI.parse(uri).scheme do + uri + else + Algora.Clipper.to_absolute(:video, video.uuid, uri) + end + end +end diff --git a/lib/algora/ads.ex b/lib/algora/ads.ex new file mode 100644 index 0000000..5a9fd32 --- /dev/null +++ b/lib/algora/ads.ex @@ -0,0 +1,279 @@ +defmodule Algora.Ads do + @moduledoc """ + The Ads context. + """ + + import Ecto.Query, warn: false + alias Algora.Repo + + alias Algora.Ads.{Ad, Visit, Impression, Events, ContentMetrics, Appearance, ProductReview} + + @pubsub Algora.PubSub + + def display_duration, do: :timer.minutes(2) + def rotation_interval, do: :timer.minutes(30) + + def unsubscribe_to_ads() do + Phoenix.PubSub.unsubscribe(@pubsub, topic()) + end + + def subscribe_to_ads() do + Phoenix.PubSub.subscribe(@pubsub, topic()) + end + + defp topic(), do: "ads" + + defp broadcast!(topic, msg) do + Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg}) + end + + def broadcast_ad_created!(ad) do + broadcast!(topic(), %Events.AdCreated{ad: ad}) + end + + def broadcast_ad_updated!(ad) do + broadcast!(topic(), %Events.AdUpdated{ad: ad}) + end + + def broadcast_ad_deleted!(ad) do + broadcast!(topic(), %Events.AdDeleted{ad: ad}) + end + + @doc """ + Returns the list of ads. + + ## Examples + + iex> list_ads() + [%Ad{}, ...] + + """ + + def list_ads do + Ad + |> order_by(asc: :id) + |> Repo.all() + end + + def list_active_ads do + Ad + |> where(verified: true, status: :active) + |> order_by(asc: :id) + |> Repo.all() + end + + @doc """ + Gets a single ad. + + Raises `Ecto.NoResultsError` if the Ad does not exist. + + ## Examples + + iex> get_ad!(123) + %Ad{} + + iex> get_ad!(456) + ** (Ecto.NoResultsError) + + """ + def get_ad!(id), do: Repo.get!(Ad, id) + + def get_ad_by_slug!(slug) do + Repo.get_by!(Ad, slug: slug) + end + + @doc """ + Creates a ad. + + ## Examples + + iex> create_ad(%{field: value}) + {:ok, %Ad{}} + + iex> create_ad(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_ad(attrs \\ %{}) do + %Ad{status: :active, verified: true} + |> Ad.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a ad. + + ## Examples + + iex> update_ad(ad, %{field: new_value}) + {:ok, %Ad{}} + + iex> update_ad(ad, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_ad(%Ad{} = ad, attrs) do + ad + |> Ad.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a ad. + + ## Examples + + iex> delete_ad(ad) + {:ok, %Ad{}} + + iex> delete_ad(ad) + {:error, %Ecto.Changeset{}} + + """ + def delete_ad(%Ad{} = ad) do + Repo.delete(ad) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking ad changes. + + ## Examples + + iex> change_ad(ad) + %Ecto.Changeset{data: %Ad{}} + + """ + def change_ad(%Ad{} = ad, attrs \\ %{}) do + Ad.changeset(ad, attrs) + end + + def track_visit(attrs \\ %{}) do + %Visit{} + |> Visit.changeset(attrs) + |> Repo.insert() + end + + def track_impressions(attrs \\ %{}) do + %Impression{} + |> Impression.changeset(attrs) + |> Repo.insert() + end + + def get_current_index(ads) do + :os.system_time(:millisecond) + |> div(rotation_interval()) + |> rem(length(ads)) + end + + def rotate_ads(ads, index \\ nil) do + index = index || get_current_index(ads) + Enum.concat(Enum.drop(ads, index), Enum.take(ads, index)) + end + + def next_slot(time \\ DateTime.utc_now()) do + time + |> DateTime.truncate(:millisecond) + |> DateTime.add(ms_until_next_slot(time), :millisecond) + end + + def time_until_next_slot(time \\ DateTime.utc_now()) do + DateTime.diff(next_slot(time), time, :millisecond) + end + + defp ms_until_next_slot(time) do + rotation_interval() - rem(DateTime.to_unix(time, :millisecond), rotation_interval()) + end + + def list_content_metrics do + Repo.all(ContentMetrics) + |> Repo.preload(video: [:appearances, :product_reviews]) + end + + def create_content_metrics(attrs \\ %{}) do + %ContentMetrics{} + |> ContentMetrics.changeset(attrs) + |> Repo.insert() + end + + def change_content_metrics(%ContentMetrics{} = content_metrics, attrs \\ %{}) do + ContentMetrics.changeset(content_metrics, attrs) + end + + @doc """ + Gets a single content_metrics. + + Raises `Ecto.NoResultsError` if the ContentMetrics does not exist. + + ## Examples + + iex> get_content_metrics!(123) + %ContentMetrics{} + + iex> get_content_metrics!(456) + ** (Ecto.NoResultsError) + + """ + def get_content_metrics!(id) do + Repo.get!(ContentMetrics, id) + end + + @doc """ + Updates a content_metrics. + + ## Examples + + iex> update_content_metrics(content_metrics, %{field: new_value}) + {:ok, %ContentMetrics{}} + + iex> update_content_metrics(content_metrics, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_content_metrics(%ContentMetrics{} = content_metrics, attrs) do + content_metrics + |> ContentMetrics.changeset(attrs) + |> Repo.update() + end + + def create_appearance(attrs \\ %{}) do + %Appearance{} + |> Appearance.changeset(attrs) + |> Repo.insert() + end + + def change_appearance(%Appearance{} = appearance, attrs \\ %{}) do + Appearance.changeset(appearance, attrs) + end + + def create_product_review(attrs \\ %{}) do + %ProductReview{} + |> ProductReview.changeset(attrs) + |> Repo.insert() + end + + def change_product_review(%ProductReview{} = product_review, attrs \\ %{}) do + ProductReview.changeset(product_review, attrs) + end + + def list_appearances(ad) do + Appearance + |> where(ad_id: ^ad.id) + |> preload(video: :user) + |> Repo.all() + end + + def list_product_reviews(ad) do + ProductReview + |> where(ad_id: ^ad.id) + |> preload(video: :user) + |> Repo.all() + end + + def list_content_metrics(appearances) do + video_ids = Enum.map(appearances, & &1.video_id) + + ContentMetrics + |> where([cm], cm.video_id in ^video_ids) + |> Repo.all() + end +end diff --git a/lib/algora/ads/ad.ex b/lib/algora/ads/ad.ex new file mode 100644 index 0000000..e78a53c --- /dev/null +++ b/lib/algora/ads/ad.ex @@ -0,0 +1,60 @@ +defmodule Algora.Ads.Ad do + use Ecto.Schema + import Ecto.Changeset + + schema "ads" do + field :slug, :string + field :name, :string + field :status, Ecto.Enum, values: [:inactive, :active] + field :verified, :boolean, default: false + field :website_url, :string + field :composite_asset_url, :string + field :asset_url, :string + field :logo_url, :string + field :qrcode_url, :string + field :og_image_url, :string + field :start_date, :naive_datetime + field :end_date, :naive_datetime + field :total_budget, :integer + field :daily_budget, :integer + field :tech_stack, {:array, :string} + field :user_id, :id + field :border_color, :string + field :scheduled_for, :utc_datetime, virtual: true + + timestamps() + end + + @doc false + def changeset(ad, attrs) do + ad + |> cast(attrs, [ + :slug, + :name, + :verified, + :website_url, + :composite_asset_url, + :asset_url, + :logo_url, + :qrcode_url, + :og_image_url, + :start_date, + :end_date, + :total_budget, + :daily_budget, + :tech_stack, + :status, + :border_color + ]) + |> validate_required([ + :slug, + :website_url, + :composite_asset_url, + :border_color + ]) + |> validate_format(:border_color, ~r/^#([0-9A-F]{3}){1,2}$/i, + message: "must be a valid hex color code" + ) + |> unique_constraint(:slug) + end +end diff --git a/lib/algora/ads/appearance.ex b/lib/algora/ads/appearance.ex new file mode 100644 index 0000000..5b76d12 --- /dev/null +++ b/lib/algora/ads/appearance.ex @@ -0,0 +1,23 @@ +defmodule Algora.Ads.Appearance do + use Ecto.Schema + import Ecto.Changeset + alias Algora.Library.Video + alias Algora.Ads.Ad + + schema "ad_appearances" do + field :airtime, :integer + + belongs_to :ad, Ad + belongs_to :video, Video + + timestamps() + end + + def changeset(appearance, attrs) do + appearance + |> cast(attrs, [:airtime, :ad_id, :video_id]) + |> validate_required([:airtime, :ad_id, :video_id]) + |> foreign_key_constraint(:ad_id) + |> foreign_key_constraint(:video_id) + end +end diff --git a/lib/algora/ads/content_metrics.ex b/lib/algora/ads/content_metrics.ex new file mode 100644 index 0000000..f50aebd --- /dev/null +++ b/lib/algora/ads/content_metrics.ex @@ -0,0 +1,37 @@ +defmodule Algora.Ads.ContentMetrics do + use Ecto.Schema + import Ecto.Changeset + alias Algora.Library.Video + + schema "content_metrics" do + field :algora_stream_url, :string + field :twitch_stream_url, :string + field :youtube_video_url, :string + field :twitter_video_url, :string + field :twitch_avg_concurrent_viewers, :integer + field :twitch_views, :integer + field :youtube_views, :integer + field :twitter_views, :integer + + belongs_to :video, Video + + timestamps() + end + + def changeset(content_metrics, attrs) do + content_metrics + |> cast(attrs, [ + :algora_stream_url, + :twitch_stream_url, + :youtube_video_url, + :twitter_video_url, + :twitch_avg_concurrent_viewers, + :twitch_views, + :youtube_views, + :twitter_views, + :video_id + ]) + |> validate_required([:video_id]) + |> foreign_key_constraint(:video_id) + end +end diff --git a/lib/algora/ads/events.ex b/lib/algora/ads/events.ex new file mode 100644 index 0000000..823b3a6 --- /dev/null +++ b/lib/algora/ads/events.ex @@ -0,0 +1,13 @@ +defmodule Algora.Ads.Events do + defmodule AdCreated do + defstruct ad: nil + end + + defmodule AdUpdated do + defstruct ad: nil + end + + defmodule AdDeleted do + defstruct ad: nil + end +end diff --git a/lib/algora/ads/impression.ex b/lib/algora/ads/impression.ex new file mode 100644 index 0000000..2788a87 --- /dev/null +++ b/lib/algora/ads/impression.ex @@ -0,0 +1,20 @@ +defmodule Algora.Ads.Impression do + use Ecto.Schema + import Ecto.Changeset + + schema "ad_impressions" do + field :duration, :integer + field :viewers_count, :integer + field :ad_id, :id + field :video_id, :id + + timestamps() + end + + @doc false + def changeset(impression, attrs) do + impression + |> cast(attrs, [:duration, :viewers_count, :ad_id, :video_id]) + |> validate_required([:duration, :viewers_count, :ad_id]) + end +end diff --git a/lib/algora/ads/product_review.ex b/lib/algora/ads/product_review.ex new file mode 100644 index 0000000..41e576f --- /dev/null +++ b/lib/algora/ads/product_review.ex @@ -0,0 +1,25 @@ +defmodule Algora.Ads.ProductReview do + use Ecto.Schema + import Ecto.Changeset + alias Algora.Library.Video + alias Algora.Ads.Ad + + schema "product_reviews" do + field :clip_from, :integer + field :clip_to, :integer + field :thumbnail_url, :string + + belongs_to :ad, Ad + belongs_to :video, Video + + timestamps() + end + + def changeset(product_review, attrs) do + product_review + |> cast(attrs, [:clip_from, :clip_to, :thumbnail_url, :ad_id, :video_id]) + |> validate_required([:clip_from, :clip_to, :ad_id, :video_id]) + |> foreign_key_constraint(:ad_id) + |> foreign_key_constraint(:video_id) + end +end diff --git a/lib/algora/ads/visit.ex b/lib/algora/ads/visit.ex new file mode 100644 index 0000000..cd5e0b9 --- /dev/null +++ b/lib/algora/ads/visit.ex @@ -0,0 +1,18 @@ +defmodule Algora.Ads.Visit do + use Ecto.Schema + import Ecto.Changeset + + schema "ad_visits" do + field :ad_id, :id + field :video_id, :id + + timestamps() + end + + @doc false + def changeset(visit, attrs) do + visit + |> cast(attrs, [:ad_id, :video_id]) + |> validate_required([:ad_id]) + end +end diff --git a/lib/algora/clipper.ex b/lib/algora/clipper.ex index 36e9fa2..72147a6 100644 --- a/lib/algora/clipper.ex +++ b/lib/algora/clipper.ex @@ -3,10 +3,10 @@ defmodule Algora.Clipper do defp bucket(), do: Algora.config([:buckets, :media]) - defp to_absolute(:video, uuid, uri), + def to_absolute(:video, uuid, uri), do: "#{Storage.endpoint_url()}/#{bucket()}/#{uuid}/#{uri}" - defp to_absolute(:clip, uuid, uri), + def to_absolute(:clip, uuid, uri), do: "#{Storage.endpoint_url()}/#{bucket()}/clips/#{uuid}/#{uri}" def clip(video, from, to) do diff --git a/lib/algora/contact.ex b/lib/algora/contact.ex new file mode 100644 index 0000000..006a818 --- /dev/null +++ b/lib/algora/contact.ex @@ -0,0 +1,104 @@ +defmodule Algora.Contact do + @moduledoc """ + The Contact context. + """ + + import Ecto.Query, warn: false + alias Algora.Repo + + alias Algora.Contact.Info + + @doc """ + Returns the list of contact_info. + + ## Examples + + iex> list_contact_info() + [%Info{}, ...] + + """ + def list_contact_info do + Repo.all(Info) + end + + @doc """ + Gets a single info. + + Raises `Ecto.NoResultsError` if the Info does not exist. + + ## Examples + + iex> get_info!(123) + %Info{} + + iex> get_info!(456) + ** (Ecto.NoResultsError) + + """ + def get_info!(id), do: Repo.get!(Info, id) + + @doc """ + Creates a info. + + ## Examples + + iex> create_info(%{field: value}) + {:ok, %Info{}} + + iex> create_info(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_info(attrs \\ %{}) do + %Info{} + |> Info.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a info. + + ## Examples + + iex> update_info(info, %{field: new_value}) + {:ok, %Info{}} + + iex> update_info(info, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_info(%Info{} = info, attrs) do + info + |> Info.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a info. + + ## Examples + + iex> delete_info(info) + {:ok, %Info{}} + + iex> delete_info(info) + {:error, %Ecto.Changeset{}} + + """ + def delete_info(%Info{} = info) do + Repo.delete(info) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking info changes. + + ## Examples + + iex> change_info(info) + %Ecto.Changeset{data: %Info{}} + + """ + def change_info(%Info{} = info, attrs \\ %{}) do + Info.changeset(info, attrs) + end +end diff --git a/lib/algora/contact/info.ex b/lib/algora/contact/info.ex new file mode 100644 index 0000000..53101ee --- /dev/null +++ b/lib/algora/contact/info.ex @@ -0,0 +1,20 @@ +defmodule Algora.Contact.Info do + use Ecto.Schema + import Ecto.Changeset + + schema "contact_info" do + field :email, :string + field :website_url, :string + field :revenue, :string + field :company_location, :string + + timestamps() + end + + @doc false + def changeset(info, attrs) do + info + |> cast(attrs, [:email, :website_url, :revenue, :company_location]) + |> validate_required([:email]) + end +end diff --git a/lib/algora/library.ex b/lib/algora/library.ex index 6b2a361..dd5f5cc 100644 --- a/lib/algora/library.ex +++ b/lib/algora/library.ex @@ -355,6 +355,23 @@ defmodule Algora.Library do to_hhmmss(trunc(duration)) end + def from_hhmmss(timestamp) do + case String.split(timestamp, ":") do + [hours, minutes, seconds] -> + String.to_integer(hours) * 3600 + String.to_integer(minutes) * 60 + + String.to_integer(seconds) + + [minutes, seconds] -> + String.to_integer(minutes) * 60 + String.to_integer(seconds) + + [seconds] -> + String.to_integer(seconds) + + _ -> + raise ArgumentError, "Invalid time format. Expected hh:mm:ss" + end + end + def unsubscribe_to_channel(%Channel{} = channel) do Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id)) end @@ -557,6 +574,21 @@ defmodule Algora.Library do |> Repo.all() end + def list_all_videos(limit \\ 100) do + from(v in Video, + join: u in User, + on: v.user_id == u.id, + limit: ^limit, + select_merge: %{ + channel_handle: u.handle, + channel_name: u.name, + channel_avatar_url: u.avatar_url + } + ) + |> order_by_inserted(:desc) + |> Repo.all() + end + def list_livestreams(limit \\ 100) do from(v in Video, join: u in User, diff --git a/lib/algora/library/video.ex b/lib/algora/library/video.ex index c0cc005..5232f39 100644 --- a/lib/algora/library/video.ex +++ b/lib/algora/library/video.ex @@ -7,6 +7,7 @@ defmodule Algora.Library.Video do alias Algora.Library.Video alias Algora.Shows.Show alias Algora.Chat.Message + alias Algora.Ads.{Appearance, ContentMetrics, ProductReview} @type t() :: %__MODULE__{} @@ -37,6 +38,9 @@ defmodule Algora.Library.Video do belongs_to :transmuxed_from, Video has_many :messages, Message + has_many :appearances, Appearance + has_many :content_metrics, ContentMetrics + has_many :product_reviews, ProductReview timestamps() end diff --git a/lib/algora_web/components/layouts/root.html.heex b/lib/algora_web/components/layouts/root.html.heex index d3bc728..8271324 100644 --- a/lib/algora_web/components/layouts/root.html.heex +++ b/lib/algora_web/components/layouts/root.html.heex @@ -54,6 +54,12 @@ + + +
diff --git a/lib/algora_web/components/layouts/root_embed.html.heex b/lib/algora_web/components/layouts/root_embed.html.heex index ff20c45..efa8e64 100644 --- a/lib/algora_web/components/layouts/root_embed.html.heex +++ b/lib/algora_web/components/layouts/root_embed.html.heex @@ -54,6 +54,12 @@ + + + <%= @inner_content %> diff --git a/lib/algora_web/components/tech_icon.ex b/lib/algora_web/components/tech_icon.ex new file mode 100644 index 0000000..2df699a --- /dev/null +++ b/lib/algora_web/components/tech_icon.ex @@ -0,0 +1,40 @@ +defmodule AlgoraWeb.Components.TechIcon do + use Phoenix.Component + + def tech_icon(assigns) do + ~H""" + + <%= case @tech do %> + <% "TypeScript" -> %> + + + + + + <% "PHP" -> %> + + + + + + + <% _ -> %> + + + + + <% end %> + + """ + end +end diff --git a/lib/algora_web/controllers/ad_redirect_controller.ex b/lib/algora_web/controllers/ad_redirect_controller.ex new file mode 100644 index 0000000..c7748e3 --- /dev/null +++ b/lib/algora_web/controllers/ad_redirect_controller.ex @@ -0,0 +1,15 @@ +defmodule AlgoraWeb.AdRedirectController do + use AlgoraWeb, :controller + alias Algora.Ads + + def go(conn, %{"slug" => slug}) do + ad = Ads.get_ad_by_slug!(slug) + + ## TODO: log errors + Ads.track_visit(%{ad_id: ad.id}) + + conn + |> put_status(:found) + |> redirect(external: ad.website_url) + end +end diff --git a/lib/algora_web/live/ad_live/analytics.ex b/lib/algora_web/live/ad_live/analytics.ex new file mode 100644 index 0000000..56d4527 --- /dev/null +++ b/lib/algora_web/live/ad_live/analytics.ex @@ -0,0 +1,174 @@ +defmodule AlgoraWeb.AdLive.Analytics do + use AlgoraWeb, :live_view + alias AlgoraWeb.Components.TechIcon + alias AlgoraWeb.RTMPDestinationIconComponent + + alias Algora.{Ads, Library} + alias AlgoraWeb.PlayerComponent + + @impl true + def mount(%{"slug" => slug}, _session, socket) do + ad = Ads.get_ad_by_slug!(slug) + + %{ + stats: stats, + appearances: appearances, + product_reviews: product_reviews, + product_review: product_review + } = fetch_ad_stats(ad) + + blurb = + if product_review, + do: %{ + video: Library.get_video!(product_review.video_id), + current_time: product_review.clip_from + } + + if connected?(socket) do + if blurb do + send_update(PlayerComponent, %{ + id: "analytics-player", + video: blurb.video, + current_user: socket.assigns.current_user, + current_time: blurb.current_time + }) + end + end + + {:ok, + socket + |> assign(ad: ad) + |> assign(stats: stats) + |> assign(appearances: appearances) + |> assign(product_review: product_review) + |> assign(product_reviews: product_reviews) + |> assign(blurb: blurb)} + end + + @impl true + def handle_info(_arg, socket) do + {:noreply, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + socket = + cond do + socket.assigns.ad.og_image_url -> + assign(socket, :page_image, socket.assigns.ad.og_image_url) + + socket.assigns.product_review -> + assign(socket, :page_image, socket.assigns.product_review.thumbnail_url) + + true -> + socket + end + + {:noreply, + socket + |> assign(:page_title, socket.assigns.ad.name) + |> assign( + :page_description, + "Discover the appearances of #{socket.assigns.ad.name} in livestreams and videos" + )} + end + + defp fetch_ad_stats(ad) do + appearances = Ads.list_appearances(ad) + content_metrics = Ads.list_content_metrics(appearances) + + product_reviews = + Ads.list_product_reviews(ad) |> Enum.sort_by(&(&1.clip_to - &1.clip_from), :desc) + + twitch_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.twitch_views end) + youtube_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.youtube_views end) + twitter_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.twitter_views end) + + tech_stack_data = + appearances + |> group_data_by_tech_stack(content_metrics) + |> Enum.sort_by(fn {_, d} -> d.views end, :desc) + + product_review = List.first(product_reviews) + + views = + %{ + "Twitch" => twitch_views, + "YouTube" => youtube_views, + "Twitter" => twitter_views + } + |> Enum.sort_by(fn {_, v} -> v end, :desc) + + %{ + stats: %{ + views: views, + total_views: twitch_views + youtube_views + twitter_views, + airtime: calculate_total_airtime(appearances), + streams: length(appearances), + creators: length(Enum.uniq_by(appearances, & &1.video.user.id)), + tech_stack_data: tech_stack_data + }, + appearances: appearances, + product_reviews: product_reviews, + product_review: product_review + } + end + + defp group_data_by_tech_stack(appearances, content_metrics) do + appearances + |> Enum.zip(content_metrics) + |> Enum.reduce(%{}, fn {appearance, metrics}, acc -> + tech_stack = get_tech_stack(appearance.video.user.id) + total_views = metrics.twitch_views + metrics.youtube_views + metrics.twitter_views + creator = appearance.video.user + + Map.update(acc, tech_stack, %{views: total_views, creators: [creator]}, fn existing -> + %{ + views: existing.views + total_views, + creators: [creator | existing.creators] |> Enum.uniq() + } + end) + end) + end + + # TODO: This is a hack, we need to get the tech stack from the user's profile + defp get_tech_stack(user_id) do + case user_id do + 7 -> "TypeScript" + 109 -> "TypeScript" + 307 -> "PHP" + _ -> "Other" + end + end + + defp calculate_total_airtime(appearances) do + appearances + |> Enum.reduce(0, fn appearance, acc -> acc + appearance.airtime end) + |> format_duration() + end + + defp format_duration(seconds) do + hours = div(seconds, 3600) + minutes = div(rem(seconds, 3600), 60) + remaining_seconds = rem(seconds, 60) + + cond do + hours > 0 -> "#{hours}h #{minutes}m #{remaining_seconds}s" + minutes > 0 -> "#{minutes}m #{remaining_seconds}s" + true -> "#{remaining_seconds}s" + end + end + + defp format_number(number) when number >= 1_000_000 do + :io_lib.format("~.1fM", [number / 1_000_000]) |> to_string() + end + + defp format_number(number) when number >= 1_000 do + :io_lib.format("~.1fK", [number / 1_000]) |> to_string() + end + + defp format_number(number), do: to_string(number) + + defp tech_icon(assigns), do: TechIcon.tech_icon(assigns) + defp source_icon(assigns), do: RTMPDestinationIconComponent.icon(assigns) +end diff --git a/lib/algora_web/live/ad_live/analytics.html.heex b/lib/algora_web/live/ad_live/analytics.html.heex new file mode 100644 index 0000000..a6296d5 --- /dev/null +++ b/lib/algora_web/live/ad_live/analytics.html.heex @@ -0,0 +1,311 @@ +
+
+ + {@ad.website_url} + +
+
+
+
+
+
+

Views

+

<%= format_number(@stats.total_views) %>

+
+
+

Airtime

+

<%= @stats.airtime %>

+
+
+

Streams

+

<%= @stats.streams %>

+
+
+

Creators

+

<%= @stats.creators %>

+
+
+
+
+
+
+

+ Top Sources +

+
+
+ + + + + + + + + <%= for {source, views} <- @stats.views do %> + <%= if views > 0 do %> + + + + + <% end %> + <% end %> + +
+ Source + + Views +
+ <.source_icon icon={source |> String.downcase() |> String.to_atom()} /> + <%= source %> + + <%= format_number(views) %> +
+
+
+
+
+

+ Top Languages +

+
+
+ + + + + + + + + + <%= for {tech, data} <- @stats.tech_stack_data do %> + + + + + + <% end %> + +
+ Language + + Creators + + Views +
+ <.tech_icon tech={tech} /> <%= tech %> + +
+ <%= for creator <- Enum.take(data.creators, 5) do %> + {"Avatar + <% end %> +
+
+ <%= format_number(data.views) %> +
+
+
+
+
+ +
+
+ <.live_component module={PlayerComponent} id="analytics-player" /> +
+ <.link + href={"/#{@blurb.video.channel_handle}/#{@blurb.video.id}"} + class="hidden sm:block w-full max-w-sm p-6 bg-gray-800/40 hover:bg-gray-800/60 overflow-hidden rounded-lg lg:rounded-2xl shadow-inner shadow-white/[10%] lg:border border-white/[15%] hover:border/white/[20%]" + > +
+
+ {@blurb.video.channel_handle} +
+
+
<%= @blurb.video.channel_name %>
+
@<%= @blurb.video.channel_handle %>
+
+
+
<%= @blurb.video.title %>
+
+ Streamed on <%= @blurb.video.inserted_at |> Calendar.strftime("%b %d, %Y") %> +
+ +
+ +
+

+ Appearances +

+
+
+ + + + + + + + + <%= for product_review <- @product_reviews do %> + + + + + <% end %> + +
+ Livestream + + Airtime +
+ <.link + navigate={"/#{product_review.video.user.handle}/#{product_review.video.id}?t=#{product_review.clip_from}"} + class="flex flex-col sm:flex-row items-start sm:items-center gap-2" + > + +
+
+ <%= product_review.video.user.name %> • <%= product_review.video.title %> +
+
+ Streamed on <%= product_review.video.inserted_at + |> Calendar.strftime("%b %d, %Y") %> +
+
+ <%= Library.to_hhmmss(product_review.clip_from) %> - <%= Library.to_hhmmss( + product_review.clip_to + ) %> +
+
+ +
+ <%= format_duration(product_review.clip_to - product_review.clip_from) %> +
+
+
+
+
+ +
+
+
+

+ Your most successful ad campaign + is just a few livestreams away +

+

+ Stand out in front of millions on Twitch, YouTube and X with in-video livestream ads +

+
+ <.link + href="https://cal.com/ioannisflo" + class="rounded-md bg-white px-3.5 py-2.5 text-2xl font-semibold text-gray-900 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" + > + Let's talk + +
+
+
+ +
+
diff --git a/lib/algora_web/live/ad_live/content_live.ex b/lib/algora_web/live/ad_live/content_live.ex new file mode 100644 index 0000000..cedcb8f --- /dev/null +++ b/lib/algora_web/live/ad_live/content_live.ex @@ -0,0 +1,625 @@ +defmodule AlgoraWeb.ContentLive do + use AlgoraWeb, :live_view + + import Ecto.Changeset + + alias Algora.Ads + alias Algora.Ads.{ContentMetrics, Appearance, ProductReview} + alias Algora.Library + alias AlgoraWeb.LayoutComponent + + @impl true + def mount(_params, _session, socket) do + content_metrics = Ads.list_content_metrics() + ads = Ads.list_ads() + videos = Library.list_all_videos(1000) + + {:ok, + socket + |> assign(:ads, ads) + |> assign(:videos, videos) + |> assign(:content_metrics, content_metrics) + |> assign(:new_content_metrics_form, to_form(Ads.change_content_metrics(%ContentMetrics{}))) + |> assign(:new_appearance_form, to_form(Ads.change_appearance(%Appearance{}))) + |> assign(:new_product_review_form, to_form(Ads.change_product_review(%ProductReview{}))) + |> assign(:show_appearance_modal, false) + |> assign(:show_product_review_modal, false) + |> assign(:show_content_metrics_modal, false) + |> assign(:editing_content_metrics, nil) + |> assign(:uploaded_files, []) + |> allow_upload(:product_review_thumbnail, + accept: accept(), + max_file_size: max_file_size() * 1_000_000, + max_entries: 1, + auto_upload: true, + progress: &handle_progress/3 + )} + end + + @impl true + def render(assigns) do + ~H""" +
+ <%= for content_metric <- @content_metrics do %> +
+
+
+
<%= content_metric.video.title %>
+
+ <%= Calendar.strftime(content_metric.video.inserted_at, "%b %d, %Y, %I:%M %p UTC") %> +
+
+
+ <.link href={content_metric.twitch_stream_url} class="flex items-center gap-2"> + + + + <%= content_metric.twitch_avg_concurrent_viewers || 0 %> CCV / <%= content_metric.twitch_views || + 0 %> Views + + <.link href={content_metric.youtube_video_url} class="flex items-center gap-2"> + + + + <%= content_metric.youtube_views || 0 %> Views + + <.link href={content_metric.twitter_video_url} class="flex items-center gap-2"> + + + + <%= content_metric.twitter_views || 0 %> Views + +
+
+ + + + + + + + + + + <%= for ad_id <- Enum.uniq(Enum.map(content_metric.video.appearances, & &1.ad_id) ++ Enum.map(content_metric.video.product_reviews, & &1.ad_id)) do %> + <% ad = Ads.get_ad!(ad_id) %> + + + + + + + <% end %> + +
AdAirtimeBlurbThumbnail
<%= ad.slug %> + <%= Enum.map_join( + Enum.filter(content_metric.video.appearances, &(&1.ad_id == ad_id)), + ", ", + &Library.to_hhmmss(&1.airtime) + ) %> + + <%= Enum.map_join( + Enum.filter(content_metric.video.product_reviews, &(&1.ad_id == ad_id)), + ", ", + &"#{Library.to_hhmmss(&1.clip_from)} - #{Library.to_hhmmss(&1.clip_to)}" + ) %> + + <%= Enum.map_join( + Enum.filter(content_metric.video.product_reviews, &(&1.ad_id == ad_id)), + ", ", + & &1.thumbnail_url + ) %> +
+ +
+ <.button phx-click="open_appearance_modal" phx-value-video_id={content_metric.video_id}> + Add airtime + + <.button + phx-click="open_product_review_modal" + phx-value-video_id={content_metric.video_id} + > + Add blurb + + <.button phx-click="edit_content_metrics" phx-value-id={content_metric.id}> + Edit metrics + +
+
+ <% end %> + +
+ <.button phx-click="open_content_metrics_modal">Add content +
+
+ + <.modal + :if={@show_appearance_modal} + id="appearance-modal" + show + on_cancel={JS.patch(~p"/admin/content")} + > + <.header>Add Airtime + <.simple_form for={@new_appearance_form} phx-submit="save_appearance"> + <%= hidden_input(@new_appearance_form, :video_id) %> + <.input + field={@new_appearance_form[:ad_id]} + type="select" + label="Ad" + prompt="Select an ad" + options={Enum.map(@ads, fn ad -> {ad.slug, ad.id} end)} + /> + <.input field={@new_appearance_form[:airtime]} label="Airtime" placeholder="hh:mm:ss" /> +
+
+ <.button type="submit">Submit + + + + <.modal + :if={@show_product_review_modal} + id="product-review-modal" + show + on_cancel={JS.patch(~p"/admin/content")} + > + <.header>Add Blurb + <.simple_form + for={@new_product_review_form} + phx-change="validate_product_review" + phx-submit="save_product_review" + > + <%= hidden_input(@new_product_review_form, :video_id) %> + <.input + field={@new_product_review_form[:ad_id]} + type="select" + label="Ad" + prompt="Select an ad" + options={Enum.map(@ads, fn ad -> {ad.slug, ad.id} end)} + /> + <.input + field={@new_product_review_form[:clip_from]} + type="text" + label="Clip From" + placeholder="hh:mm:ss" + /> + <.input + field={@new_product_review_form[:clip_to]} + type="text" + label="Clip To" + placeholder="hh:mm:ss" + /> +
+ +
+ <.live_file_input + upload={@uploads.product_review_thumbnail} + class="absolute inset-0 opacity-0 cursor-pointer" + /> + +
+
+
+
+ <%= hidden_input(@new_product_review_form, :thumbnail_url) %> + <%= for err <- upload_errors(@uploads.product_review_thumbnail) do %> +

<%= error_to_string(err) %>

+ <% end %> + <.button type="submit">Submit + + + + <.modal + :if={@show_content_metrics_modal} + id="content-metrics-modal" + show + on_cancel={JS.patch(~p"/admin/content")} + > + <.header>Add content + <.simple_form for={@new_content_metrics_form} phx-submit="save_content_metrics"> + <.input + field={@new_content_metrics_form[:video_id]} + type="select" + label="Video" + options={ + Enum.map(@videos, fn video -> + {"#{video.title} (#{Calendar.strftime(video.inserted_at, "%b %d, %Y, %I:%M %p UTC")})", + video.id} + end) + } + prompt="Select a video" + phx-change="video_selected" + /> + <.input + field={@new_content_metrics_form[:algora_stream_url]} + type="text" + label="Algora URL" + phx-change="url_entered" + phx-debounce="300" + /> +
+ <.input + field={@new_content_metrics_form[:twitch_stream_url]} + type="text" + label="Twitch URL" + /> + <.input + field={@new_content_metrics_form[:youtube_video_url]} + type="text" + label="YouTube URL" + /> + <.input + field={@new_content_metrics_form[:twitter_video_url]} + type="text" + label="Twitter URL" + /> +
+ + <.input + field={@new_content_metrics_form[:twitch_avg_concurrent_viewers]} + type="number" + label="Twitch Average CCV" + /> + +
+ <.input field={@new_content_metrics_form[:twitch_views]} type="number" label="Twitch Views" /> + <.input + field={@new_content_metrics_form[:youtube_views]} + type="number" + label="YouTube Views" + /> + <.input + field={@new_content_metrics_form[:twitter_views]} + type="number" + label="Twitter Views" + /> +
+ + <.button type="submit">Submit + + + + <.modal + :if={@editing_content_metrics} + id="edit-content-metrics-modal" + show + on_cancel={JS.patch(~p"/admin/content")} + > + <.header>Edit metrics + <.simple_form for={@new_content_metrics_form} phx-submit="update_content_metrics"> + <%= hidden_input(@new_content_metrics_form, :id) %> + <.input + field={@new_content_metrics_form[:video_id]} + type="select" + label="Video" + options={ + Enum.map(@videos, fn video -> + {"#{video.title} (#{Calendar.strftime(video.inserted_at, "%b %d, %Y, %I:%M %p UTC")})", + video.id} + end) + } + prompt="Select a video" + phx-change="video_selected" + /> + <.input + field={@new_content_metrics_form[:algora_stream_url]} + type="text" + label="Algora URL" + phx-change="url_entered" + phx-debounce="300" + /> +
+ <.input + field={@new_content_metrics_form[:twitch_stream_url]} + type="text" + label="Twitch URL" + /> + <.input + field={@new_content_metrics_form[:youtube_video_url]} + type="text" + label="YouTube URL" + /> + <.input + field={@new_content_metrics_form[:twitter_video_url]} + type="text" + label="Twitter URL" + /> +
+ + <.input + field={@new_content_metrics_form[:twitch_avg_concurrent_viewers]} + type="number" + label="Twitch Average CCV" + /> + +
+ <.input field={@new_content_metrics_form[:twitch_views]} type="number" label="Twitch Views" /> + <.input + field={@new_content_metrics_form[:youtube_views]} + type="number" + label="YouTube Views" + /> + <.input + field={@new_content_metrics_form[:twitter_views]} + type="number" + label="Twitter Views" + /> +
+ + <.button type="submit">Update + + + """ + end + + @impl true + def handle_event("save_content_metrics", %{"content_metrics" => params}, socket) do + case Ads.create_content_metrics(params) do + {:ok, _content_metrics} -> + content_metrics = Ads.list_content_metrics() + + {:noreply, + socket + |> assign(:content_metrics, content_metrics) + |> assign( + :new_content_metrics_form, + to_form(Ads.change_content_metrics(%ContentMetrics{})) + ) + |> assign(:show_content_metrics_modal, false)} + + {:error, changeset} -> + {:noreply, assign(socket, :new_content_metrics_form, to_form(changeset))} + end + end + + @impl true + def handle_event("save_appearance", %{"appearance" => params}, socket) do + params = Map.update!(params, "airtime", &Library.from_hhmmss/1) + + case Ads.create_appearance(params) do + {:ok, _appearance} -> + content_metrics = Ads.list_content_metrics() + + {:noreply, + socket + |> assign(:content_metrics, content_metrics) + |> assign(:new_appearance_form, to_form(Ads.change_appearance(%Appearance{}))) + |> assign(:show_appearance_modal, false)} + + {:error, changeset} -> + {:noreply, assign(socket, :new_appearance_form, to_form(changeset))} + end + end + + @impl true + def handle_event("validate_product_review", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("save_product_review", %{"product_review" => params}, socket) do + params = Map.update!(params, "clip_from", &Library.from_hhmmss/1) + params = Map.update!(params, "clip_to", &Library.from_hhmmss/1) + + case Ads.create_product_review(params) do + {:ok, _product_review} -> + content_metrics = Ads.list_content_metrics() + + {:noreply, + socket + |> assign(:content_metrics, content_metrics) + |> assign(:new_product_review_form, to_form(Ads.change_product_review(%ProductReview{}))) + |> assign(:show_product_review_modal, false)} + + {:error, changeset} -> + {:noreply, assign(socket, :new_product_review_form, to_form(changeset))} + end + end + + @impl true + def handle_event("open_appearance_modal", %{"video_id" => video_id}, socket) do + {:noreply, + socket + |> assign(:show_appearance_modal, true) + |> assign( + :new_appearance_form, + to_form(Ads.change_appearance(%Appearance{video_id: String.to_integer(video_id)})) + )} + end + + @impl true + def handle_event("open_product_review_modal", %{"video_id" => video_id}, socket) do + {:noreply, + socket + |> assign(:show_product_review_modal, true) + |> assign( + :new_product_review_form, + to_form(Ads.change_product_review(%ProductReview{video_id: String.to_integer(video_id)})) + )} + end + + @impl true + def handle_event("open_content_metrics_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_content_metrics_modal, true) + |> assign(:new_content_metrics_form, to_form(Ads.change_content_metrics(%ContentMetrics{})))} + end + + @impl true + def handle_event("video_selected", %{"content_metrics" => %{"video_id" => video_id}}, socket) do + video = + if video_id != "", + do: Enum.find(socket.assigns.videos, &(&1.id == String.to_integer(video_id))) + + url = + if video, do: "#{AlgoraWeb.Endpoint.url()}/#{video.channel_handle}/#{video_id}", else: "" + + {:noreply, + socket + |> assign( + :new_content_metrics_form, + to_form( + Ads.change_content_metrics(%ContentMetrics{video_id: video_id, algora_stream_url: url}) + ) + )} + end + + @impl true + def handle_event("url_entered", %{"content_metrics" => %{"algora_stream_url" => url}}, socket) do + video = + Enum.find( + socket.assigns.videos, + &(url == "#{AlgoraWeb.Endpoint.url()}/#{&1.channel_handle}/#{&1.id}") + ) + + video_id = if video, do: video.id, else: nil + + {:noreply, + socket + |> assign( + :new_content_metrics_form, + to_form( + Ads.change_content_metrics(%ContentMetrics{video_id: video_id, algora_stream_url: url}) + ) + )} + end + + @impl true + def handle_event("edit_content_metrics", %{"id" => id}, socket) do + content_metrics = Ads.get_content_metrics!(id) + changeset = Ads.change_content_metrics(content_metrics) + + {:noreply, + socket + |> assign(:editing_content_metrics, content_metrics) + |> assign(:new_content_metrics_form, to_form(changeset)) + |> assign(:show_content_metrics_modal, true)} + end + + @impl true + def handle_event("update_content_metrics", %{"content_metrics" => params}, socket) do + case Ads.update_content_metrics(socket.assigns.editing_content_metrics, params) do + {:ok, _updated_content_metrics} -> + content_metrics = Ads.list_content_metrics() + + {:noreply, + socket + |> assign(:content_metrics, content_metrics) + |> assign(:editing_content_metrics, nil) + |> assign(:show_content_metrics_modal, false)} + + {:error, changeset} -> + {:noreply, assign(socket, :new_content_metrics_form, to_form(changeset))} + end + end + + def handle_event("cancel-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :product_review_thumbnail, ref)} + end + + defp handle_progress(:product_review_thumbnail, entry, socket) do + if entry.done? do + form = + consume_uploaded_entry(socket, entry, fn %{path: path} = _meta -> + uuid = Ecto.UUID.generate() + remote_path = "blurbs/#{uuid}" + + content_type = ExMarcel.MimeType.for({:path, path}) + + {:ok, _} = + Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end, + content_type: content_type + ) + + bucket = Algora.config([:buckets, :media]) + %{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{}) + + # {:ok, form} = + # Ecto.Changeset.apply_action(socket.assigns.new_product_review_form.source, :update) + + form = + socket.assigns.new_product_review_form.source + |> put_change( + :thumbnail_url, + "#{scheme}#{host}/#{bucket}/#{remote_path}" + ) + |> to_form() + + {:ok, form} + end) + + {:noreply, socket |> assign(:new_product_review_form, form)} + else + {:noreply, socket} + end + end + + @impl true + def handle_params(params, _url, socket) do + LayoutComponent.hide_modal() + {:noreply, socket |> apply_action(socket.assigns.live_action, params)} + end + + defp apply_action(socket, :show, _params) do + socket + |> assign(:page_title, "Content") + |> assign(:page_description, "Content") + end + + defp error_to_string(:too_large) do + "Only images up to #{max_file_size()} MB are allowed." + end + + defp error_to_string(:not_accepted) do + "Uploaded file is not a valid image. Only #{accept() |> Enum.intersperse(", ") |> Enum.join()} files are allowed." + end + + defp max_file_size, do: 10 + defp accept, do: ~w(.png .jpg .jpeg .gif) +end diff --git a/lib/algora_web/live/ad_live/form_component.ex b/lib/algora_web/live/ad_live/form_component.ex new file mode 100644 index 0000000..24b7e4a --- /dev/null +++ b/lib/algora_web/live/ad_live/form_component.ex @@ -0,0 +1,113 @@ +defmodule AlgoraWeb.AdLive.FormComponent do + use AlgoraWeb, :live_component + + alias Algora.Ads + + @impl true + def render(assigns) do + ~H""" +
+ <.header class="mb-8"> + <%= @title %> + <:subtitle>Use this form to manage ad records in your database. + + + <.simple_form + for={@form} + id="ad-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+
+ tv.algora.io/go/ +
+ <.input field={@form[:slug]} type="text" label="QR Code URL" class="ps-[6.75rem]" /> +
+ <.input field={@form[:website_url]} type="text" label="Website URL" /> + <.input field={@form[:composite_asset_url]} type="text" label="Asset URL" /> + <.input + field={@form[:border_color]} + type="text" + label="Border color" + style={"color: #{valid_color(@form[:border_color].value)}"} + /> + <:actions> + <.button phx-disable-with="Saving...">Save Ad + + +
+ """ + end + + @impl true + def update(%{ad: ad} = assigns, socket) do + changeset = Ads.change_ad(ad) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def handle_event("validate", %{"ad" => ad_params}, socket) do + changeset = + socket.assigns.ad + |> Ads.change_ad(ad_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"ad" => ad_params}, socket) do + save_ad(socket, socket.assigns.action, ad_params) + end + + defp save_ad(socket, :edit, ad_params) do + case Ads.update_ad(socket.assigns.ad, ad_params) do + {:ok, ad} -> + notify_parent({:saved, ad}) + Ads.broadcast_ad_updated!(ad) + + {:noreply, + socket + |> put_flash(:info, "Ad updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_ad(socket, :new, ad_params) do + case Ads.create_ad(ad_params) do + {:ok, ad} -> + notify_parent({:saved, ad}) + Ads.broadcast_ad_created!(ad) + + {:noreply, + socket + |> put_flash(:info, "Ad created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp valid_color(color) do + if is_valid_hex_color?(color), do: color, else: "inherit" + end + + defp is_valid_hex_color?(nil), do: false + + defp is_valid_hex_color?(color), do: color =~ ~r/^#([0-9A-F]{3}){1,2}$/i +end diff --git a/lib/algora_web/live/ad_live/index.ex b/lib/algora_web/live/ad_live/index.ex new file mode 100644 index 0000000..3b07455 --- /dev/null +++ b/lib/algora_web/live/ad_live/index.ex @@ -0,0 +1,95 @@ +defmodule AlgoraWeb.AdLive.Index do + use AlgoraWeb, :live_view + + alias Algora.Ads + alias Algora.Ads.Ad + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + schedule_next_rotation() + end + + next_slot = Ads.next_slot() + + ads = + Ads.list_active_ads() + |> Ads.rotate_ads() + |> Enum.with_index(-1) + |> Enum.map(fn {ad, index} -> + %{ + ad + | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) + } + end) + + {:ok, + socket + |> stream(:ads, ads) + |> assign(:next_slot, Ads.next_slot())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Ad") + |> assign(:ad, Ads.get_ad!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Ad") + |> assign(:ad, %Ad{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Ads") + |> assign(:ad, nil) + end + + @impl true + def handle_info({AlgoraWeb.AdLive.FormComponent, {:saved, ad}}, socket) do + {:noreply, stream_insert(socket, :ads, ad)} + end + + @impl true + def handle_info(:rotate_ads, socket) do + schedule_next_rotation() + + next_slot = Ads.next_slot() + + rotated_ads = + socket.assigns.ads + |> Ads.rotate_ads(1) + |> Enum.with_index(-1) + |> Enum.map(fn {ad, index} -> + %{ + ad + | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) + } + end) + + {:noreply, + socket + |> stream(:ads, rotated_ads) + |> assign(:next_slot, Ads.next_slot())} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + ad = Ads.get_ad!(id) + {:ok, _} = Ads.delete_ad(ad) + Ads.broadcast_ad_deleted!(ad) + + {:noreply, stream_delete(socket, :ads, ad)} + end + + defp schedule_next_rotation do + Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot()) + end +end diff --git a/lib/algora_web/live/ad_live/index.html.heex b/lib/algora_web/live/ad_live/index.html.heex new file mode 100644 index 0000000..c2e5335 --- /dev/null +++ b/lib/algora_web/live/ad_live/index.html.heex @@ -0,0 +1,71 @@ +<.header class="pl-4 pr-6"> + Active Ads + <:actions> + <.link patch={~p"/ads/new"}> + <.button>New Ad + + + + +<.table id="ads" rows={@streams.ads} row_click={fn {_id, ad} -> JS.navigate(~p"/ads/#{ad}") end}> + <:col :let={{_id, ad}} label=""> + +
+
+ <%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %> +
+
+ <%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %> +
+
+ + + + algora.tv/go/<%= ad.slug %> +
+
+
+ {ad.website_url} + + <:action :let={{_id, ad}}> +
+ <.link navigate={~p"/ads/#{ad}"}>Show +
+ <.link patch={~p"/ads/#{ad}/edit"}>Edit + + <:action :let={{id, ad}}> + <.link + phx-click={JS.push("delete", value: %{id: ad.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal :if={@live_action in [:new, :edit]} id="ad-modal" show on_cancel={JS.patch(~p"/ads")}> + <.live_component + module={AlgoraWeb.AdLive.FormComponent} + id={@ad.id || :new} + title={@page_title} + action={@live_action} + ad={@ad} + patch={~p"/ads"} + /> + diff --git a/lib/algora_web/live/ad_live/schedule.ex b/lib/algora_web/live/ad_live/schedule.ex new file mode 100644 index 0000000..a7c7572 --- /dev/null +++ b/lib/algora_web/live/ad_live/schedule.ex @@ -0,0 +1,68 @@ +defmodule AlgoraWeb.AdLive.Schedule do + use AlgoraWeb, :live_view + + alias Algora.Ads + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + schedule_next_rotation() + end + + next_slot = Ads.next_slot() + + ads = + Ads.list_active_ads() + |> Ads.rotate_ads() + |> Enum.with_index(-1) + |> Enum.map(fn {ad, index} -> + %{ + ad + | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) + } + end) + + {:ok, + socket + |> stream(:ads, ads) + |> assign(:next_slot, Ads.next_slot())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :schedule, _params) do + socket + |> assign(:page_title, "Ads schedule") + |> assign(:ad, nil) + end + + @impl true + def handle_info(:rotate_ads, socket) do + schedule_next_rotation() + + next_slot = Ads.next_slot() + + rotated_ads = + socket.assigns.ads + |> Ads.rotate_ads(1) + |> Enum.with_index(-1) + |> Enum.map(fn {ad, index} -> + %{ + ad + | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond) + } + end) + + {:noreply, + socket + |> stream(:ads, rotated_ads) + |> assign(:next_slot, Ads.next_slot())} + end + + defp schedule_next_rotation do + Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot()) + end +end diff --git a/lib/algora_web/live/ad_live/schedule.html.heex b/lib/algora_web/live/ad_live/schedule.html.heex new file mode 100644 index 0000000..36c5a3d --- /dev/null +++ b/lib/algora_web/live/ad_live/schedule.html.heex @@ -0,0 +1,41 @@ +<.header class="pl-4 pr-6"> + Ads schedule + + +<.table id="ads" rows={@streams.ads}> + <:col :let={{_id, ad}} label=""> + +
+
+ <%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %> +
+
+ <%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %> +
+
+ + + + algora.tv/go/<%= ad.slug %> +
+
+
+ {ad.website_url} + + diff --git a/lib/algora_web/live/ad_live/show.ex b/lib/algora_web/live/ad_live/show.ex new file mode 100644 index 0000000..91c831c --- /dev/null +++ b/lib/algora_web/live/ad_live/show.ex @@ -0,0 +1,21 @@ +defmodule AlgoraWeb.AdLive.Show do + use AlgoraWeb, :live_view + + alias Algora.Ads + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:ad, Ads.get_ad!(id))} + end + + defp page_title(:show), do: "Show Ad" + defp page_title(:edit), do: "Edit Ad" +end diff --git a/lib/algora_web/live/ad_live/show.html.heex b/lib/algora_web/live/ad_live/show.html.heex new file mode 100644 index 0000000..6d03ac1 --- /dev/null +++ b/lib/algora_web/live/ad_live/show.html.heex @@ -0,0 +1,37 @@ +<.header> + Ad <%= @ad.id %> + <:subtitle>This is a ad record from your database. + <:actions> + <.link patch={~p"/ads/#{@ad}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit ad + + + + +<.list> + <:item title="Verified"><%= @ad.verified %> + <:item title="Website url"><%= @ad.website_url %> + <:item title="Composite asset url"><%= @ad.composite_asset_url %> + <:item title="Asset url"><%= @ad.asset_url %> + <:item title="Logo url"><%= @ad.logo_url %> + <:item title="Qrcode url"><%= @ad.qrcode_url %> + <:item title="Start date"><%= @ad.start_date %> + <:item title="End date"><%= @ad.end_date %> + <:item title="Total budget"><%= @ad.total_budget %> + <:item title="Daily budget"><%= @ad.daily_budget %> + <:item title="Tech stack"><%= @ad.tech_stack %> + <:item title="Status"><%= @ad.status %> + + +<.back navigate={~p"/ads"}>Back to ads + +<.modal :if={@live_action == :edit} id="ad-modal" show on_cancel={JS.patch(~p"/ads/#{@ad}")}> + <.live_component + module={AlgoraWeb.AdLive.FormComponent} + id={@ad.id} + title={@page_title} + action={@live_action} + ad={@ad} + patch={~p"/ads/#{@ad}"} + /> + diff --git a/lib/algora_web/live/ad_overlay_live.ex b/lib/algora_web/live/ad_overlay_live.ex new file mode 100644 index 0000000..7aabb6a --- /dev/null +++ b/lib/algora_web/live/ad_overlay_live.ex @@ -0,0 +1,146 @@ +defmodule AlgoraWeb.AdOverlayLive do + use AlgoraWeb, :live_view + require Logger + + alias AlgoraWeb.{LayoutComponent, Presence} + alias Algora.{Accounts, Library, Ads} + + def render(assigns) do + ~H""" + <%= if @ads && length(@ads) > 0 do %> +
+ {@current_ad.website_url} + {@next_ad.website_url} +
+ <% end %> + """ + end + + def mount(%{"channel_handle" => channel_handle} = params, _session, socket) do + channel = + Accounts.get_user_by!(handle: channel_handle) + |> Library.get_channel!() + + ads = Ads.list_active_ads() + current_ad_index = Ads.get_current_index(ads) + current_ad = Enum.at(ads, current_ad_index) + {next_ad, next_index} = get_next_ad(ads, current_ad_index) + + if connected?(socket) do + Ads.subscribe_to_ads() + schedule_next_ad() + end + + {:ok, + socket + |> assign(:channel, channel) + |> assign(:ads, ads) + |> assign(:current_ad_index, current_ad_index) + |> assign(:current_ad, current_ad) + |> assign(:next_ad, next_ad) + |> assign(:next_ad_index, next_index) + |> assign(:show_ad, Map.has_key?(params, "test")) + |> assign(:test_mode, Map.has_key?(params, "test"))} + end + + def handle_params(params, _url, socket) do + LayoutComponent.hide_modal() + {:noreply, socket |> apply_action(socket.assigns.live_action, params)} + end + + def handle_info(:toggle_ad, %{assigns: %{test_mode: true}} = socket), do: {:noreply, socket} + + def handle_info(:toggle_ad, socket) do + case socket.assigns.show_ad do + true -> + schedule_next_ad() + {:noreply, assign(socket, :show_ad, false)} + + false -> + track_impressions(socket.assigns.next_ad, socket.assigns.channel.handle) + Process.send_after(self(), :toggle_ad, Ads.display_duration()) + + {new_next_ad, new_next_index} = + get_next_ad(socket.assigns.ads, socket.assigns.next_ad_index) + + {:noreply, + socket + |> assign(:show_ad, true) + |> assign(:current_ad, socket.assigns.next_ad) + |> assign(:current_ad_index, socket.assigns.next_ad_index) + |> assign(:next_ad, new_next_ad) + |> assign(:next_ad_index, new_next_index)} + end + end + + def handle_info({Ads, %Ads.Events.AdCreated{}}, socket) do + update_ads_state(socket) + end + + def handle_info({Ads, %Ads.Events.AdDeleted{}}, socket) do + update_ads_state(socket) + end + + def handle_info({Ads, %Ads.Events.AdUpdated{}}, socket) do + update_ads_state(socket) + end + + def handle_info(_arg, socket), do: {:noreply, socket} + + defp apply_action(socket, :show, params) do + channel_name = params["channel_handle"] + + socket + |> assign(:page_title, channel_name) + |> assign(:page_description, "Watch #{channel_name} on Algora TV") + end + + defp schedule_next_ad do + Process.send_after(self(), :toggle_ad, Ads.time_until_next_slot()) + end + + defp track_impressions(nil, _channel_handle), do: :ok + + defp track_impressions(ad, channel_handle) do + viewers_count = + Presence.list_online_users(channel_handle) + |> Enum.flat_map(fn %{metas: metas} -> metas end) + |> Enum.filter(fn meta -> meta.id != channel_handle end) + |> length() + + Ads.track_impressions(%{ + ad_id: ad.id, + duration: Ads.display_duration(), + viewers_count: viewers_count + }) + end + + defp get_next_ad(ads, current_index) do + next_index = rem(current_index + 1, length(ads)) + {Enum.at(ads, next_index), next_index} + end + + defp update_ads_state(socket) do + ads = Ads.list_active_ads() + current_ad_index = Ads.get_current_index(ads) + current_ad = Enum.at(ads, current_ad_index) + {next_ad, next_index} = get_next_ad(ads, current_ad_index) + + {:noreply, + socket + |> assign(:ads, ads) + |> assign(:current_ad_index, current_ad_index) + |> assign(:current_ad, current_ad) + |> assign(:next_ad, next_ad) + |> assign(:next_ad_index, next_index)} + end +end diff --git a/lib/algora_web/live/partner_live.ex b/lib/algora_web/live/partner_live.ex new file mode 100644 index 0000000..566a7ca --- /dev/null +++ b/lib/algora_web/live/partner_live.ex @@ -0,0 +1,381 @@ +defmodule AlgoraWeb.PartnerLive do + use AlgoraWeb, :live_view + require Logger + + alias AlgoraWeb.LayoutComponent + alias Algora.Contact + alias Algora.Contact.Info + + def render(assigns) do + ~H""" +
+
+ +
+ + +
+
+ <.logo /> + +

+ Your most successful + ad campaign + is just a few livestreams away +

+

+ In-video livestream ads that help you stand out + in front of millions on Twitch, YouTube and X. +

+ +
+
+ Demo +
+
+
+ +
+
+
+

+ Influencer Marketing on Autopilot +

+

+ Distribute your ad creatives to the most engaged tech audience in-video and measure success with our comprehensive analytics +

+
+
+
+
+ Analytics + +
+
+
+
+
+
+ + + + Easy Upload +
+
+ Upload your ad creatives quickly and easily through our intuitive interface. +
+
+
+
+ + + + Targeted Audience Reach +
+
+ Connect with a highly engaged, tech-focused audience across multiple streaming platforms. +
+
+
+
+ + + + Automated Placement +
+
+ Our system automatically places your ads in relevant tech content. +
+
+
+
+ + + + Wide Reach +
+
+ Access a vast network of tech-savvy viewers through our platform. +
+
+
+
+ + + + Detailed Analytics +
+
+ Get comprehensive insights into your ad performance with our powerful analytics tools. +
+
+
+
+ + + + Smart Spending +
+
+ Get more bang for your buck with our clever, targeted ad approach. +
+
+
+
+
+ +
+
+ +
+

Work With Us

+
+
+

+ We only partner with 1-2 new clients per month. Your application reaches our CEO's inbox faster than the speed of light. +

+
+ <.form for={@form} phx-submit="save" action="#" class="pt-8"> +
+
+ <.input field={@form[:email]} type="email" label="What is your email address?" /> +
+
+ <.input field={@form[:website_url]} type="text" label="What is your website?" /> +
+
+ <.input field={@form[:revenue]} type="text" label="What is your revenue?" /> +
+
+ <.input + field={@form[:company_location]} + type="text" + label="Where is your company based?" + /> +
+
+
+ <.button + phx-disable-with="Sending..." + class=" w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-600 active:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-400" + > + Let's talk + +
+ +
+
+
+ + +
+ """ + end + + def mount(_params, _session, socket) do + {:ok, socket |> assign(:form, to_form(Contact.change_info(%Info{})))} + end + + def handle_event("save", %{"info" => info_params}, socket) do + case Contact.create_info(info_params) do + {:ok, _info} -> + {:noreply, + socket + |> put_flash(:info, "Thank you for your interest. We'll be in touch soon!") + |> assign(:form, to_form(Contact.change_info(%Info{})))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def handle_params(params, _url, socket) do + LayoutComponent.hide_modal() + {:noreply, socket |> apply_action(socket.assigns.live_action, params)} + end + + defp apply_action(socket, :show, _params) do + socket + |> assign(:page_title, "Partner") + |> assign( + :page_description, + "In-video livestream ads that help you stand out in front of millions on Twitch, YouTube and X." + ) + |> assign(:page_image, "#{AlgoraWeb.Endpoint.url()}/images/og/partner.png") + end +end diff --git a/lib/algora_web/live/show_live/form_component.ex b/lib/algora_web/live/show_live/form_component.ex index e185e41..70a9da1 100644 --- a/lib/algora_web/live/show_live/form_component.ex +++ b/lib/algora_web/live/show_live/form_component.ex @@ -110,9 +110,11 @@ defmodule AlgoraWeb.ShowLive.FormComponent do consume_uploaded_entry(socket, entry, fn %{path: path} = _meta -> remote_path = "shows/#{socket.assigns.show.id}/cover/#{System.os_time(:second)}" + content_type = ExMarcel.MimeType.for({:path, path}) + {:ok, _} = Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end, - content_type: "image/jpeg" + content_type: content_type ) bucket = Algora.config([:buckets, :media]) diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index b834394..427bfd0 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -56,6 +56,13 @@ defmodule AlgoraWeb.Router do get "/:channel_handle/embed", EmbedPopoutController, :get get "/:channel_handle/:video_id/embed", EmbedPopoutController, :get_by_id + live_session :ads, + layout: {AlgoraWeb.Layouts, :live_bare}, + root_layout: {AlgoraWeb.Layouts, :root_embed} do + live "/partner", PartnerLive, :show + live "/:channel_handle/ads", AdOverlayLive, :show + end + live_session :chat, layout: {AlgoraWeb.Layouts, :live_chat}, root_layout: {AlgoraWeb.Layouts, :root_embed} do @@ -75,8 +82,16 @@ defmodule AlgoraWeb.Router do scope "/", AlgoraWeb do pipe_through :browser + get "/go/:slug", AdRedirectController, :go + delete "/auth/logout", OAuthCallbackController, :sign_out + live_session :schedule, + on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Nav] do + live "/ads/schedule", AdLive.Schedule, :schedule + live "/analytics/:slug", AdLive.Analytics, :show + end + live_session :admin, on_mount: [ {AlgoraWeb.UserAuth, :ensure_authenticated}, @@ -85,6 +100,14 @@ defmodule AlgoraWeb.Router do ] do live "/shows", ShowLive.Index, :index live "/shows/new", ShowLive.Index, :new + + live "/ads", AdLive.Index, :index + live "/ads/new", AdLive.Index, :new + live "/ads/:id/edit", AdLive.Index, :edit + live "/ads/:id", AdLive.Show, :show + live "/ads/:id/show/edit", AdLive.Show, :edit + + live "/admin/content", ContentLive, :show end live_session :authenticated, diff --git a/mix.exs b/mix.exs index 5e8fed6..daaaf73 100644 --- a/mix.exs +++ b/mix.exs @@ -44,7 +44,6 @@ defmodule Algora.MixProject do {:ex_m3u8, "~> 0.9.0"}, {:ex_marcel, "~> 0.1.0"}, {:exla, ">= 0.0.0"}, - {:exsync, "~> 0.2", only: :dev}, {:ffmpex, "~> 0.10.0"}, {:finch, "~> 0.18"}, {:floki, ">= 0.30.0", only: :test}, diff --git a/priv/repo/migrations/20240729183655_create_ads.exs b/priv/repo/migrations/20240729183655_create_ads.exs new file mode 100644 index 0000000..72c6d4b --- /dev/null +++ b/priv/repo/migrations/20240729183655_create_ads.exs @@ -0,0 +1,26 @@ +defmodule Algora.Repo.Migrations.CreateAds do + use Ecto.Migration + + def change do + create table(:ads) do + add :verified, :boolean, default: false, null: false + add :website_url, :string + add :composite_asset_url, :string + add :asset_url, :string + add :logo_url, :string + add :qrcode_url, :string + add :start_date, :naive_datetime + add :end_date, :naive_datetime + add :total_budget, :integer + add :daily_budget, :integer + add :tech_stack, {:array, :string} + add :click_count, :integer + add :status, :string + add :user_id, references(:users, on_delete: :nothing) + + timestamps() + end + + create index(:ads, [:user_id]) + end +end diff --git a/priv/repo/migrations/20240729183917_create_ad_impressions.exs b/priv/repo/migrations/20240729183917_create_ad_impressions.exs new file mode 100644 index 0000000..3cfac60 --- /dev/null +++ b/priv/repo/migrations/20240729183917_create_ad_impressions.exs @@ -0,0 +1,17 @@ +defmodule Algora.Repo.Migrations.CreateAdImpressions do + use Ecto.Migration + + def change do + create table(:ad_impressions) do + add :duration, :integer + add :viewers_count, :integer + add :ad_id, references(:ads, on_delete: :nothing) + add :video_id, references(:videos, on_delete: :nothing) + + timestamps() + end + + create index(:ad_impressions, [:ad_id]) + create index(:ad_impressions, [:video_id]) + end +end diff --git a/priv/repo/migrations/20240730000856_create_ad_visits.exs b/priv/repo/migrations/20240730000856_create_ad_visits.exs new file mode 100644 index 0000000..27ca717 --- /dev/null +++ b/priv/repo/migrations/20240730000856_create_ad_visits.exs @@ -0,0 +1,19 @@ +defmodule Algora.Repo.Migrations.CreateAdVisits do + use Ecto.Migration + + def change do + create table(:ad_visits) do + add :ad_id, references(:ads, on_delete: :nothing) + add :video_id, references(:videos, on_delete: :nothing) + + timestamps() + end + + alter table(:ads) do + remove :click_count + end + + create index(:ad_visits, [:ad_id]) + create index(:ad_visits, [:video_id]) + end +end diff --git a/priv/repo/migrations/20240730160846_create_contact_info.exs b/priv/repo/migrations/20240730160846_create_contact_info.exs new file mode 100644 index 0000000..0f6ca3e --- /dev/null +++ b/priv/repo/migrations/20240730160846_create_contact_info.exs @@ -0,0 +1,14 @@ +defmodule Algora.Repo.Migrations.CreateContactInfo do + use Ecto.Migration + + def change do + create table(:contact_info) do + add :email, :string + add :website_url, :string + add :revenue, :string + add :company_location, :string + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20240802162318_add_slug_to_ads.exs b/priv/repo/migrations/20240802162318_add_slug_to_ads.exs new file mode 100644 index 0000000..36d2a9b --- /dev/null +++ b/priv/repo/migrations/20240802162318_add_slug_to_ads.exs @@ -0,0 +1,11 @@ +defmodule Algora.Repo.Local.Migrations.AddSlugToAds do + use Ecto.Migration + + def change do + alter table(:ads) do + add :slug, :string + end + + create unique_index(:ads, [:slug]) + end +end diff --git a/priv/repo/migrations/20240805035256_add_border_color_to_ads.exs b/priv/repo/migrations/20240805035256_add_border_color_to_ads.exs new file mode 100644 index 0000000..b7ec537 --- /dev/null +++ b/priv/repo/migrations/20240805035256_add_border_color_to_ads.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Local.Migrations.AddBorderColorToAds do + use Ecto.Migration + + def change do + alter table(:ads) do + add :border_color, :string + end + end +end diff --git a/priv/repo/migrations/20240809163713_create_ad_related_tables.exs b/priv/repo/migrations/20240809163713_create_ad_related_tables.exs new file mode 100644 index 0000000..f846261 --- /dev/null +++ b/priv/repo/migrations/20240809163713_create_ad_related_tables.exs @@ -0,0 +1,45 @@ +defmodule Algora.Repo.Migrations.CreateAdRelatedTables do + use Ecto.Migration + + def change do + create table(:product_reviews) do + add :clip_from, :integer, null: false + add :clip_to, :integer, null: false + add :thumbnail_url, :string + add :ad_id, references(:ads, on_delete: :nothing), null: false + add :video_id, references(:videos, on_delete: :nothing), null: false + + timestamps() + end + + create index(:product_reviews, [:ad_id]) + create index(:product_reviews, [:video_id]) + + create table(:ad_appearances) do + add :airtime, :integer, null: false + add :ad_id, references(:ads, on_delete: :nothing), null: false + add :video_id, references(:videos, on_delete: :nothing), null: false + + timestamps() + end + + create index(:ad_appearances, [:ad_id]) + create index(:ad_appearances, [:video_id]) + + create table(:content_metrics) do + add :algora_stream_url, :string + add :twitch_stream_url, :string + add :youtube_video_url, :string + add :twitter_video_url, :string + add :twitch_avg_concurrent_viewers, :integer + add :twitch_views, :integer + add :youtube_views, :integer + add :twitter_views, :integer + add :video_id, references(:videos, on_delete: :nothing), null: false + + timestamps() + end + + create index(:content_metrics, [:video_id]) + end +end diff --git a/priv/repo/migrations/20240814021502_add_name_to_ads.exs b/priv/repo/migrations/20240814021502_add_name_to_ads.exs new file mode 100644 index 0000000..9de33bd --- /dev/null +++ b/priv/repo/migrations/20240814021502_add_name_to_ads.exs @@ -0,0 +1,11 @@ +defmodule Algora.Repo.Local.Migrations.AddNameToAds do + use Ecto.Migration + + def change do + alter table(:ads) do + add :name, :string + end + + execute "UPDATE ads SET name = INITCAP(slug)" + end +end diff --git a/priv/repo/migrations/20240814024335_update_content_metrics_fields.exs b/priv/repo/migrations/20240814024335_update_content_metrics_fields.exs new file mode 100644 index 0000000..17eea28 --- /dev/null +++ b/priv/repo/migrations/20240814024335_update_content_metrics_fields.exs @@ -0,0 +1,17 @@ +defmodule Algora.Repo.Local.Migrations.UpdateContentMetricsFields do + use Ecto.Migration + + def change do + execute "UPDATE content_metrics SET twitch_avg_concurrent_viewers = COALESCE(twitch_avg_concurrent_viewers, 0)" + execute "UPDATE content_metrics SET twitch_views = COALESCE(twitch_views, 0)" + execute "UPDATE content_metrics SET youtube_views = COALESCE(youtube_views, 0)" + execute "UPDATE content_metrics SET twitter_views = COALESCE(twitter_views, 0)" + + alter table(:content_metrics) do + modify :twitch_avg_concurrent_viewers, :integer, null: false, default: 0, from: :integer + modify :twitch_views, :integer, null: false, default: 0, from: :integer + modify :youtube_views, :integer, null: false, default: 0, from: :integer + modify :twitter_views, :integer, null: false, default: 0, from: :integer + end + end +end diff --git a/priv/repo/migrations/20240814033358_add_og_image_url_to_ads.exs b/priv/repo/migrations/20240814033358_add_og_image_url_to_ads.exs new file mode 100644 index 0000000..59e9491 --- /dev/null +++ b/priv/repo/migrations/20240814033358_add_og_image_url_to_ads.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Local.Migrations.AddOgImageUrlToAds do + use Ecto.Migration + + def change do + alter table(:ads) do + add :og_image_url, :string + end + end +end diff --git a/priv/static/images/analytics.png b/priv/static/images/analytics.png new file mode 100644 index 0000000..0621d20 Binary files /dev/null and b/priv/static/images/analytics.png differ diff --git a/priv/static/images/og/partner.png b/priv/static/images/og/partner.png new file mode 100644 index 0000000..04ad2eb Binary files /dev/null and b/priv/static/images/og/partner.png differ diff --git a/priv/static/images/partner-demo.png b/priv/static/images/partner-demo.png new file mode 100644 index 0000000..da49816 Binary files /dev/null and b/priv/static/images/partner-demo.png differ diff --git a/scripts/cossgpt.livemd b/scripts/cossgpt.livemd index f93629e..72e5eb2 100644 --- a/scripts/cossgpt.livemd +++ b/scripts/cossgpt.livemd @@ -7,10 +7,6 @@ import Ecto.Changeset alias Algora.{Accounts, Library, Repo, Storage, Cache, ML} IEx.configure(inspect: [charlists: :as_lists]) - -if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do - ExSync.register_group_leader() -end ``` ## Section diff --git a/test/algora/ads_test.exs b/test/algora/ads_test.exs new file mode 100644 index 0000000..8942dc7 --- /dev/null +++ b/test/algora/ads_test.exs @@ -0,0 +1,33 @@ +defmodule Algora.AdsTest do + use Algora.DataCase + + alias Algora.Ads + + describe "next_slot/1" do + test "returns the next 10-minute slot" do + # Test case 1: Exactly at the start of a slot + time = ~U[2024-08-03 10:00:00.000Z] + assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z] + + # Test case 2: In the middle of a slot + time = ~U[2024-08-03 10:05:30.123Z] + assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z] + + # Test case 3: Just before the next slot + time = ~U[2024-08-03 10:09:59.999Z] + assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z] + + # Test case 4: Crossing an hour boundary + time = ~U[2024-08-03 10:55:00.123Z] + assert Ads.next_slot(time) == ~U[2024-08-03 11:00:00.000Z] + + # Test case 5: Crossing a day boundary + time = ~U[2024-08-03 23:55:00.123Z] + assert Ads.next_slot(time) == ~U[2024-08-04 00:00:00.000Z] + end + + test "uses current time when no argument is provided" do + assert Ads.next_slot() == Ads.next_slot(DateTime.utc_now()) + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..839af15 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule AlgoraWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use AlgoraWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint AlgoraWeb.Endpoint + + use AlgoraWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import AlgoraWeb.ConnCase + end + end + + setup tags do + Algora.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..c0836af --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule Algora.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Algora.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias Algora.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Algora.DataCase + end + end + + setup tags do + Algora.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Algora.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..63865ae --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Algora.Repo, :manual)