XPost Categorization and acceptance progress

This commit is contained in:
CJ_Clippy 2025-03-14 21:22:12 -08:00
parent f5451ce52c
commit 80e9fbb13d
41 changed files with 1186 additions and 111 deletions

@ -3,6 +3,7 @@
"redhat.vscode-yaml",
"elixir-lsp.elixir-ls",
"jetify.devbox",
"redhat.ansible"
"redhat.ansible",
"dotjoshjohnson.xml"
]
}

@ -1,4 +1,3 @@
@import "bulma";
@import "variables";
@ -9,11 +8,16 @@
.is-unclickable {
.is-unclickable {
cursor: not-allowed
}
.phx-click-loading {
opacity: 15%;
}
svg {
color: "lime"
}

Binary file not shown.

Before

(image error) Size: 148 KiB

Before

Width: 64px  |  Height: 64px  |  Size: 323 B

After

(image error) Size: 323 B

@ -32,7 +32,8 @@ config :bright, Oban,
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(30)},
{Oban.Plugins.Cron,
crontab: [
{"*/15 * * * *", Bright.ObanWorkers.ReadPosts}
{"*/10 * * * *", Bright.ObanWorkers.ScrapePosts},
{"*/1 * * * *", Bright.ObanWorkers.ProcessPosts}
]}
]

@ -28,7 +28,8 @@ config :bright,
aws_region: System.get_env("AWS_REGION"),
public_s3_endpoint: System.get_env("PUBLIC_S3_ENDPOINT"),
site_url: System.get_env("SITE_URL"),
cache_dir: System.get_env("CACHE_DIR")
cache_dir: System.get_env("CACHE_DIR"),
vultr_ai_api_key: System.get_env("VULTR_AI_API_KEY")
config :bright, :torrent,
tracker_url: System.get_env("TRACKER_URL"),

@ -10,11 +10,11 @@ config :bcrypt_elixir, :log_rounds, 1
# Run `mix help test` for more information.
config :bright, Bright.Repo,
database: System.get_env("DB_NAME", "bright"),
# database: "bright_test#{System.get_env("MIX_TEST_PARTITION")}",
hostname: System.get_env("DB_HOST", "localhost"),
username: System.get_env("DB_USER", "postgres"),
password: System.get_env("DB_PASS", "password"),
port: System.get_env("DB_PORT", "5433"),
# database: "bright_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 4

@ -0,0 +1,10 @@
Please name jobs as if you were speaking with a human.
## Bad example
posts_read.ex
## Good Example
read_posts.ex

@ -0,0 +1,141 @@
defmodule Bright.ObanWorkers.ProcessPosts do
@moduledoc """
Read the social media posts we have in the db, and create Stream records for each post with a livestream invitation.
"""
use Oban.Worker, queue: :default, max_attempts: 3
alias Bright.Vtubers.Vtuber
alias Bright.Repo
alias Bright.Socials.XPost
alias Bright.Streams.Stream
alias Bright.Platforms.Platform
import Ecto.Query
require Logger
# 1. get unprocessed posts
# 2. process each post
# * get platforms mentioned
# * find any reason to determine the post is NOT a NSFW livestream announcement
# 3. for each nsfw livestream announcement post, create a stream
@impl Oban.Worker
def perform(%Oban.Job{args: %{}}) do
Logger.info(">>>> Process Posts is performing.")
known_platforms = Repo.all(Platform)
{num, nil} =
get_unprocessed_posts()
|> then(fn posts ->
if posts == [] do
Logger.info("No unprocessed posts found")
else
Logger.debug("#{length(posts)} unprocessed posts found.")
Enum.each(posts, &process_post(&1, known_platforms))
mark_posts_as_processed(posts)
end
end)
{:ok, num}
end
def process_post(post, known_platforms) do
with platforms <- XPost.get_platforms_mentioned(post, known_platforms),
true <- is_nsfw_live_annoucement?(post, platforms, known_platforms),
{:ok, _stream} <- create_stream(post, platforms) do
:ok
else
_ -> :ok
end
end
def get_unprocessed_posts() do
XPost
|> where([p], is_nil(p.processed_at))
|> preload(:vtuber)
|> Repo.all()
end
@doc """
create streams for each announcement post
"""
def create_stream(post, platforms) do
vtuber = post.vtuber
date = post |> Map.get(:date)
title = "#{post.vtuber.display_name} #{date}"
Logger.debug(
"WE ARE CREATING A Stream with platforms=#{inspect(platforms)} title=#{inspect(title)} with date=#{inspect(date)} post=#{inspect(post)}"
)
changeset =
%Stream{}
|> Stream.changeset(%{title: title, date: date})
|> Ecto.Changeset.put_assoc(:platforms, platforms)
|> Ecto.Changeset.put_assoc(:vtubers, [vtuber])
|> Ecto.Changeset.put_assoc(:x_post, post)
case Repo.insert(changeset) do
{:ok, stream} ->
Logger.info("Created stream: #{inspect(stream)}")
{:error, %Ecto.Changeset{errors: [date: {"has already been taken", _}]}} ->
Logger.warn("Stream #{title} already exists. Skipping...")
{:error, changeset} ->
Logger.error(
"Failed to create stream: #{inspect(changeset)} #{inspect(changeset.errors)}"
)
end
end
defp mark_posts_as_processed(posts) when length(posts) > 0 do
post_ids = Enum.map(posts, & &1.id)
from(p in XPost, where: p.id in ^post_ids)
|> Repo.update_all(set: [processed_at: DateTime.utc_now(:second)])
end
# No posts to update
defp mark_posts_as_processed(_), do: :ok
@doc """
Is the post a valid NSFW livestream announcement?
Eligibility requirements.
To be considered a NSFW live announcement, a post must satisfy all the following conditions.
* The post is authored by the lewdtuber
* The post mentions a NSFW platform
* The post does not contain any URLs to SFW streaming platforms.
"""
def is_nsfw_live_annoucement?(%XPost{vtuber: vtuber} = post, platforms, known_platforms) do
Logger.debug("Checking if post is NSFW live announcement: #{inspect(post)}")
nsfw_platforms = Enum.filter(known_platforms, & &1.nsfw?)
sfw_platforms = Enum.reject(known_platforms, & &1.nsfw?)
conditions = [
{:authored_by_vtuber?, not is_nil(vtuber)},
{:contains_nsfw_link?,
Enum.any?(platforms, fn plat -> Enum.any?(nsfw_platforms, &match_platform?(plat, &1)) end)},
{:no_sfw_link?,
not Enum.any?(platforms, fn plat ->
Enum.any?(sfw_platforms, &match_platform?(plat, &1))
end)}
]
Enum.reduce_while(conditions, true, fn {label, condition}, _acc ->
if condition do
{:cont, true}
else
Logger.debug("NSFW announcement check failed at: #{label}")
{:halt, false}
end
end)
end
defp match_platform?(plat, platform), do: String.contains?(plat, &URI.parse(&1.url).hostname)
end

