diff --git a/config/config.exs b/config/config.exs index 6b119132..2a3caee1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,6 +18,7 @@ config :philomena, advert_url_root: "/spns", badge_url_root: "/media", tag_url_root: "/media", + channel_url_root: "/media", image_file_root: "priv/static/system/images", cdn_host: "", proxy_host: nil, diff --git a/config/prod.secret.exs b/config/prod.secret.exs index 87dfe95f..704848b0 100644 --- a/config/prod.secret.exs +++ b/config/prod.secret.exs @@ -16,6 +16,7 @@ config :bcrypt_elixir, config :philomena, anonymous_name_salt: System.get_env("ANONYMOUS_NAME_SALT"), + channel_url_root: System.get_env("CHANNEL_URL_ROOT"), password_pepper: System.get_env("PASSWORD_PEPPER"), avatar_url_root: System.get_env("AVATAR_URL_ROOT"), advert_url_root: System.get_env("ADVERT_URL_ROOT"), diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex index 2d8a96db..1c86ccdd 100644 --- a/lib/philomena/channels.ex +++ b/lib/philomena/channels.ex @@ -7,6 +7,7 @@ defmodule Philomena.Channels do alias Philomena.Repo alias Philomena.Channels.Channel + alias Philomena.Notifications @doc """ Returns the list of channels. @@ -104,35 +105,6 @@ defmodule Philomena.Channels do alias Philomena.Channels.Subscription - @doc """ - Returns the list of channel_subscriptions. - - ## Examples - - iex> list_channel_subscriptions() - [%Subscription{}, ...] - - """ - def list_channel_subscriptions do - Repo.all(Subscription) - end - - @doc """ - Gets a single subscription. - - Raises `Ecto.NoResultsError` if the Subscription does not exist. - - ## Examples - - iex> get_subscription!(123) - %Subscription{} - - iex> get_subscription!(456) - ** (Ecto.NoResultsError) - - """ - def get_subscription!(id), do: Repo.get!(Subscription, id) - @doc """ Creates a subscription. @@ -145,28 +117,11 @@ defmodule Philomena.Channels do {:error, %Ecto.Changeset{}} """ - def create_subscription(attrs \\ %{}) do - %Subscription{} - |> Subscription.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a subscription. - - ## Examples - - iex> update_subscription(subscription, %{field: new_value}) - {:ok, %Subscription{}} - - iex> update_subscription(subscription, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_subscription(%Subscription{} = subscription, attrs) do - subscription - |> Subscription.changeset(attrs) - |> Repo.update() + def create_subscription(_channel, nil), do: {:ok, nil} + def create_subscription(channel, user) do + %Subscription{channel_id: channel.id, user_id: user.id} + |> Subscription.changeset(%{}) + |> Repo.insert(on_conflict: :nothing) end @doc """ @@ -181,20 +136,22 @@ defmodule Philomena.Channels do {:error, %Ecto.Changeset{}} """ - def delete_subscription(%Subscription{} = subscription) do - Repo.delete(subscription) + def delete_subscription(channel, user) do + clear_notification(channel, user) + + %Subscription{channel_id: channel.id, user_id: user.id} + |> Repo.delete() end - @doc """ - Returns an `%Ecto.Changeset{}` for tracking subscription changes. + def subscribed?(_channel, nil), do: false + def subscribed?(channel, user) do + Subscription + |> where(channel_id: ^channel.id, user_id: ^user.id) + |> Repo.exists?() + end - ## Examples - - iex> change_subscription(subscription) - %Ecto.Changeset{source: %Subscription{}} - - """ - def change_subscription(%Subscription{} = subscription) do - Subscription.changeset(subscription, %{}) + def clear_notification(channel, user) do + Notifications.delete_unread_notification("Channel", channel.id, user) + Notifications.delete_unread_notification("LivestreamChannel", channel.id, user) end end diff --git a/lib/philomena_web/controllers/channel_controller.ex b/lib/philomena_web/controllers/channel_controller.ex new file mode 100644 index 00000000..16de88d3 --- /dev/null +++ b/lib/philomena_web/controllers/channel_controller.ex @@ -0,0 +1,30 @@ +defmodule PhilomenaWeb.ChannelController do + use PhilomenaWeb, :controller + + alias Philomena.Channels + alias Philomena.Channels.Channel + alias Philomena.Repo + import Ecto.Query + + plug :load_resource, model: Channel + + def index(conn, _params) do + channels = + Channel + |> where([c], c.nsfw == false and not is_nil(c.last_fetched_at)) + |> order_by(desc: :is_live, asc: :title) + |> preload(:associated_artist_tag) + |> Repo.paginate(conn.assigns.scrivener) + + render(conn, "index.html", layout_class: "layout--wide", channels: channels) + end + + def show(conn, _params) do + channel = conn.assigns.channel + user = conn.assigns.current_user + + if user, do: Channels.clear_notification(channel, user) + + redirect(conn, external: channel.url) + end +end diff --git a/lib/philomena_web/plugs/channel_plug.ex b/lib/philomena_web/plugs/channel_plug.ex new file mode 100644 index 00000000..51d683d7 --- /dev/null +++ b/lib/philomena_web/plugs/channel_plug.ex @@ -0,0 +1,18 @@ +defmodule PhilomenaWeb.ChannelPlug do + alias Plug.Conn + alias Philomena.Channels.Channel + alias Philomena.Repo + import Ecto.Query + + def init([]), do: [] + + def call(conn, _opts) do + live_channels = + Channel + |> where(is_live: true) + |> Repo.aggregate(:count, :id) + + conn + |> Conn.assign(:live_channels, live_channels) + end +end \ No newline at end of file diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 60161b06..c5f2e281 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -18,6 +18,7 @@ defmodule PhilomenaWeb.Router do plug PhilomenaWeb.SiteNoticePlug plug PhilomenaWeb.ForumListPlug plug PhilomenaWeb.FilterSelectPlug + plug PhilomenaWeb.ChannelPlug end pipeline :api do @@ -134,6 +135,7 @@ defmodule PhilomenaWeb.Router do resources "/dnp", DnpEntryController, only: [:index, :show] resources "/staff", StaffController, only: [:index] resources "/stats", StatController, only: [:index] + resources "/channels", ChannelController, only: [:index, :show] get "/:id", ImageController, :show # get "/:forum_id", ForumController, :show # impossible to do without constraints diff --git a/lib/philomena_web/templates/channel/_channel_box.html.slime b/lib/philomena_web/templates/channel/_channel_box.html.slime new file mode 100644 index 00000000..438097a8 --- /dev/null +++ b/lib/philomena_web/templates/channel/_channel_box.html.slime @@ -0,0 +1,34 @@ +- link_class = "media-box__header media-box__header--channel media-box__header--link" + +.media-box + a.media-box__header.media-box__header--channel.media-box__header--link href=Routes.channel_path(@conn, :show, @channel) title=@channel.title + = @channel.title || @channel.short_name + + .media-box__header.media-box__header--channel + = if @channel.is_live do + .spacing-right.label.label--success.label--block.label--small: strong LIVE NOW + => @channel.viewers + => pluralize "viewer", "viewers", @channel.viewers + - else + .label.label--danger.label--block.label--small: strong OFF AIR + + = if @channel.nsfw do + .media-box__overlay + | NSFW + + .media-box__content.media-box__content--channel + a href=Routes.channel_path(@conn, :show, @channel) + .image-constrained.media-box__content--channel + img src=channel_image(@channel) alt="#{@channel.title}" + + = if @channel.associated_artist_tag do + a href=Routes.tag_path(@conn, :show, @channel.associated_artist_tag) class=link_class + i.fa.fa-fw.fa-tags> + = @channel.associated_artist_tag.name + - else + .media-box__header.media-box__header--channel No artist tag + + /- if current_user + / = subscription_link channel, current_user, class: link_class + /- else + / .media-box__header.media-box__header--channel Login to subscribe diff --git a/lib/philomena_web/templates/channel/index.html.slime b/lib/philomena_web/templates/channel/index.html.slime new file mode 100644 index 00000000..1cb88ab1 --- /dev/null +++ b/lib/philomena_web/templates/channel/index.html.slime @@ -0,0 +1,30 @@ +h1 Livestreams + +- route = fn p -> Routes.channel_path(@conn, :index, p) end +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @channels, route: route, conn: @conn + +/= form_tag channels_path, method: :get, class: 'hform', enforce_utf8: false do + .field + = text_field_tag :cq, params[:cq], class: 'input hform__text', placeholder: 'Search channels' + = submit_tag 'Search', class: 'hform__button button' +.block + .block__header + = pagination + + .block__content + = for channel <- @channels do + = render PhilomenaWeb.ChannelView, "_channel_box.html", channel: channel, conn: @conn + + .block__header + = pagination + +br +.clearfix   +h2 FAQ +p + strong> Q: Do you host streams? + | A: No, we cheat and just link to streams on Livestream/Picarto since that's where (almost) everyone is already. This is simply a nice way to track streaming artists. +p + strong> Q: How do I get my stream/a friend's stream/'s stream here? + ' A: Send a private message to a site administrator + ' with a link to the stream and the artist tag if applicable. diff --git a/lib/philomena_web/templates/layout/_header_navigation.html.slime b/lib/philomena_web/templates/layout/_header_navigation.html.slime index 02217c8e..300b6a6e 100644 --- a/lib/philomena_web/templates/layout/_header_navigation.html.slime +++ b/lib/philomena_web/templates/layout/_header_navigation.html.slime @@ -30,6 +30,11 @@ | Post Search a.header__link href="/tags" | Tags + a.header__link href="/channels" + ' Live + span.header__counter + = @conn.assigns.live_channels + a.header__link href='/galleries' | Galleries a.header__link href='/commissions' diff --git a/lib/philomena_web/views/channel_view.ex b/lib/philomena_web/views/channel_view.ex new file mode 100644 index 00000000..85290217 --- /dev/null +++ b/lib/philomena_web/views/channel_view.ex @@ -0,0 +1,24 @@ +defmodule PhilomenaWeb.ChannelView do + use PhilomenaWeb, :view + + def channel_image(%{channel_image: image, is_live: false}) when image not in [nil, ""], + do: channel_url_root() <> "/" <> image + + def channel_image(%{type: "LivestreamChannel", short_name: short_name}) do + now = DateTime.utc_now() |> DateTime.to_unix(:microsecond) + Camo.Image.image_url("https://thumbnail.api.livestream.com/thumbnail?name=#{short_name}&rand=#{now}") + end + + def channel_image(%{type: "PicartoChannel", thumbnail_url: thumbnail_url}), + do: Camo.Image.image_url(thumbnail_url || "https://picarto.tv/images/missingthumb.jpg") + + def channel_image(%{type: "PiczelChannel", remote_stream_id: remote_stream_id}), + do: Camo.Image.image_url("https://piczel.tv/api/thumbnail/stream_#{remote_stream_id}.jpg") + + def channel_image(%{type: "TwitchChannel", short_name: short_name}), + do: Camo.Image.image_url("https://static-cdn.jtvnw.net/previews-ttv/live_user_#{String.downcase(short_name)}-320x180.jpg") + + defp channel_url_root do + Application.get_env(:philomena, :channel_url_root) + end +end diff --git a/test/philomena_web/controllers/channel_controller_test.exs b/test/philomena_web/controllers/channel_controller_test.exs new file mode 100644 index 00000000..f1b9b008 --- /dev/null +++ b/test/philomena_web/controllers/channel_controller_test.exs @@ -0,0 +1,88 @@ +defmodule PhilomenaWeb.ChannelControllerTest do + use PhilomenaWeb.ConnCase + + alias Philomena.Channels + + @create_attrs %{} + @update_attrs %{} + @invalid_attrs %{} + + def fixture(:channel) do + {:ok, channel} = Channels.create_channel(@create_attrs) + channel + end + + describe "index" do + test "lists all channels", %{conn: conn} do + conn = get(conn, Routes.channel_path(conn, :index)) + assert html_response(conn, 200) =~ "Listing Channels" + end + end + + describe "new channel" do + test "renders form", %{conn: conn} do + conn = get(conn, Routes.channel_path(conn, :new)) + assert html_response(conn, 200) =~ "New Channel" + end + end + + describe "create channel" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, Routes.channel_path(conn, :create), channel: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == Routes.channel_path(conn, :show, id) + + conn = get(conn, Routes.channel_path(conn, :show, id)) + assert html_response(conn, 200) =~ "Show Channel" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, Routes.channel_path(conn, :create), channel: @invalid_attrs) + assert html_response(conn, 200) =~ "New Channel" + end + end + + describe "edit channel" do + setup [:create_channel] + + test "renders form for editing chosen channel", %{conn: conn, channel: channel} do + conn = get(conn, Routes.channel_path(conn, :edit, channel)) + assert html_response(conn, 200) =~ "Edit Channel" + end + end + + describe "update channel" do + setup [:create_channel] + + test "redirects when data is valid", %{conn: conn, channel: channel} do + conn = put(conn, Routes.channel_path(conn, :update, channel), channel: @update_attrs) + assert redirected_to(conn) == Routes.channel_path(conn, :show, channel) + + conn = get(conn, Routes.channel_path(conn, :show, channel)) + assert html_response(conn, 200) + end + + test "renders errors when data is invalid", %{conn: conn, channel: channel} do + conn = put(conn, Routes.channel_path(conn, :update, channel), channel: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Channel" + end + end + + describe "delete channel" do + setup [:create_channel] + + test "deletes chosen channel", %{conn: conn, channel: channel} do + conn = delete(conn, Routes.channel_path(conn, :delete, channel)) + assert redirected_to(conn) == Routes.channel_path(conn, :index) + assert_error_sent 404, fn -> + get(conn, Routes.channel_path(conn, :show, channel)) + end + end + end + + defp create_channel(_) do + channel = fixture(:channel) + {:ok, channel: channel} + end +end