Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Develop a hosting feature so streamers can recommend other channels to their community when they are offline. #786

Merged
merged 4 commits into from
Jan 15, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ $fa-font-path: "/fa-fonts";
@import "glimesh/components/follow-button";
@import "glimesh/components/apex-charts";
@import "glimesh/components/video";
@import "glimesh/components/channel-lookup-typeahead";

@import "glimesh/pages/dmca";
@import "glimesh/pages/hosting.scss";

@include media-breakpoint-up(lg) {
.layout-spacing {
Expand Down
40 changes: 40 additions & 0 deletions assets/css/glimesh/components/channel-lookup-typeahead.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
@mixin show-dropdown {
z-index: 9999;
display: block;
max-height: 250px;
overflow: auto;
}

.channel-typeahead {
position: relative;
&-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin-top: -2px;
> .list-group-item {
display: flex;
align-items: center;
padding: 12px 15px;
margin: 0;
opacity: 100;
&:hover {
background: blue;
cursor: pointer;
}
}
&:hover {
@include show-dropdown;
}
}
&-input {
position: relative;
&:focus {
+ .channel-typeahead-dropdown {
@include show-dropdown;
}
}
}
}
14 changes: 14 additions & 0 deletions assets/css/glimesh/pages/hosting.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.fixed-header-table {
table {
text-align: left;
position: relative;
border-collapse: collapse;
}
th, td {
padding: 0.25rem;
}
th {
position: sticky;
top: 0; /* Don't forget this, required for the stickiness */
}
}
2 changes: 2 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import InfiniteScroll from "./hooks/InfiniteScroll";
import TagSearch from "./hooks/TagSearch";
import LaunchCountdown from "./hooks/LaunchCountdown";
import Tagify from "./hooks/Tagify";
import ChannelLookupTypeahead from "./hooks/ChannelLookupTypeahead";

// https://github.com/github/markdown-toolbar-element
import "@github/markdown-toolbar-element";
Expand All @@ -33,6 +34,7 @@ Hooks.InfiniteScroll = InfiniteScroll;
Hooks.TagSearch = TagSearch;
Hooks.LaunchCountdown = LaunchCountdown;
Hooks.Tagify = Tagify;
Hooks.ChannelLookupTypeahead = ChannelLookupTypeahead;

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
Expand Down
11 changes: 11 additions & 0 deletions assets/js/hooks/ChannelLookupTypeahead.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default {
mounted() {
this.el.addEventListener("click", e => {
let userId = this.el.getAttribute('data-id');
let userName = this.el.getAttribute('data-name');
let fieldName = this.el.getAttribute('data-fieldname');
let channelId = this.el.getAttribute('data-channel-id');
this.pushEvent(fieldName + "_selection_made", {"user_id": userId, "username": userName, "channel_id": channelId});
});
}
}
4 changes: 4 additions & 0 deletions lib/glimesh/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -656,4 +656,8 @@ defmodule Glimesh.Accounts do
valid?: false
}}
end

def get_account_age_in_days(%User{} = user) do
NaiveDateTime.diff(NaiveDateTime.utc_now(), user.inserted_at, :second) / 86_400
end
end
138 changes: 138 additions & 0 deletions lib/glimesh/channel_hosts_lookups.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
defmodule Glimesh.ChannelHostsLookups do
@moduledoc """
Channel Hosting lookups
"""

import Ecto.Query, warn: false

alias Glimesh.ChannelLookups
alias Glimesh.Repo
alias Glimesh.Streams.ChannelHosts

def get_channel_hosting_list(channel_id) do
ChannelHosts
|> where([ch], ch.hosting_channel_id == ^channel_id)
|> preload(host: [:user], target: [:user])
|> Repo.all()
end

def get_current_hosting_target(channel) do
ChannelHosts
|> where([ch], ch.hosting_channel_id == ^channel.id and ch.status == "hosting")
|> limit(1)
|> preload(host: [:user], target: [:user])
|> Repo.one()
end