@ -1,8 +1,6 @@
defmodule Bright.ObanWorkers.ReadPosts do
defmodule Bright.ObanWorkers.ScrapePosts do
@moduledoc """
Read a vtuber's social media feed and save the posts to the db
* [ ] X
"""
alias Bright.Vtubers.Vtuber
@ -15,7 +13,7 @@ defmodule Bright.ObanWorkers.ReadPosts do
@impl Oban.Worker
def perform(%Oban.Job{args: %{}}) do
Logger.info(">>>> Save Posts is performing.")
Logger.info(">>>> Scrape Posts is performing.")
vtubers = Repo.all(Vtuber)
Logger.debug("there are #{length(vtubers)} vtubers.")

@ -5,7 +5,7 @@ defmodule Bright.Platforms do
import Ecto.Query, warn: false
alias Bright.Repo
alias Bright.Platforms.PlatformAlias
alias Bright.Platforms.Platform
@doc """
@ -19,6 +19,7 @@ defmodule Bright.Platforms do
"""
def list_platforms do
Repo.all(Platform)
|> Repo.preload([:platform_aliases])
end
@doc """
@ -35,7 +36,11 @@ defmodule Bright.Platforms do
** (Ecto.NoResultsError)
"""
def get_platform!(id), do: Repo.get!(Platform, id)
def get_platform!(id) do
Platform
|> Repo.get!(Platform, id)
|> Repo.preload([:platform_aliases])
end
@doc """
Creates a platform.
@ -69,6 +74,7 @@ defmodule Bright.Platforms do
"""
def update_platform(%Platform{} = platform, attrs) do
platform
|> Repo.preload([:platform_aliases])
|> Platform.changeset(attrs)
|> Repo.update()
end
@ -101,4 +107,22 @@ defmodule Bright.Platforms do
def change_platform(%Platform{} = platform, attrs \\ %{}) do
Platform.changeset(platform, attrs)
end
@doc """
Creates a platform alias.
## Examples
iex> create_platform_alias(%{field: value})
{:ok, %PlatformAlias{}}
iex> create_platform(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_platform_alias(attrs \\ %{}) do
%PlatformAlias{}
|> PlatformAlias.changeset(attrs)
|> Repo.insert()
end
end

@ -4,8 +4,11 @@ defmodule Bright.Platforms.Platform do
schema "platforms" do
field :name, :string
field :slug, :string
field :url, :string
field :icon, :string
field :nsfw, :boolean
has_many :platform_aliases, Bright.Platforms.PlatformAlias
timestamps(type: :utc_datetime)
end
@ -13,7 +16,7 @@ defmodule Bright.Platforms.Platform do
@doc false
def changeset(platform, attrs) do
platform
|> cast(attrs, [:name, :url, :icon])
|> validate_required([:name, :url, :icon])
|> cast(attrs, [:name, :url, :slug])
|> validate_required([:name, :url, :slug])
end
end

@ -0,0 +1,18 @@
defmodule Bright.Platforms.PlatformAlias do
use Ecto.Schema
import Ecto.Changeset
schema "platform_aliases" do
field :url, :string
field :platform_id, :id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(platform_alias, attrs) do
platform_alias
|> cast(attrs, [:url, :platform_id])
|> validate_required([:url, :platform_id])
end
end

@ -0,0 +1,19 @@
defmodule Bright.Platforms.PlatformAlias do
use Ecto.Schema
import Ecto.Changeset
schema "platform_aliases" do
field :url, :string
belongs_to :platform, Bright.Platforms.Platform
timestamps(type: :utc_datetime)
end
@doc false
def changeset(platform_alias, attrs) do
platform_alias
|> cast(attrs, [:url, :platform_id])
|> validate_required([:url, :platform_id])
end
end

@ -1,23 +1,24 @@
defmodule Bright.Socials.XPost do
use Ecto.Schema
import Ecto.Changeset
import Ecto.{Changeset, Query}
alias Bright.Repo
alias Bright.Vtubers.Vtuber
alias Bright.Socials.RSSParser
alias Bright.Socials.{XPost, RSSParser}
alias Bright.Platforms.Platform
alias Quinn
require Logger
@livestream_domains ["chaturbate.com", "fansly.com", "onlyfans.com"]
@doc """
We cache the posts in the db so it's clear which tweets we've read and which ones we haven't.
We only parse tweets which haven't been cached.
The idea is to process only uncached posts.
"""
schema "x_posts" do
field :raw, :string
field :url, :string
field :date, :utc_datetime
field :is_invitation, :boolean
field :processed_at, :utc_datetime
belongs_to :stream, Stream
belongs_to :vtuber, Bright.Vtubers.Vtuber
timestamps(type: :utc_datetime)
@ -26,9 +27,10 @@ defmodule Bright.Socials.XPost do
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:raw, :url, :date, :vtuber_id])
|> validate_required([:raw, :url, :date])
|> unique_constraint([:date, :url])
|> cast(attrs, [:raw, :url, :date, :vtuber_id, :processed_at])
|> validate_required([:raw, :url, :date, :vtuber_id])
|> unique_constraint(:date)
|> unique_constraint(:url)
end
@doc """
@ -64,19 +66,57 @@ defmodule Bright.Socials.XPost do
end
end
@doc """
save the posts to the db
"""
def save_posts(posts) do
Logger.debug("@todo implement save_posts()")
def extract_hostname(url) do
uri = URI.parse(url)
uri.host || ""
end
def includes_alias?(%XPost{raw: raw}, platform), do: includes_alias?(raw, platform)
def includes_alias?(raw, platform) do
case Map.get(platform, :platform_aliases, []) do
[] -> false
aliases -> Enum.any?(aliases, fn alias -> raw =~ extract_hostname(alias.url) end)
end
end
@doc """
return true if there is a livestream invitation in the post, false otherwise
Checks if the given raw text or XPost includes a reference to the specified platform.
The function checks for matches against the platform's hostname or its aliases.
## Parameters
- raw_text: The raw text or XPost to search within.
- platform: The %Platform{} struct containing the URL and aliases to match against.
## Returns
- `true` if the platform's hostname or any of its aliases are found in the raw text.
- `false` otherwise.
"""
def find_livestream_invitation(%__MODULE__{raw: raw}) do
Enum.any?(@livestream_domains, fn domain ->
String.downcase(raw) =~ ~r/#{domain}\/[^\s]*/
end)
def includes_platform?(%XPost{raw: raw}, platform) do
includes_platform?(raw, platform)
end
def includes_platform?(raw_text, %Platform{url: url} = platform)
when is_binary(raw_text) and is_binary(url) do
hostname_match = raw_text =~ extract_hostname(url)
alias_match = includes_alias?(raw_text, platform)
hostname_match || alias_match
end
def includes_platform?(_, _), do: false
def get_platforms_mentioned(%XPost{raw: raw}, [%Platform{} = platforms]) do
get_platforms_mentioned(raw, platforms)
end
def get_platforms_mentioned(raw_text, platforms) do
Enum.filter(platforms, &includes_platform?(raw_text, &1))
end
end
defimpl Phoenix.HTML.Safe, for: Bright.Socials.XPost do
def to_iodata(x_post) do
Phoenix.HTML.Safe.to_iodata("#{x_post.raw} -- #{x_post.url} -- #{x_post.date}")
end
end

