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 @@
+
+
+
+
+
+
+
+ Edit
+
+
+ Last 30 days
+
+
+
+
+
+
+
+
+
+
+
+
+
Views
+
<%= format_number(@stats.total_views) %>
+
+
+
Airtime
+
<%= @stats.airtime %>
+
+
+
Streams
+
<%= @stats.streams %>
+
+
+
Creators
+
<%= @stats.creators %>
+
+
+
+
+
+
+
+ Top Sources
+
+
+
+
+
+
+
+ Source
+
+
+ Views
+
+
+
+
+ <%= for {source, views} <- @stats.views do %>
+ <%= if views > 0 do %>
+
+
+ <.source_icon icon={source |> String.downcase() |> String.to_atom()} />
+ <%= source %>
+
+
+ <%= format_number(views) %>
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+
+ Top Languages
+
+
+
+
+
+
+
+ Language
+
+
+ Creators
+
+
+ Views
+
+
+
+
+ <%= for {tech, data} <- @stats.tech_stack_data do %>
+
+
+ <.tech_icon tech={tech} /> <%= tech %>
+
+
+
+ <%= for creator <- Enum.take(data.creators, 5) do %>
+
+ <% end %>
+
+
+
+ <%= format_number(data.views) %>
+
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+ <.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_name %>
+
@<%= @blurb.video.channel_handle %>
+
+
+
<%= @blurb.video.title %>
+
+ Streamed on <%= @blurb.video.inserted_at |> Calendar.strftime("%b %d, %Y") %>
+
+
+
+
+
+
+ Appearances
+
+
+
+
+
+
+
+ Livestream
+
+
+ Airtime
+
+
+
+
+ <%= for product_review <- @product_reviews do %>
+
+
+ <.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) %>
+
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ Ad
+ Airtime
+ Blurb
+ Thumbnail
+
+
+
+ <%= 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) %>
+
+ <%= 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
+ ) %>
+
+
+ <% end %>
+
+
+
+
+ <.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"
+ />
+
+
+ Thumbnail
+
+
+ <.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 %>
+
+
+
+
+
+ <: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 %>
+
+
+
+
+
+
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 %>
+
+
+
+
+ <% 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Influencer Marketing on Autopilot
+
+
+ Distribute your ad creatives to the most engaged tech audience in-video and measure success with our comprehensive 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <.logo />
+
+ We work with elite tech startups to provide elite advertising.
+
+
+
+
+
+
+ """
+ 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)