1
0
mirror of https://github.com/algora-io/tv.git synced 2025-09-16 08:26:20 +02:00

feat: tag system for videos and channels (#92)

* chore: setup migrations for tags field in users and videos

* chore: add tags to user and video schema

* feat(ui): add tags live_component

* chore: update tags handling
change add_tag event from enter to use 'space' key
add channel tags to live video

* fix: clear tag input when a tag is added

* fix: allow channel/user tags to be inherited by videos

* remove rebase artifact

* De-duplicate video tags by coalescing user tags

This will avoid updating every video for a user anytime they change
their tags and will also allow videos to have different tags from a user's tags

Added Library.count_tags/1 and Library.list_videos_by_tag/2

* add gin index for tags

* enforce limit and order for Library.count_tags/1

* add Accounts.count_tags/1

* sync video tags with user tags during livestream reconciliation

---------

Co-authored-by: ty <lastcanal@gmail.com>
Co-authored-by: zafer <zafer@algora.io>
This commit is contained in:
Caleb
2024-12-13 16:27:21 +01:00
committed by GitHub
parent 2167e485c5
commit 291e5a1adf
12 changed files with 259 additions and 17 deletions

View File

@@ -488,6 +488,17 @@ const Hooks = {
this.setup();
},
},
ChannelTagInput: {
mounted() {
this.handleEvent("tag_added", () => {
const input = this.el.querySelector("input");
if (input) {
input.value = "";
}
});
},
},
} satisfies Record<string, Partial<ViewHook> & Record<string, unknown>>;
// Accessible focus handling

View File

@@ -3,6 +3,8 @@ defmodule Algora.Accounts do
import Ecto.Changeset
alias Algora.{Repo, Restream, Google}
alias Algora.{Repo, Restream}
alias Algora.Library.{Video}
alias Algora.Accounts.{User, Identity, Destination, Entity}
def list_users(opts) do
@@ -18,7 +20,9 @@ defmodule Algora.Accounts do
end
def update_settings(%User{} = user, attrs) do
user |> change_settings(attrs) |> Repo.update()
user
|> User.settings_changeset(attrs)
|> Repo.update()
end
## Database getters
@@ -309,4 +313,20 @@ defmodule Algora.Accounts do
entity -> entity
end
end
def count_tags(limit \\ 100) do
unnest_tags_query = from u in User,
select_merge: %{id: u.id, tag: fragment("unnest(tags)")}
tags_query = from t in subquery(unnest_tags_query),
group_by: t.tag,
limit: ^limit,
select: %{
tag: t.tag,
count: count(t.tag)
}
Repo.all(order_by(tags_query, [q], desc: q.count))
end
end

View File

@@ -3,6 +3,8 @@ defmodule Algora.Accounts.User do
import Ecto.Changeset
alias Algora.{Repo}
alias Hex.API.User
alias Algora.Repo
alias Algora.Accounts.{User, Identity, Entity}
schema "users" do
@@ -20,6 +22,7 @@ defmodule Algora.Accounts.User do
field :bounties_count, :integer
field :solving_challenge, :boolean, default: false
field :featured, :boolean, default: false
field :tags, {:array, :string}, default: []
embeds_many :tech, Tech do
field :name, :string
@@ -115,11 +118,18 @@ defmodule Algora.Accounts.User do
end
end
def update_user_tags(user, tags) do
user
|> User.settings_changeset(%{tags: tags})
|> Repo.update()
end
def settings_changeset(%User{} = user, params) do
user
|> cast(params, [:handle, :name, :channel_tagline])
|> cast(params, [:handle, :name, :channel_tagline, :tags])
|> validate_required([:handle, :name, :channel_tagline])
|> validate_handle()
|> validate_length(:tags, max: 10)
end
defp validate_email(changeset) do

View File