@ -11,6 +11,7 @@ defmodule Bright.Streams do
alias Bright.Vtubers.Vtuber
alias Bright.Tags.Tag
alias Bright.Platforms.Platform
alias Bright.Socials.XPost
alias Bright.{
Cache,
@ -34,7 +35,7 @@ defmodule Bright.Streams do
def list_streams do
Stream
|> Repo.all()
|> Repo.preload([:tags, :vods, :vtubers, :platforms])
|> Repo.preload([:tags, :vods, :vtubers, :platforms, :x_post])
end
@doc """
@ -54,7 +55,7 @@ defmodule Bright.Streams do
def get_stream!(id) do
Stream
|> Repo.get!(id)
|> Repo.preload([:tags, :vods, :vtubers, :platforms])
|> Repo.preload([:tags, :vods, :vtubers, :platforms, :x_post])
end
@doc """
@ -126,7 +127,7 @@ defmodule Bright.Streams do
platforms = list_platforms_by_id(attrs["platform_ids"])
stream
|> Repo.preload([:tags, :vods, :vtubers])
|> Repo.preload([:tags, :vods, :vtubers, :x_post])
|> Stream.changeset(attrs)
|> Ecto.Changeset.put_assoc(:tags, tags)
|> Ecto.Changeset.put_assoc(:vods, vods)

@ -4,12 +4,14 @@ defmodule Bright.Streams.Stream do
alias Bright.Tags.Tag
alias Bright.Vtubers.Vtuber
alias Bright.Platforms.Platform
alias Bright.Socials.XPost
schema "streams" do
field :date, :utc_datetime
field :title, :string
field :notes, :string
has_one :x_post, XPost
many_to_many :tags, Tag, join_through: "streams_tags", on_replace: :delete
many_to_many :vtubers, Vtuber, join_through: "streams_vtubers", on_replace: :delete
many_to_many :platforms, Platform, join_through: "streams_platforms", on_replace: :delete
@ -24,5 +26,6 @@ defmodule Bright.Streams.Stream do
stream
|> cast(attrs, [:title, :notes, :date])
|> validate_required([:title, :date])
|> unique_constraint([:date])
end
end

@ -0,0 +1,131 @@
defmodule Bright.VultrAI do
require Logger
@model "mistral-7b-v0.3"
@chat_endpoint "https://api.vultrinference.com/v1/chat/completions"
@doc """
This is not in-use due to less than stellar results. Keeping for future reference.
"""
def parse_social_post(raw_text, known_platforms) do
system_prompt = """
You are a social media post parser specializing in identifying livestream invitations and the platforms they are taking place on.
Analyze the following tweet and extract relevant information according to the JSON schema provided. Your response must be **valid JSON**.
## **Rules:**
1. **Title:** Use a short, relevant snippet from the tweet that represents the livestream event. If no livestream is mentioned, set the title as an empty string (`""`).
2. **Platforms:** Identify any livestream platforms mentioned in the tweet from this predefined list:
#{known_platforms}
- If none are found, return an empty array (`[]`).
## **Expected Response Schema**
#{expected_schema(known_platforms)}
"""
user_prompt = raw_text
request(@chat_endpoint, @model, system_prompt, user_prompt)
end
def expected_schema(known_platforms) do
%{
"type" => "object",
"properties" => %{
"title" => %{
"type" => "string",
"minLength" => 0
},
"platforms" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => known_platforms
},
"minItems" => 0,
"uniqueItems" => true
}
},
"required" => ["title", "platforms"],
"additionalProperties" => false
}
|> Jason.encode!()
end
def request(endpoint, model, system_prompt, user_prompt) do
api_key = Application.get_env(:bright, :vultr_ai_api_key)
headers = [
{"Authorization", "Bearer #{api_key}"},
{"Content-Type", "application/json"},
{"Accept", "Application/json; Charset=utf-8"}
]
body =
%{
messages: [
%{
role: "system",
content: system_prompt
},
%{
role: "user",
content: user_prompt
}
],
model: model,
stream: false,
max_tokens: 512,
n: 1,
seed: 0,
temperature: 1,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
logprobs: false
}
|> Jason.encode!()
case(HTTPoison.post(endpoint, body, headers)) do
{:ok, %HTTPoison.Response{status_code: 200, body: response_body}} ->
Logger.info("Successful VultrAI")
parse_response(response_body)
{:ok, %HTTPoison.Response{status_code: status_code, body: error_body}} ->
Logger.error("Failed VultrAI status=#{status_code}, error=#{error_body}")
{:error, %{status: status_code, body: error_body}}
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("VultrAI HTTP request failed, reason=#{inspect(reason)}")
{:error, reason}
end
end
defp parse_response(response_body) do
with {:ok, decoded} <- Jason.decode(response_body),
{:ok, raw_content} <- extract_content(decoded),
{:ok, parsed_content} <- Jason.decode(raw_content) do
Logger.info("Successful VultrAI response")
Logger.debug(parsed_content)
{:ok, parsed_content}
else
error ->
log_and_return_error("Failed to parse response", error)
end
end
defp extract_content(%{"choices" => [%{"message" => %{"content" => content}} | _]}) do
{:ok, content}
end
defp extract_content(_), do: {:error, :invalid_response_format}
defp log_and_return_error(message, details) do
Logger.error("#{message}: #{inspect(details)}")
{:error, details}
end
end