def get_targets_host_info(host_username, target_channel) do
host = ChannelLookups.get_channel_for_username(host_username)

ChannelHosts
|> where(
[ch],
ch.hosting_channel_id == ^host.id and ch.status == "hosting" and
ch.target_channel_id == ^target_channel.id
)
|> preload(host: [:user], target: [:user])
|> Repo.one()
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've used raw sql in the following methods for clarity purposes. The Ecto.Query format was getting hard to read.

@doc """
Used by Auto Host Job -- find channels that are being hosted but are no longer live and reset them to "ready" state
"""
def unhost_channels_not_live do
sql = """
update channel_hosts\
set status = 'ready'\
where id in\
(select ch.id from channel_hosts as ch\
inner join channels as c on ch.target_channel_id = c.id\
where c.status != 'live'\
and ch.status = 'hosting')\
returning id\
"""

Repo.query(sql)
end

@doc """
Used by Auto Host Job -- find channels that are no longer eligible for hosting by the current host and set them to "error" state
"""
def invalidate_hosting_channels_where_necessary do
sql = """
update channel_hosts\
set status = 'error'\
where id in\
(select ch.id from channel_hosts as ch\
inner join channels as tc on ch.target_channel_id = tc.id\
inner join users as tu on tc.user_id = tu.id\
inner join channels as hc on ch.hosting_channel_id = hc.id\
inner join users as hu on hc.user_id = hu.id\
where ch.status != 'error'\
and (tc.allow_hosting = 'false'\
or tc.inaccessible = 'true'\
or tu.is_banned = 'true'\
or tu.can_stream = 'false'\
or exists(select user_id from channel_bans where user_id = hc.user_id and channel_id = tc.id and expires_at is null)\
or hu.is_banned = 'true'\
or hu.can_stream = 'false'\
or hc.inaccessible = 'true'))\
returning id\
"""

Repo.query(sql)
end

@doc """
Used by Auto Host Job -- find channels that are live with hosted channels that aren't live and host them!
This will only affect channel_hosts table records in "ready" state -- it is assumed that invalidate_hosting_channels_where_necessary()
and unhost_channels_not_live() have updated the channel_hosts table record states appropriately.
"""
def host_some_channels do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)

sql = """
update channel_hosts\
set status = 'hosting', last_hosted_date = $1\
where id in\
(select distinct on(ch_sub.hosting_channel_id) ch_sub.id\
from channel_hosts as ch_sub\
inner join channels as tc on ch_sub.target_channel_id = tc.id\
inner join channels as hc on ch_sub.hosting_channel_id = hc.id\
where ch_sub.status = 'ready'\
and hc.status = 'offline'\
and tc.status = 'live'\
and not exists(select true from channel_hosts where status = 'hosting' and hosting_channel_id = ch_sub.hosting_channel_id)\
group by ch_sub.id\
order by ch_sub.hosting_channel_id asc, min(coalesce(ch_sub.last_hosted_date, to_timestamp(0))) asc)\
returning id\
"""

Repo.query(sql, [now])
end

def recheck_error_status_channels do
sql = """
update channel_hosts\
set status = 'ready'\
where id in\
(select ch.id from channel_hosts as ch\
inner join channels as tc on ch.target_channel_id = tc.id\
inner join users as tu on tc.user_id = tu.id\
inner join channels as hc on ch.hosting_channel_id = hc.id\
inner join users as hu on hc.user_id = hu.id\
where ch.status = 'error'\
and tc.allow_hosting = 'true'\
and tc.inaccessible = 'false'\
and tu.is_banned = 'false'\
and tu.can_stream = 'true'\
and not exists(select user_id from channel_bans where user_id = hc.user_id and channel_id = tc.id and expires_at is null)\
and hu.is_banned = 'false'\
and hu.can_stream = 'true'\
and hc.inaccessible = 'false')\
returning id\
"""