@@ -674,7 +674,12 @@ defmodule Algora.Library do
result =
Repo.update_all(from(v in Video, where: v.id == ^video.id),
set: [user_id: user.id, title: user.channel_tagline, visibility: user.visibility]
set: [
user_id: user.id,
title: user.channel_tagline,
visibility: user.visibility,
tags: user.tags
]
)
video = get_video!(video.id)
@@ -699,7 +704,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
}
)
|> Video.not_deleted()
@@ -715,7 +721,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
}
)
|> Video.not_deleted()
@@ -732,7 +739,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
}
)
|> Video.not_deleted()
@@ -748,7 +756,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
},
where: v.id in ^ids
)
@@ -773,7 +782,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
},
where: v.show_id in ^ids
)
@@ -794,7 +804,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
}
)
|> Video.not_deleted()
@@ -810,7 +821,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
},
where:
not is_nil(v.url) and
@@ -832,7 +844,8 @@ defmodule Algora.Library do
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url,
messages_count: count(m.id)
messages_count: count(m.id),
tags: coalesce(v.tags, u.tags)
},
where:
is_nil(v.transmuxed_from_id) and
@@ -873,6 +886,46 @@ defmodule Algora.Library do
|> Enum.map(&get_channel!/1)
end
def list_videos_by_tag(tag, limit \\ 100) do
from(v in Video,
join: u in User,
on: v.user_id == u.id,
limit: ^limit,
where:
not is_nil(v.url) and
is_nil(v.transmuxed_from_id) and
v.visibility == :public and
is_nil(v.vertical_thumbnail_url) and
(v.is_live == true or v.duration >= 120 or v.type == :vod) and
^tag in v.tags or ^tag in u.tags,
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
}
)
|> Video.not_deleted()
|> order_by_live()
|> Repo.all()
end
def count_tags(limit \\ 100) do
unnest_tags_query = from u in User,
select_merge: %{id: u.id, tag: fragment("unnest(tags)")}
tags_query = from v in Video,
join: t in subquery(unnest_tags_query),
group_by: t.tag,
limit: ^limit,
select: %{
tag: t.tag,
count: count(t.tag)
}
Repo.all(order_by(tags_query, [q], desc: q.count))
end
def get_channel!(%Accounts.User{} = user) do
%Channel{
user_id: user.id,
@@ -886,7 +939,8 @@ defmodule Algora.Library do
bounties_count: user.bounties_count,
orgs_contributed: user.orgs_contributed,
tech: user.tech,
solving_challenge: user.solving_challenge
solving_challenge: user.solving_challenge,
tags: user.tags
}
end
@@ -907,7 +961,8 @@ defmodule Algora.Library do
select_merge: %{
channel_handle: u.handle,
channel_name: u.name,
channel_avatar_url: u.avatar_url
channel_avatar_url: u.avatar_url,
tags: coalesce(v.tags, u.tags)
}
)
|> Video.not_deleted()

View File

@@ -10,5 +10,6 @@ defmodule Algora.Library.Channel do
bounties_count: nil,
orgs_contributed: nil,
tech: nil,
solving_challenge: nil
solving_challenge: nil,
tags: []
end

View File

@@ -4,6 +4,7 @@ defmodule Algora.Library.Video do
import Ecto.Changeset
import Ecto.Query
alias Algora.Repo
alias Algora.Accounts.User
alias Algora.Library.Video
alias Algora.Storage
@@ -40,6 +41,7 @@ defmodule Algora.Library.Video do
field :remote_path, :string
field :local_path, :string
field :deleted_at, :naive_datetime
field :tags, {:array, :string}, default: []
belongs_to :user, User
belongs_to :show, Show
@@ -56,8 +58,15 @@ defmodule Algora.Library.Video do
@doc false
def changeset(video, attrs) do
video
|> cast(attrs, [:title])
|> cast(attrs, [:title, :tags])
|> validate_required([:title])
|> validate_length(:tags, max: 10)
end
def create_video(attrs \\ %{}) do
%Video{}
|> Video.changeset(attrs)
|> Repo.insert()
end
def change_thumbnail(video, thumbnail_url \\ "") do

View File