@ -85,6 +85,7 @@ defmodule BrightWeb do
import Phoenix.HTML
# Core UI components and translation
import BrightWeb.CoreComponents
import BrightWeb.SVGIcon
import BrightWeb.Gettext
# Shortcut for generating JS commands

@ -18,6 +18,7 @@ defmodule BrightWeb.CoreComponents do
use Gettext, backend: BrightWeb.Gettext
alias Phoenix.LiveView.JS
import BrightWeb.SVGIcon
@doc """
Renders an external link.
@ -181,7 +182,7 @@ defmodule BrightWeb.CoreComponents do
>
{gettext("Attempting to reconnect")}
<!--<.icon name={:hero_arrow_path} class="ml-1 h-3 w-3 animate-spin" />-->
<.icon name="academic_cap" class="h-4 w-4" />
<.icon name="graduation_cap" class="h-4 w-4" />
</.flash>
<.flash
@ -594,7 +595,7 @@ defmodule BrightWeb.CoreComponents do
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<span class="icon-text mt-3">
<.icon name="arrow-left" class="icon" />
<.icon name="arrow_left" class="icon" />
{render_slot(@inner_block)}
</span>
</.link>
@ -602,36 +603,36 @@ defmodule BrightWeb.CoreComponents do
"""
end
@doc """
Renders a [FontAwesom Icon](https://fontawesome.com/).
# @doc """
# Renders a [FontAwesome Icon](https://fontawesome.com/).
Icons are extracted from the `assets` directory and bundled within
your compiled app.css by SASS.
# Icons are extracted from the `assets` directory and bundled within
# your compiled app.css by SASS.
## Examples
# ## Examples
<.icon name="shopping-cart" />
<.icon name="home" style="solid" />
<.icon name="spinner" class="ml-1 w-3 h-3 animate-spin" />
"""
# <.icon name="shopping-cart" />
# <.icon name="home" style="solid" />
# <.icon name="spinner" class="ml-1 w-3 h-3 animate-spin" />
# """
# # attr :name, :string, required: true
# # attr :class, :string, default: nil
# attr :rest, :global,
# doc: "the arbitrary HTML attributes for the svg container",
# include: ~w(fill stroke stroke-width)
# attr :name, :string, required: true
# attr :class, :string, default: nil
# # can be "solid", "brands"
# attr :style, :string, default: "solid"
# attr :class, :string, default: ""
attr :rest, :global,
doc: "the arbitrary HTML attributes for the svg container",
include: ~w(fill stroke stroke-width)
attr :name, :string, required: true
# can be "solid", "brands"
attr :style, :string, default: "solid"
attr :class, :string, default: ""
def icon(assigns) do
~H"""
<i class={"fa#{style_prefix(@style)} fa-#{@name} #{@class}"}></i>
"""
end
# def icon(assigns) do
# ~H"""
# <i class={"fa#{style_prefix(@style)} fa-#{@name} #{@class}"}></i>
# """
# end
defp style_prefix("solid"), do: "s"
defp style_prefix("brands"), do: "b"

@ -51,7 +51,7 @@
<div class="navbar-end">
<div class="navbar-item">
<.icon name="person-digging" class="is-unclickable" />
<.icon name="person_digging" type="solid" class="h-4 w-4 is-unclickable"/>
<.icon name="hammer" class="is-unclickable" />
</div>
@ -66,10 +66,6 @@
<.link href={~p"/auth/patreon"} method="get" class="navbar-item">
Sign in via Patreon
</.link>
<p>hello</p>
<.link href={~p"/auth/patreon"} class="navbar-item">
Log in
</.link>
<% end %>
</div>
</div>

