diff --git a/lib/mave_core/analytics.ex b/lib/mave_core/analytics.ex index ec1406f..577bbf6 100644 --- a/lib/mave_core/analytics.ex +++ b/lib/mave_core/analytics.ex @@ -98,6 +98,85 @@ defmodule MaveCore.Analytics do }} end + @doc """ + Returns the space's most viewed video embeds this year as + `%{embed_hash: hash, views: count}` maps, ordered by views. + """ + def space_top_videos(space_hash, limit \\ 25) + + def space_top_videos(space_hash, limit) + when is_binary(space_hash) and is_integer(limit) and limit > 0 do + with {:ok, space_hash} <- parse_space_hash(space_hash) do + if SharedStorageSpace.hash?(space_hash) do + {:ok, []} + else + do_space_top_videos(space_hash, limit) + end + end + end + + def space_top_videos(_space_hash, _limit), do: {:error, :invalid_space_hash} + + defp do_space_top_videos(space_hash, limit) do + now = DateTime.utc_now() + year_from = DateTime.new!(Date.new!(now.year, 1, 1), ~T[00:00:00], "Etc/UTC") + + db_name = clickhouse_database_name() + threshold = @min_watch_seconds * 1000 + + sql = """ + /* mave_perf:spaces.top_videos */ + WITH ordered AS ( + SELECT + embed_hash, + session_id, + timestamp, + name, + leadInFrame(timestamp) OVER ( + PARTITION BY embed_hash, session_id + ORDER BY timestamp + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS next_ts, + leadInFrame(name) OVER ( + PARTITION BY embed_hash, session_id + ORDER BY timestamp + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS next_name + FROM #{db_name}.events + PREWHERE space_hash = #{string_literal(space_hash)} + AND timestamp >= #{datetime_literal(year_from)} + AND timestamp < #{datetime_literal(now)} + WHERE name IN ('play', 'pause') + ), + session_aggregates AS ( + SELECT + embed_hash, + session_id, + sumIf( + greatest(toInt64(0), toUnixTimestamp64Milli(next_ts) - toUnixTimestamp64Milli(timestamp)), + name = 'play' AND next_name = 'pause' + ) AS watch_ms + FROM ordered + GROUP BY embed_hash, session_id + ) + SELECT + embed_hash, + countIf(watch_ms >= #{threshold}) AS views + FROM session_aggregates + GROUP BY embed_hash + HAVING views > 0 + ORDER BY views DESC, embed_hash ASC + LIMIT #{limit} + """ + + query_clickhouse(sql, {:error, :analytics_unavailable}, fn rows -> + {:ok, + Enum.map(rows, fn [embed_hash, views] -> + %{embed_hash: embed_hash, views: to_int(views)} + end)} + end) + end + def space_monthly_watch_time(space_hash) when is_binary(space_hash) do with {:ok, space_hash} <- parse_space_hash(space_hash) do if SharedStorageSpace.hash?(space_hash) do diff --git a/lib/mave_core/embeds.ex b/lib/mave_core/embeds.ex index 94eb515..0398a2e 100644 --- a/lib/mave_core/embeds.ex +++ b/lib/mave_core/embeds.ex @@ -795,6 +795,26 @@ defmodule MaveCore.Embeds do defp maybe_broadcast_embed_updated(result, _payload), do: result + @doc """ + Maps embed hashes of the space's video embeds to their dashboard id and + display name. Deleted embeds and unknown hashes are left out. + """ + @spec dashboard_videos_by_hash(Space.t(), [String.t()]) :: %{ + String.t() => %{id: String.t(), name: String.t()} + } + def dashboard_videos_by_hash(%Space{}, []), do: %{} + + def dashboard_videos_by_hash(%Space{id: space_id}, hashes) when is_list(hashes) do + Embed + |> where([embed], embed.space_id == ^space_id and embed.hash in ^hashes) + |> where([embed], embed.type == :video and is_nil(embed.deleted_at)) + |> preload(asset: [:current_video]) + |> Repo.all() + |> Map.new(fn embed -> + {embed.hash, %{id: dashboard_embed_id(embed), name: embed_name(embed)}} + end) + end + @spec dashboard_embed_id(Embed.t()) :: String.t() def dashboard_embed_id(%Embed{id: id}) when is_binary(id), do: id diff --git a/lib/mave_core_web/dashboard_routes.ex b/lib/mave_core_web/dashboard_routes.ex index b773050..460aaa8 100644 --- a/lib/mave_core_web/dashboard_routes.ex +++ b/lib/mave_core_web/dashboard_routes.ex @@ -23,6 +23,10 @@ defmodule MaveCoreWeb.DashboardRoutes do path_for(space_or_user, "/data", [], opts) end + def data_top_list_path(space_or_user, opts \\ []) do + path_for(space_or_user, "/data/top-list", [], opts) + end + def settings_path(space_or_user, tab \\ nil, opts \\ []) do suffix = case tab do diff --git a/lib/mave_core_web/live/dashboard/data/index.ex b/lib/mave_core_web/live/dashboard/data/index.ex index c64fced..d079462 100644 --- a/lib/mave_core_web/live/dashboard/data/index.ex +++ b/lib/mave_core_web/live/dashboard/data/index.ex @@ -4,8 +4,14 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do use MaveCoreWeb, :live_view import MaveCoreWeb.DashboardComponents, except: [icon: 1] + @top_list_size 25 + # Deleted videos keep their analytics, so query extra rows to fill the list. + @top_list_query_size 50 + alias MaveCore.Analytics + alias MaveCore.Embeds alias MaveCore.UsageLimits + alias MaveCoreWeb.DashboardRoutes alias MaveCoreWeb.Formatter alias Phoenix.LiveView.AsyncResult @@ -14,6 +20,7 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do socket |> assign(:page_title, gettext("Data")) |> assign(:settings_open, false) + |> assign(:route_scope, :unscoped) |> assign(:show_all_devices, false) |> assign(:show_all_browsers, false) |> assign(:data_access_notice, nil) @@ -21,17 +28,16 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do |> assign_async_defaults()} end - def handle_params(_params, _url, socket) do - space_hash = socket.assigns.current_space.hash + def handle_params(params, _url, socket) do + space = socket.assigns.current_space socket = - case UsageLimits.can_view_space_data?(socket.assigns.current_space) do + case UsageLimits.can_view_space_data?(space) do :ok -> socket |> assign(:data_access_notice, nil) |> assign(:data_access_help, nil) - |> assign_async_defaults() - |> start_async(:fetch_stats, fn -> Analytics.space_data(space_hash) end) + |> start_tab_fetch(socket.assigns.live_action, space) {:error, reason} -> socket @@ -40,7 +46,32 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do |> assign_empty_stats() end - {:noreply, socket} + {:noreply, assign(socket, :route_scope, route_scope(params))} + end + + defp start_tab_fetch(socket, :top_list, space) do + socket + |> assign(:top_videos, AsyncResult.loading()) + |> start_async(:fetch_top_videos, fn -> fetch_top_videos(space) end) + end + + defp start_tab_fetch(socket, _overview, space) do + socket + |> assign_async_defaults() + |> start_async(:fetch_stats, fn -> Analytics.space_data(space.hash) end) + end + + defp fetch_top_videos(space) do + with {:ok, rows} <- Analytics.space_top_videos(space.hash, @top_list_query_size) do + videos = Embeds.dashboard_videos_by_hash(space, Enum.map(rows, & &1.embed_hash)) + + top_videos = + for %{embed_hash: hash, views: views} <- rows, + Map.has_key?(videos, hash), + do: Map.put(videos[hash], :views, views) + + {:ok, Enum.take(top_videos, @top_list_size)} + end end def handle_async(:fetch_stats, {:ok, {:ok, result}}, socket) do @@ -72,6 +103,19 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do def handle_async(:fetch_stats, {:exit, _reason}, socket), do: {:noreply, assign_failed(socket)} + def handle_async(:fetch_top_videos, {:ok, {:ok, top_videos}}, socket) do + {:noreply, assign(socket, :top_videos, AsyncResult.ok(socket.assigns.top_videos, top_videos))} + end + + def handle_async(:fetch_top_videos, _result, socket) do + {:noreply, + assign( + socket, + :top_videos, + AsyncResult.failed(socket.assigns.top_videos, :analytics_unavailable) + )} + end + def handle_event("toggle_show_all_devices", _, socket) do {:noreply, assign(socket, :show_all_devices, !socket.assigns.show_all_devices)} end @@ -83,143 +127,169 @@ defmodule MaveCoreWeb.Dashboard.Data.Index do def render(assigns) do ~H"""