XPost Categorization and acceptance progress
This commit is contained in:
parent
f5451ce52c
commit
80e9fbb13d
.vscode
apps/bright
assets
config
lib
bright
bright_web.exbright_web
components
controllers
priv/repo/migrations
20250312122342_add_x_post_processed_at.exs20250312232843_create_platform_aliases.exs20250313162525_enforce_unique_stream_date.exs20250313174150_remove_x_posts_unique_index.exs20250313174339_enforce_x_posts_unique_date.exs20250313184416_add_stream_reference_to_xposts.exs20250314002717_add_nsfw_boolean_to_platforms.exs20250314204733_add_slug_to_platform.exs20250314220550_remove_platform_icon.exs
test
bright
support/fixtures
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -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
|
||||
|
||||
|
10
apps/bright/lib/bright/oban_workers/README.md
Normal file
10
apps/bright/lib/bright/oban_workers/README.md
Normal file
@ -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
|
141
apps/bright/lib/bright/oban_workers/process_posts.ex
Normal file
141
apps/bright/lib/bright/oban_workers/process_posts.ex
Normal file
@ -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
|
||||
|
18
apps/bright/lib/bright/platforms/platform_alias.ex
Normal file
18
apps/bright/lib/bright/platforms/platform_alias.ex
Normal file
@ -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
|
19
apps/bright/lib/bright/platforms/platform_alias.ex.bak
Normal file
19
apps/bright/lib/bright/platforms/platform_alias.ex.bak
Normal file
@ -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
|
||||
|
131
apps/bright/lib/bright/vultr_ai.ex
Normal file
131
apps/bright/lib/bright/vultr_ai.ex
Normal file
@ -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>
|
||||
|
122
apps/bright/lib/bright_web/components/svg_icon.ex
Normal file
122
apps/bright/lib/bright_web/components/svg_icon.ex
Normal file
@ -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
|
44
apps/bright/lib/bright_web/components/svg_icon/icon.ex
Normal file
44
apps/bright/lib/bright_web/components/svg_icon/icon.ex
Normal file
@ -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 doesn’t 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, we’re 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
|
92
apps/bright/test/bright/oban_workers/process_posts_test.exs
Normal file
92
apps/bright/test/bright/oban_workers/process_posts_test.exs
Normal file
@ -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
|
||||
|
105
apps/bright/test/bright/vultr_ai_test.exs
Normal file
105
apps/bright/test/bright/vultr_ai_test.exs
Normal file
@ -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⦉ LINKS: 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
|
||||
|
Loading…
x
Reference in New Issue
Block a user