@ -0,0 +1,122 @@
defmodule BrightWeb.SVGIcon do
@moduledoc """
This package adds a convenient way of using svg icons with your Phoenix, Phoenix LiveView and Surface applications.
greets https://github.com/miguel-s/ex_heroicons/blob/main/lib/heroicons.ex
## Usage
<SVGIcons.icon name="chaturbate" class="h-4 w-4" />
## Config
Defaults can be set in the application configuration.
config :bright, :icons_type: "outline"
"""
use Phoenix.Component
alias BrightWeb.SVGIcon.Icon
svg_icons_path = "priv/static/assets/icons"
unless File.exists?(svg_icons_path) do
raise """
SVG icons not found. Expected to load them from #{svg_icons_path}.
"""
end
icon_paths =
svg_icons_path
|> Path.join("**/*.svg")
|> Path.wildcard()
icons =
for icon_path <- icon_paths do
@external_resource Path.relative_to_cwd(icon_path)
Icon.parse!(icon_path)
end
types = icons |> Enum.map(& &1.type) |> Enum.uniq()
names = icons |> Enum.map(& &1.name) |> Enum.uniq()
default_type =
case Application.compile_env(:bright, :icons_type) do
nil ->
"solid"
type when is_binary(type) ->
if type in types do
type
else
raise ArgumentError,
"expected default type to be one of #{inspect(types)}, got: #{inspect(type)}"
end
type ->
raise ArgumentError,
"expected default type to be one of #{inspect(types)}, got: #{inspect(type)}"
end
@names names
def names, do: @names
@types types
def types, do: @types
attr :name, :string, values: @names, required: true, doc: "the name of the icon"
attr :type, :string, values: @types, default: default_type, doc: "the type of the icon"
attr :class, :string, default: nil, doc: "the css classes to add to the svg container"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the svg container"
def icon(assigns) do
name = assigns[:name]
if name == nil or name not in @names do
raise ArgumentError,
"expected icon name to be one of #{inspect(unquote(@names))}, got: #{inspect(name)}"
end
type = assigns[:type]
if type == nil or type not in @types do
raise ArgumentError,
"expected icon type to be one of #{inspect(unquote(@types))}, got: #{inspect(type)}"
end
~H"""
<span class="icon">
<.svg_container focusable="false" type={@type} class={@class} {@rest}>
<%= {:safe, svg_body(@name, @type)} %>
</.svg_container>
</span>
"""
end
attr :type, :string, values: @types, default: default_type, doc: "the type of the icon"
attr :class, :string, default: nil, doc: "the css classes to add to the svg container"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the svg container"
slot :inner_block, required: true, doc: "the svg to render"
defp svg_container(assigns) do
~H"""
<%= render_slot(@inner_block) %>
"""
end
defp svg_viewbox(type) do
case type do
"micro" -> "0 0 16 16"
"mini" -> "0 0 20 20"
"solid" -> "0 0 24 24"
"outline" -> "0 0 24 24"
end
end
for %Icon{name: name, type: type, file: file} <- icons do
defp svg_body(unquote(name), unquote(type)) do
unquote(file)
end
end
end

@ -0,0 +1,44 @@
defmodule BrightWeb.SVGIcon.Icon do
@moduledoc """
This module defines the data structure and functions for working with icons stored as SVG files.
"""
alias __MODULE__
@doc """
Defines the SVGIcon.Icon struct.
Its fields are:
* `:type` - the type of the icon
* `:name` - the name of the icon
* `:file` - the binary content of the file
"""
defstruct [:type, :name, :file]
@type t :: %Icon{type: String.t(), name: String.t(), file: binary}
@doc "Parses a SVG file and returns structured data"
@spec parse!(String.t()) :: Icon.t()
def parse!(filename) do
[type, name] =
filename
|> Path.split()
|> Enum.take(-2)
|> case do
["solid", name] -> ["solid", name]
["outline", name] -> ["outline", name]
end
name = Path.rootname(name)
file =
filename
|> File.read!()
|> String.split("\n")
|> Enum.map(&String.trim/1)
%__MODULE__{type: type, name: name, file: file}
end
end

@ -8,6 +8,8 @@
</div>
</section>
<div class="section">
<p>
A platform built by fans, for fans, dedicated to preserving the moments that matter in the world of R-18 VTuber live streaming. It all started with a simple need: capturing ProjektMelody's streams on Chaturbate. Chaturbate doesnt save VODs, and sometimes we missed the magic. Other times, creators like ProjektMelody faced unnecessary de-platforming for simply being unique. We wanted to create a space where this content could endure, unshaken by the tides of censorship or fleeting platforms.

@ -6,5 +6,18 @@
</main>
<section class="section">
<p>@todo</p>
<p>@todo documentation</p>
</section>
<div class="section">
<p class="title">icons test</p>
<.icon name="bittorrent" type="solid"/>
<.icon name="hammer" type="solid"/>
<.icon name="fansly" type="solid"/>
<.icon name="reddit" type="solid"/>
<.icon name="chaturbate" type="solid"/>
<.icon name="throne" type="solid"/>
<.icon name="tiktok" type="solid"/>
<.icon name="pornhub" type="solid"/>
</div>

@ -15,7 +15,8 @@
</p>
<i>
<p class="mt-3">
<.icon name="circle-exclamation" class="icon h-4 w-4" />
<% # <.icon name="circle-exclamation" class="icon h-4 w-4" /> %>
(Yeah, were still testing.) check back each week for updates.
</p>
</i>

@ -15,7 +15,8 @@
<div class="columns is-1">
<%= for platform <- stream.platforms do %>
<div class="column is-mobile is-narrow">
{raw(platform.icon)}
<%# {raw(platform.icon)} %>
<.icon name={platform.slug}/>
</div>
<% end %>
</div>

