You've already forked algora-tv
							
							
				mirror of
				https://github.com/algora-io/tv.git
				synced 2025-10-30 23:07:56 +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