@@ -2,6 +2,7 @@ defmodule AlgoraWeb.SettingsLive do
use AlgoraWeb, :live_view
alias Algora.Accounts
alias Algora.Library.{Video}
alias Algora.Accounts.Destination
alias AlgoraWeb.RTMPDestinationIconComponent
@@ -21,6 +22,12 @@ defmodule AlgoraWeb.SettingsLive do
<.input field={@form[:name]} label="Name" />
<.input label="Email" name="email" value={@current_user.email} disabled />
<.input field={@form[:channel_tagline]} label="Stream tagline" />
<.live_component
module={AlgoraWeb.TagsComponent}
id="channel_tags"
name="channel_tags"
tags={@current_user.tags || []}
/>
<:actions>
<.button>Save</.button>
</:actions>
@@ -307,7 +314,8 @@ defmodule AlgoraWeb.SettingsLive do
|> assign(show_add_destination_modal: false)
|> assign(stream_key: current_user.stream_key)
|> assign(connected_with_restream: connected_with_restream)
|> assign(connected_with_google: connected_with_google),
|> assign(connected_with_google: connected_with_google)
|> assign(tags: current_user.tags || []),
temporary_assigns: [
stream_url:
"rtmp://#{rtmp_host}:#{Algora.config([:rtmp_port])}/#{Algora.config([:rtmp_path])}"
@@ -324,11 +332,15 @@ defmodule AlgoraWeb.SettingsLive do
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.update_settings(socket.assigns.current_user, params) do
current_tags = socket.assigns.tags
params_with_tags = Map.put(params, "tags", current_tags)
case Accounts.update_settings(socket.assigns.current_user, params_with_tags) do
{:ok, user} ->
{:noreply,
socket
|> assign(current_user: user)
|> assign(tags: user.tags)
|> put_flash(:info, "Settings updated!")}
{:error, changeset} ->
@@ -336,6 +348,33 @@ defmodule AlgoraWeb.SettingsLive do
end
end
def handle_event("save", %{"user" => params}, socket) do
current_tags = socket.assigns.tags
params_with_tags = Map.put(params, "tags", current_tags)
case Accounts.update_settings(socket.assigns.current_user, params_with_tags) do
{:ok, user} ->
{:noreply,
socket
|> assign(current_user: user)
|> assign(tags: user.tags)
|> put_flash(:info, "Settings updated!")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
{:error, reason} ->
{:noreply,
socket
|> put_flash(:error, "Error updating settings: #{reason}")}
end
end
# to listen for the change event in the tags component
def handle_info({:update_tags, updated_tags}, socket) do
{:noreply, assign(socket, tags: updated_tags)}
end
def handle_event("toggle_destination", %{"id" => id}, socket) do
destination = Accounts.get_destination!(id)
Accounts.update_destination(destination, %{active: !destination.active})

View File

@@ -0,0 +1,65 @@
defmodule AlgoraWeb.TagsComponent do
use AlgoraWeb, :live_component
def update(assigns, socket) do
{:ok, assign(socket, id: assigns.id, name: assigns.name, tags: assigns.tags)}
end
def render(assigns) do
~H"""
<div class="tag-input-container" id={"tag-input-container-#{@id}"} phx-hook="ChannelTagInput">
<div class="flex flex-col">
<label class="pb-2 font-bold">Channel tags</label>
<input type="text"
id={"#{@id}-input"}
name={@name}
phx-keydown="key_pressed"
phx-target={@myself}
placeholder="Type a tag and press the spacebar to add it"
class="flex-grow px-3 py-2 bg-gray-950 focus:border-gray-600 shadow-sm focus:ring-gray-600 placeholder-slate-100 block w-full rounded-md sm:text-sm focus:ring-1"
/>
</div>
<div class="flex flex-wrap gap-2 mt-2">
<%= for tag <- @tags do %>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-950 text-white border border-gray-600">
<%= tag %>
<button type="button" phx-click="remove_tag" phx-value-tag={tag} phx-target={@myself} class="ml-1 danger-400 hover:text-blue-600">
&times;
</button>
</span>
<% end %>
</div>
</div>
"""
end
def handle_event("key_pressed", %{"key" => " ", "value" => value}, socket) do
add_tag(socket, value)
end
def handle_event("key_pressed", _params, socket) do
{:noreply, socket}
end
def handle_event("remove_tag", %{"tag" => tag}, socket) do
updated_tags = List.delete(socket.assigns.tags, tag)
send(self(), {:update_tags, updated_tags})
{:noreply, assign(socket, tags: updated_tags)}
end
defp add_tag(socket, value) do
tag = String.trim(value)
current_tags = socket.assigns.tags
if tag !== "" and tag not in current_tags do
updated_tags = current_tags ++ [tag]
send(self(), {:update_tags, updated_tags})
{:noreply,
socket
|> assign(tags: updated_tags)
|> push_event("tag_added", %{})}
else
{:noreply, socket}
end
end
end

View File

@@ -658,6 +658,7 @@ defmodule AlgoraWeb.VideoLive do
video: video,
subtitles: subtitles,
tabs: tabs,
tags: current_user.tags,
# TODO: reenable once fully implemented
# associated segments need to be removed from db & vectorstore
can_edit: false,

View File

@@ -0,0 +1,9 @@
defmodule Algora.Repo.Local.Migrations.AddTagsToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :tags, {:array, :string}
end
end
end

View File

@@ -0,0 +1,9 @@
defmodule Algora.Repo.Local.Migrations.AddTagsToVideos do
use Ecto.Migration
def change do
alter table(:videos) do
add :tags, {:array, :string}
end
end
end

View File

@@ -0,0 +1,13 @@
defmodule Algora.Repo.Local.Migrations.AddGinIndexToTags do
use Ecto.Migration
def up do
execute("create index users_tags_index on users using gin (tags);")
execute("create index videos_tags_index on videos using gin (tags);")
end
def down do
execute("drop index users_tags_index;")
execute("drop index videos_tags_index;")
end
end