@ -28,6 +28,12 @@
<li :for={vtuber <- @stream.vtubers}>{vtuber.display_name}</li>
</ul>
</:item>
<:item title="Platforms">
<ul>
<li :for={platform <- @stream.platforms}>{platform.name}</li>
</ul>
</:item>
<:item title="Announcement post">{@stream.x_post}</:item>
</.list>
<.back navigate={~p"/streams"}>Back to streams</.back>

@ -0,0 +1,10 @@
defmodule Bright.Repo.Migrations.AddXPostProcessedAt do
use Ecto.Migration
def change do
alter table(:x_posts) do
remove :is_invitation
add :processed_at, :utc_datetime
end
end
end

@ -0,0 +1,14 @@
defmodule Bright.Repo.Migrations.CreatePlatformAliases do
use Ecto.Migration
def change do
create table(:platform_aliases) do
add :url, :string
add :platform_id, references(:platforms, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:platform_aliases, [:platform_id])
end
end

@ -0,0 +1,7 @@
defmodule Bright.Repo.Migrations.EnforceUniqueStreamDate do
use Ecto.Migration
def change do
create unique_index(:streams, [:date])
end
end

@ -0,0 +1,7 @@
defmodule Bright.Repo.Migrations.RemoveXPostsUniqueIndex do
use Ecto.Migration
def change do
drop_if_exists unique_index(:x_posts, [:url], name: :x_posts_url_unique_index)
end
end

@ -0,0 +1,7 @@
defmodule Bright.Repo.Migrations.EnforceXPostsUniqueDate do
use Ecto.Migration
def change do
create unique_index(:x_posts, [:url])
end
end

@ -0,0 +1,11 @@
defmodule Bright.Repo.Migrations.AddStreamReferenceToXposts do
use Ecto.Migration
def change do
alter table(:x_posts) do
add :stream_id, references(:streams, on_delete: :nothing)
end
create index(:x_posts, [:stream_id])
end
end

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.AddNsfwBooleanToPlatforms do
use Ecto.Migration
def change do
alter table(:platforms) do
add :nsfw, :boolean, default: true
end
end
end

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.AddSlugToPlatform do
use Ecto.Migration
def change do
alter table(:platforms) do
add :slug, :string, null: false, default: "hammer"
end
end
end

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.RemovePlatformIcon do
use Ecto.Migration
def change do
alter table(:platforms) do
remove :icon
end
end
end

@ -0,0 +1,92 @@
defmodule Bright.ProcessPostsTest do
alias Bright.ObanWorkers.CreateS3Asset
use Bright.DataCase
use Oban.Testing, repo: Bright.Repo
alias Bright.ObanWorkers.{ProcessVod, CreateTorrent}
alias Bright.Streams
alias Bright.Streams.Stream
alias Bright.Platforms.Platform
alias Bright.VtubersFixtures
alias Bright.Socials.XPost
setup do
vtuber = Bright.VtubersFixtures.vtuber_fixture()
%Platform{
name: "Fansly",
slug: "fansly",
url: "https://fansly.com",
nsfw: true
}
|> Repo.insert!()
%Platform{
name: "Twitch",
slug: "twitch",
url: "https://twitch.tv",
nsfw: false
}
|> Repo.insert!()
%Platform{
name: "OnlyFans",
slug: "onlyfans",
url: "https://onlyfans.com",
nsfw: true
}
|> Repo.insert!()
posts =
for i <- 1..3 do
%XPost{
raw: "Raw content #{i}",
url: "https://example.com/post#{i}",
date: DateTime.utc_now(:second),
processed_at: nil,
vtuber_id: vtuber.id
}
|> Repo.insert!()
end
{:ok, posts: posts, vtuber: vtuber}
end
describe "ProcessPosts" do
import Bright.StreamsFixtures
@tag :integration
test "detects platforms based on known platform aliases" do
"@todo implement" |> flunk
end
@tag :integration
test "create a stream for each post containing an invite link" do
{:ok, _} = perform_job(Bright.ObanWorkers.ProcessPosts, %{})
streams = Repo.all(Stream)
# Assert there are exactly 3 streams
assert length(streams) == 3
end
@tag :integration
test "mark posts as processed" do
{:ok, number_processed_posts} = perform_job(Bright.ObanWorkers.ProcessPosts, %{})
assert number_processed_posts === 3
end
end
# @tag :integration
# test "torrent creation" do
# stream = stream_fixture()
# vod = vod_fixture(%{torrent: nil, stream_id: stream.id, s3_cdn_url: @test_video_url})
# {:ok, %Torrent{} = torrent} =
# perform_job(Bright.ObanWorkers.CreateTorrent, %{vod_id: vod.id})
# assert is_number(torrent.id)
# assert Regex.match?(~r/^magnet:/, torrent.magnet)
# assert Regex.match?(~r/([A-F\d]+)\b/i, torrent.info_hash_v1)
# assert Regex.match?(~r/([A-F\d]+)\b/i, torrent.info_hash_v2)
# end
end