Repo.query(sql)
end
end
92 changes: 91 additions & 1 deletion lib/glimesh/channel_lookups.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule Glimesh.ChannelLookups do
alias Glimesh.AccountFollows.Follower
alias Glimesh.Accounts.User
alias Glimesh.Repo
alias Glimesh.Streams.{Category, Channel}
alias Glimesh.Streams.{Category, Channel, ChannelHosts}

## Filtering
@spec search_live_channels(map) :: list
Expand Down Expand Up @@ -113,6 +113,62 @@ defmodule Glimesh.ChannelLookups do
|> Repo.preload([:category, :user, :stream, :subcategory, :tags])
end

def count_live_followed_channels_that_are_hosting(%User{} = user) do
from(c in Channel,
left_join: f in Follower,
on: c.user_id == f.streamer_id,
join: ch in ChannelHosts,
on: c.id == ch.hosting_channel_id,
join: target in Channel,
on: target.id == ch.target_channel_id,
left_join: target_followers in Follower,
on: target.user_id == target_followers.streamer_id,
where: ch.status == "hosting",
where: target.status == "live",
where: f.user_id == ^user.id,
where: target_followers.user_id != ^user.id or is_nil(target_followers.user_id),
distinct: target.id,
select: [target]
)
|> Repo.all()
|> length()
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've noted the following method in my "Performance Considerations" section. This query mostly utilizes joins on indexes but I have not been able to test it with a large dataset.

def list_live_followed_channels_and_hosts(%User{} = user) do
include_hosts_query =
from(c in Channel,
left_join: f in Follower,
on: c.user_id == f.streamer_id,
join: ch in ChannelHosts,
on: c.id == ch.hosting_channel_id,
join: target in Channel,
on: target.id == ch.target_channel_id,
left_join: target_followers in Follower,
on: target.user_id == target_followers.streamer_id,
where: ch.status == "hosting",
where: target.status == "live",
where: f.user_id == ^user.id,
where: target_followers.user_id != ^user.id or is_nil(target_followers.user_id),
distinct: target.id,
select: [target],
select_merge: %{match_type: "hosting"}
)

live_followed_query =
from([c] in Channel,
join: f in Follower,
on: c.user_id == f.streamer_id,
where: c.status == "live",
where: f.user_id == ^user.id,
select_merge: %{match_type: "live"}
)

query = live_followed_query |> union_all(^include_hosts_query)

Repo.all(query)
|> Repo.preload([:user, :category, :stream, :subcategory, :tags])
end

def list_all_followed_channels(user) do
Repo.all(
from c in Channel,
Expand Down Expand Up @@ -202,4 +258,38 @@ defmodule Glimesh.ChannelLookups do
def get_any_channel_for_user(user) do
Repo.one(from c in Channel, where: c.user_id == ^user.id)
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following method is used by the add hosts text input box type-ahead code. Since it takes raw user input, I attempt to sanitize that input against '\', '_', and '%' characters.

def search_hostable_channels_by_name(hosting_user, target_name) do
if target_name != nil and String.length(target_name) < 25 do
search_term = Regex.replace(~r/(\\\\|_|%)/, target_name, "\\\\\\1") <> "%"

Repo.all(
from c in Channel,
join: u in User,
on: c.user_id == u.id,
where: ilike(u.displayname, ^search_term),
where: c.allow_hosting == true,
where: c.inaccessible == false,
where: c.user_id != ^hosting_user.id,
where: u.is_banned == false,
where: u.can_stream == true,
where:
fragment(
"not exists(select user_id from channel_bans where user_id = ? and channel_id = ? and expires_at is null)",
^hosting_user.id,
c.id
),
where:
fragment(
"not exists(select target_channel_id from channel_hosts where target_channel_id = ?)",
c.id
),
order_by: [asc: u.displayname],
limit: 10
)
|> Repo.preload([:user])
else
[]
end
end
end
Loading