You've already forked algora-tv
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:
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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})
|
||||
|
65
lib/algora_web/live/tags_component.ex
Normal file
65
lib/algora_web/live/tags_component.ex
Normal 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">
|
||||
×
|
||||
</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
|
@@ -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,
|
||||
|
@@ -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
|
@@ -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
|
@@ -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
|
Reference in New Issue
Block a user