@ -4,10 +4,13 @@ defmodule Bright.XPostTest do
alias Bright.Socials.XPost
alias Bright.Vtubers.Vtuber
alias Bright.XPostsFixtures
alias Bright.Platforms.{Platform, PlatformAlias}
alias Bright.Platforms
alias Bright.VultrAI
@sample_feed "https://rss.app/feeds/FhPetvUY036xiFau.xml"
describe "x_posts" do
describe "get_new_posts" do
@tag :integration
test "get_new_posts/1 with URL" do
{:ok, posts} = XPost.get_new_posts(@sample_feed)
@ -22,19 +25,227 @@ defmodule Bright.XPostTest do
end
end
describe "find_livestream_invitation" do
describe "extract_hostname/1" do
@tag :unit
test "identify posts with invitations" do
for post <- XPostsFixtures.x_posts_live() do
assert XPost.find_livestream_invitation(post) === true
end
test "gets the hostname" do
assert XPost.extract_hostname("https://chaturbate.com") === "chaturbate.com"
assert XPost.extract_hostname("https://twitch.tv") === "twitch.tv"
assert XPost.extract_hostname("https://google.com") === "google.com"
end
end
describe "includes_alias?/2" do
@tag :unit
test "returns true when a platform alias url is found in XPost raw content" do
platform = %Platform{
url: "https://blueballfixed.ytmnd.com",
slug: "ytmnd",
name: "You're The Man Now Dog",
nsfw: false
}
platform_alias = %PlatformAlias{url: "https://shorturl.at/lZ3NM", platform_id: platform.id}
x_post = %XPost{raw: "Hello World please join my stream rn https://shorturl.at/lZ3NM"}
assert XPost.includes_alias?(x_post, platform)
end
@tag :unit
test "identify posts without invitations" do
for post <- XPostsFixtures.x_posts_offline() do
assert XPost.find_livestream_invitation(post) === false
end
test "returns false when no platform alias url is present in XPost raw content" do
end
@tag :unit
test "returns true when a platform alias url is found in raw_text" do
end
@tag :unit
test "returns false when no platform alias url is present in raw_text" do
end
end
describe "includes_platform?" do
@tag :unit
test "includes_platform? with raw text" do
raw_text = "hello world check out my stream: twitch.tv/sexyman42"
platform = %Platform{name: "Twitch", slug: "twitch", url: "https://twitch.tv", nsfw: false}
XPost.includes_platform?(raw_text, platform)
end
@tag :unit
test "includes_platform? with x_post" do
x_post = %XPost{raw: "LIVE NOW https://chaturbate.com"}
platform = %Platform{
name: "Chaturbate",
slug: "chaturbate",
url: "https://chaturbate.com",
nsfw: true
}
XPost.includes_platform?(x_post, platform)
end
end
describe "get_platforms_mentioned" do
setup %{} do
assert {:ok, %Platform{}} =
Platforms.create_platform(%{
name: "Chaturbate",
slug: "chaturbate",
url: "https://chaturbate.com"
})
assert {:ok, %Platform{}} =
Platforms.create_platform(%{
name: "OnlyFans",
slug: "onlyfans",
url: "https://onlyfans.com"
})
# seed the test db with some platforms
assert {:ok, %Platform{} = fanslyPlatform} =
Platforms.create_platform(%{
name: "Fansly",
slug: "fansly",
url: "https://fansly.com"
})
assert {:ok, %Platform{}} =
Platforms.create_platform(%{
name: "Twitch",
slug: "twitch",
url: "https://twitch.tv"
})
IO.puts("fanslyPlatform=#{inspect(fanslyPlatform)} id=#{fanslyPlatform.id}")
assert {:ok, %PlatformAlias{} = platAlias} =
Platforms.create_platform_alias(%{
url: "https://melody.buzz",
platform_id: fanslyPlatform.id
})
IO.puts("platAlias=#{inspect(platAlias)}")
:ok
end
@tag :unit
test "post with no links" do
platforms = Platforms.list_platforms()
expected_platform_names = []
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_offline_1() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "post with non-invite links" do
platforms = Platforms.list_platforms()
expected_platform_names = []
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_offline_2() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "post with only SFW invite links" do
platforms = Platforms.list_platforms()
expected_platform_names = ["Fansly", "Twitch"]
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_offline_3() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "another post with only SFW invite links" do
platforms = Platforms.list_platforms()
expected_platform_names = ["Fansly", "Twitch"]
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_offline_4() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "post with 3 platform invites 1" do
platforms = Platforms.list_platforms()
expected_platform_names = ["Fansly", "OnlyFans", "Chaturbate"]
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_live_1() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "post with 3 platform invites 2" do
platforms = Platforms.list_platforms()
expected_platform_names = ["Fansly", "OnlyFans", "Chaturbate"]
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_live_2() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
@tag :unit
test "post with 3 platform invites 3" do
platforms = Platforms.list_platforms()
expected_platform_names = ["Fansly", "OnlyFans", "Chaturbate"]
actual_platform_names =
XPost.get_platforms_mentioned(
XPostsFixtures.fixture_live_3() |> Map.get(:raw),
platforms
)
# Extract only names
|> Enum.map(& &1.name)
assert Enum.sort(actual_platform_names) == Enum.sort(expected_platform_names)
end
end
end

@ -0,0 +1,105 @@
# This is not in use due to less-than-stellar results. Keeping for future reference.
# defmodule Bright.VultrAITest do
# use Bright.DataCase
# alias Bright.VultrAI
# alias Bright.Vtubers
# alias Bright.Vtubers.Vtuber
# alias Bright.Platforms
# alias Bright.Platforms.Platform
# alias Bright.XPostsFixtures
# @platforms_fixture ["YouTube", "Twitch", "Fansly", "Chaturbate", "OnlyFans"]
# describe "VultrAI" do
# @tag :integration
# test "parse_social_post/2" do
# raw = "Join me on Twitch and YouTube for a special livestream!"
# known_platforms = ["YouTube", "Twitch", "Fansly"]
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, known_platforms)
# assert title =~ "Join me on Twitch and YouTube"
# assert Enum.sort(platforms) == Enum.sort(["YouTube", "Twitch"])
# end
# @tag :integration
# test "parse_social_post/2 backtest 1" do
# raw = XPostsFixtures.fixture_offline_1() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert Enum.sort(platforms) == Enum.sort(["Twitch", "YouTube"])
# end
# @tag :integration
# test "parse_social_post/2 backtest 2" do
# raw = XPostsFixtures.fixture_offline_2() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert Enum.sort(platforms) == Enum.sort(["Twitch", "YouTube"])
# end
# @tag :integration
# test "parse_social_post/2 backtest 3" do
# raw = XPostsFixtures.fixture_offline_3() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert Enum.sort(platforms) == Enum.sort(["Twitch", "YouTube"])
# end
# @tag :integration
# test "parse_social_post/2 backtest 4" do
# raw = XPostsFixtures.fixture_offline_4() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert platforms == ["Twitch"]
# end
# @tag :integration
# test "parse_social_post/2 backtest 5" do
# raw = XPostsFixtures.fixture_live_1() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert Enum.sort(platforms) === Enum.sort(["Fansly", "OnlyFans", "Chaturbate"])
# end
# @tag :integration
# test "parse_social_post/2 backtest 6" do
# raw = XPostsFixtures.fixture_live_2() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert Enum.sort(platforms) === Enum.sort(["Fansly", "OnlyFans", "Chaturbate"])
# end
# @tag :integration
# test "parse_social_post/2 backtest 7" do
# raw = XPostsFixtures.fixture_live_3() |> Map.get(:raw)
# {:ok, %{"title" => title, "platforms" => platforms}} =
# VultrAI.parse_social_post(raw, @platforms_fixture)
# assert title !== ""
# assert Enum.sort(platforms) === Enum.sort(["Fansly", "OnlyFans", "Chaturbate"])
# end
# end
# end

@ -9,55 +9,78 @@ defmodule Bright.XPostsFixtures do
@doc """
A x_post which does NOT contain an invite link to any of Chaturbate, Fansly, OnlyFans.
"""
@fixtures_offline [
def fixture_offline_1() do
%XPost{
raw:
"sry i got off early, gots a headphone headache but regardles.... I REALLY LIKE MONSTER HUNTER (we kicked 2 monster asses solo today!!!!)",
date: ~U[2025-03-12T05:00:00.000Z],
url: "https://x.com/ProjektMelody/status/1899686928913412421"
},
}
end
def fixture_offline_2() do
%XPost{
raw:
"▀▄▀▄▀▄ SCHEDULE ▄▀▄▀▄▀\n: http://linktr.ee/projektmelody\n⦉╰( ߬⚈ o⚈ꪷ)╯𖹭 ˗ˏˋ(‿(ᶅ͒)‿) ˎˊ˗ : https://afterdark.market\n",
date: ~U[2025-03-05T06:25:35.000Z],
url: "https://x.com/ProjektMelody/status/1897171614439223545"
},
}
end
def fixture_offline_3() do
%XPost{
raw:
"I'M LIVE! FEELIN GOOD! LETS GAEM! \n\nhttp://twitch.tv/projektmelody \nhttp://melody.buzz\n\nWe're also doin' a lil giveaway, as @GFuelEnergy\n\n sponsored us :D ---- use !join in twitch chat at spec. times!\n\nFeelin' shoppy? Code MEL gets ur butt 20% OFF ur order-- http://gfuel.ly/mel",
date: ~U[2025-02-25T03:22:57.000Z],
url: "https://x.com/ProjektMelody/status/1894226549215281249"
},
}
end
def fixture_offline_4() do
%XPost{
raw:
"HAPPY TWITCHIVERSARY!\n\nsadly ive succombed to doom scrolling & family issues lately. i couldnt push myself to bring my A-game.... so, we're 2D. however, @iJinzu is joining us! he offered to coach me in monster hunter! :,)\n\nlive: http://twitch.tv/projektmelody\nhttp://melody.buzz",
date: ~U[2025-03-07T22:15:00.000Z],
url: "https://x.com/ProjektMelody/status/1898135320874344448"
}
]
end
def fixture_offline_5() do
%XPost{
raw: "It's happening.. hold my hands please.. 🥺 ▶ twitch.tv/el_xox",
date: ~U[2025-03-12 22:53:24Z],
url: "https://x.com/el_XoX34/status/1832215752591470655"
}
end
@doc """
A x_post which contains an invite link to any of Fansly, Chaturbate, OnlyFans.
"""
@fixtures_live [
def fixture_live_1() do
%XPost{
raw: "🥯fansly: melody.buzz \n📷onlyfans.com/?ref=16786030 \n📷chaturbate.com/projektmelody",
date: ~U[2025-03-05T18:30:00.000Z],
url: "https://x.com/ProjektMelody/status/1897385055640805666"
},
}
end
def fixture_live_2() do
%XPost{
raw:
"bruh wassup, it's movie night~\n(have a faptastic day!!!!)\n\n🥯fansly: http://melody.buzz \n🍆http://onlyfans.com/?ref=16786030 \n💦http://chaturbate.com/projektmelody",
date: ~U[2025-02-26T02:14:59.000Z],
url: "https://x.com/ProjektMelody/status/1894571836408504825"
},
}
end
def fixture_live_3() do
%XPost{
raw:
"oh, damn---if @Lovense\never starts making tip-assissted ejaculating dildos. my community would frost my ass like a 30-layer wedding cake. i;d become a literal cannoli-hole... hmmm...\nANYWAY, i'm live: \n🥯fansly: http://melody.buzz \n🍆http://onlyfans.com/?ref=16786030 \n💦http://chaturbate.com/projektmelody\n",
date: ~U[2025-03-01T01:05:12.000Z],
url: "https://x.com/ProjektMelody/status/1895641435187151207"
}
]
end
@doc """
Generates a basic x_post fixture.
@ -76,25 +99,4 @@ defmodule Bright.XPostsFixtures do
x_post
end
@doc """
Returns all live x_posts.
"""
def x_posts_live() do
@fixtures_live
end
@doc """
Returns all offline x_posts.
"""
def x_posts_offline() do
@fixtures_offline
end
@doc """
Returns all x_posts (both live and offline).
"""
def all_x_posts() do
x_posts_offline() ++ x_posts_live()
end
end