├── .nvmrc ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── main.yml ├── .tool-versions ├── assets ├── .prettierrc.json ├── js │ ├── channels │ │ ├── index.js │ │ └── captions.js │ ├── utils │ │ ├── index.js │ │ ├── capitalize.js │ │ ├── graphql.js │ │ ├── event-emitter.js │ │ └── browser_compatibility.js │ ├── service │ │ ├── app-client.js │ │ ├── socket.js │ │ ├── zoom-sequence.js │ │ └── deepgram.js │ ├── tailwind.js │ ├── controllers │ │ ├── dropdown_controller.js │ │ ├── index.js │ │ ├── darkmode_controller.js │ │ ├── translations_controller.js │ │ └── zoom_controller.js │ ├── init_toast.js │ └── stimulus.js ├── css │ └── app.css ├── postcss.config.js ├── .eslintrc └── tailwind.config.js ├── elixir_buildpack.config ├── phoenix_static_buildpack.config ├── lib ├── stream_closed_captioner_phoenix_web │ ├── components │ │ ├── layouts │ │ │ ├── email.text.heex │ │ │ ├── app.html.heex │ │ │ ├── _notification.html.heex │ │ │ ├── live.html.heex │ │ │ ├── transcript.html.heex │ │ │ ├── root.html.heex │ │ │ ├── session.html.heex │ │ │ └── _user_dropdown.html.heex │ │ ├── layouts.ex │ │ ├── cards.ex │ │ ├── component_library.ex │ │ └── dropdowns.ex │ ├── controllers │ │ ├── email │ │ │ └── welcome.html.heex │ │ ├── stream_settings_html.ex │ │ ├── email_html.ex │ │ ├── terms_html.ex │ │ ├── privacy_html.ex │ │ ├── user_session_html.ex │ │ ├── user_settings_html.ex │ │ ├── announcements_html.ex │ │ ├── bits_balance_debit_html.ex │ │ ├── user_confirmation_html.ex │ │ ├── user_registration_html.ex │ │ ├── user_reset_password_html.ex │ │ ├── terms_controller.ex │ │ ├── privacy_controller.ex │ │ ├── showcase_html.ex │ │ ├── dashboard_html.ex │ │ ├── supporters_controller.ex │ │ ├── bits_balance_debit │ │ │ ├── show.html.heex │ │ │ └── index.html.heex │ │ ├── user_confirmation │ │ │ └── new.html.heex │ │ ├── announcements_controller.ex │ │ ├── dashboard │ │ │ ├── switch │ │ │ │ ├── _switch_off.html.eex │ │ │ │ └── _switch_on.html.eex │ │ │ └── indicators │ │ │ │ └── twitch_enabled_indicator.html.heex │ │ ├── showcase_controller.ex │ │ ├── error_html.ex │ │ ├── bits_balance_debit_controller.ex │ │ ├── supporters_html.ex │ │ ├── dashboard_controller.ex │ │ ├── user_reset_password │ │ │ ├── new.html.heex │ │ │ └── edit.html.heex │ │ ├── user_registration │ │ │ └── _twitch_connect_button.html.heex │ │ ├── stream_settings_controller.ex │ │ ├── user_registration_controller.ex │ │ ├── error │ │ │ ├── 500.html.heex │ │ │ └── 404.html.heex │ │ ├── announcements │ │ │ └── index.html.heex │ │ ├── supporters │ │ │ └── index.html.heex │ │ ├── showcase │ │ │ └── index.html.heex │ │ ├── error_helpers.ex │ │ ├── user_confirmation_controller.ex │ │ ├── user_reset_password_controller.ex │ │ └── user_session_controller.ex │ ├── heart_check.ex │ ├── body_reader.ex │ ├── plugs │ │ ├── auth_access_pipeline.ex │ │ ├── auth_error_handler.ex │ │ ├── maintenance.ex │ │ └── context.ex │ ├── resolvers │ │ ├── accounts_oauth.ex │ │ ├── settings.ex │ │ ├── accounts.ex │ │ └── bits.ex │ ├── gettext.ex │ ├── schema │ │ ├── middleware │ │ │ └── authorized_introspection.ex │ │ ├── accounts_types.ex │ │ └── types │ │ │ └── custom │ │ │ ├── json_type.ex │ │ │ └── datetime_type.ex │ ├── live │ │ ├── live_helpers.ex │ │ ├── page_live.ex │ │ ├── modal_component.ex │ │ ├── caption_settings_live │ │ │ └── form_component.ex │ │ └── transcirpts_live │ │ │ └── show.html.heex │ ├── gql_config.ex │ └── channels │ │ ├── active_presence.ex │ │ └── user_socket.ex ├── stream_closed_captioner_phoenix │ ├── services │ │ ├── twitch_bot.ex │ │ ├── twitch │ │ │ ├── extension │ │ │ │ ├── token.ex │ │ │ │ ├── credentials.ex │ │ │ │ └── captions_payload.ex │ │ │ ├── helix │ │ │ │ ├── cost.ex │ │ │ │ ├── credentials.ex │ │ │ │ ├── extension_info.ex │ │ │ │ ├── extension_channel.ex │ │ │ │ ├── product_data.ex │ │ │ │ ├── eventsub.ex │ │ │ │ ├── user.ex │ │ │ │ ├── stream.ex │ │ │ │ └── transaction.ex │ │ │ ├── extension_provider.ex │ │ │ ├── parser.ex │ │ │ ├── http_helpers.ex │ │ │ ├── extension.ex │ │ │ ├── oauth.ex │ │ │ ├── helix_provider.ex │ │ │ └── jwt.ex │ │ ├── notion.ex │ │ ├── zoom │ │ │ └── params.ex │ │ ├── azure │ │ │ ├── cognitive_provider.ex │ │ │ ├── cognitive │ │ │ │ ├── translation.ex │ │ │ │ └── translations.ex │ │ │ └── cognitive.ex │ │ ├── notion │ │ │ ├── block.ex │ │ │ ├── page.ex │ │ │ ├── utils.ex │ │ │ ├── parser.ex │ │ │ └── base.ex │ │ ├── helpers.ex │ │ ├── azure.ex │ │ ├── gooseman_app.ex │ │ └── zoom.ex │ ├── mailer.ex │ ├── cache.ex │ ├── repo.ex │ ├── announcement_admin.ex │ ├── announcement.ex │ ├── guardian.ex │ ├── bits │ │ ├── bits_balance_queries.ex │ │ ├── bits_balance_debit_admin.ex │ │ ├── bits_balance_admin.ex │ │ ├── bits_balance_debit.ex │ │ ├── bits_transaction_admin.ex │ │ ├── bits_balance.ex │ │ ├── bits_transaction.ex │ │ ├── bits_balance_debit_queries.ex │ │ └── bits_transaction_queries.ex │ ├── settings │ │ ├── translate_languages_admin.ex │ │ ├── translate_language.ex │ │ └── stream_settings_admin.ex │ ├── accounts │ │ ├── eventsub_subscription.ex │ │ ├── eventsub_subscription_admin.ex │ │ ├── eventsub_subscription_queries.ex │ │ ├── user_queries.ex │ │ └── user_notifier.ex │ ├── release.ex │ ├── transcripts │ │ ├── message.ex │ │ ├── transcript.ex │ │ └── transcript_admin.ex │ ├── jobs │ │ └── send_chat_reminder.ex │ ├── emails.ex │ ├── types │ │ └── deepgram.ex │ └── captions_pipeline │ │ └── translations.ex └── stream_closed_captioner_phoenix.ex ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20220914051536_create_oban_peers.exs │ │ ├── 20230105053116_create_announcements.exs │ │ ├── 20210615041739_add_access_token_to_users_table.exs │ │ ├── 20210803024103_add_blocklist_to_users_tables.exs │ │ ├── 20221218174655_add_auto_off_captions_to_user_settings.exs │ │ ├── 20211221032820_add_turn_on_reminder_to_stream_settings.exs │ │ ├── 20211102040347_add_oban_jobs_table.exs │ │ ├── 20211221041043_create_eventsub_subscriptions.exs │ │ ├── 20201205194222_create_users_auth_tables.exs │ │ ├── 20220914052253_swap_primary_oban_indexes.exs │ │ └── 20210227232918_create_feature_flags_table.exs │ └── seeds.exs ├── static │ ├── images │ │ ├── phoenix.png │ │ ├── cc100x100.png │ │ ├── register.png │ │ ├── settings.png │ │ ├── user-outline.png │ │ ├── captions-start.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── install-extension.png │ │ └── patreon.svg │ └── cache_manifest.json └── templates │ ├── phx.gen.pretty_html │ ├── view.ex │ ├── new.html.eex │ ├── edit.html.eex │ ├── form.html.eex │ ├── show.html.eex │ └── index.html.eex │ └── phx.gen.pretty_live │ ├── form_component.html.leex │ ├── show.ex │ ├── live_helpers.ex │ ├── modal_component.ex │ ├── show.html.leex │ └── index.ex ├── rel └── overlays │ ├── bin │ ├── server.bat │ ├── migrate.bat │ ├── server │ └── migrate │ └── Procfile ├── jsconfig.json ├── config ├── profanity │ └── english.txt ├── prod.exs └── dev.secret.exs ├── .formatter.exs ├── Dockerrun.aws.json ├── test ├── stream_closed_captioner_phoenix │ ├── captions_pipeline │ │ └── profanity_test.exs │ └── jobs │ │ └── send_chat_reminder_test.exs ├── stream_closed_captioner_phoenix_web │ ├── views │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ ├── live │ │ ├── page_live_test.exs │ │ └── stream_settings_live_test.exs │ ├── channels │ │ ├── captions_channel_test.exs │ │ └── user_tracker_test.exs │ └── controllers │ │ └── bits_balance_debit_controller_test.exs ├── test_helper.exs └── support │ ├── fixtures │ ├── accounts_fixtures.ex │ ├── transcripts_fixtures.ex │ ├── bits_fixture.ex │ └── settings_fixture.ex │ ├── channel_case.ex │ └── data_case.ex ├── .vscode ├── settings.json └── launch.json ├── .devcontainer └── docker-compose.yml ├── README.md ├── .dockerignore └── Dockerfile.prod /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.15.0 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: talk2megooseman 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.16.0 2 | erlang 26.0 3 | -------------------------------------------------------------------------------- /assets/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /assets/js/channels/index.js: -------------------------------------------------------------------------------- 1 | export * from "./captions" 2 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | elixir_version=1.16.0 2 | erlang_version=26.0 3 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | node_version=18.15.0 2 | clean_cache=true 3 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/email.text.heex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/email/welcome.html.heex: -------------------------------------------------------------------------------- 1 |

Thanks for joining

-------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch_bot.ex: -------------------------------------------------------------------------------- 1 | defmodule TwitchBot do 2 | use TMI 3 | end 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\stream_closed_captioner_phoenix" start 3 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\stream_closed_captioner_phoenix" eval StreamClosedCaptionerPhoenix.Release.migrate 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./stream_closed_captioner_phoenix start 4 | -------------------------------------------------------------------------------- /assets/js/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './browser_compatibility'; 2 | export * from './event-emitter'; 3 | export * from './capitalize'; 4 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('tailwindcss'), 4 | require('autoprefixer'), 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/extension/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Extension.Token do 2 | use Joken.Config 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /priv/static/images/cc100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/cc100x100.png -------------------------------------------------------------------------------- /priv/static/images/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/register.png -------------------------------------------------------------------------------- /priv/static/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/settings.png -------------------------------------------------------------------------------- /priv/static/images/user-outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/user-outline.png -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/notion.ex: -------------------------------------------------------------------------------- 1 | defmodule Notion do 2 | @moduledoc """ 3 | Notion API 4 | ## Usage 5 | 6 | """ 7 | end 8 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/zoom/params.ex: -------------------------------------------------------------------------------- 1 | defmodule Zoom.Params do 2 | defstruct [:seq, :lang] 3 | 4 | use ExConstructor 5 | end 6 | -------------------------------------------------------------------------------- /priv/static/images/captions-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/captions-start.png -------------------------------------------------------------------------------- /priv/static/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/favicon-16x16.png -------------------------------------------------------------------------------- /priv/static/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/favicon-32x32.png -------------------------------------------------------------------------------- /priv/static/images/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/favicon-96x96.png -------------------------------------------------------------------------------- /assets/js/service/app-client.js: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "graphql-request"; 2 | 3 | export const appClient = new GraphQLClient('/api', { headers: {} }) 4 | -------------------------------------------------------------------------------- /priv/static/images/install-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talk2MeGooseman/stream_closed_captioner_phoenix/HEAD/priv/static/images/install-extension.png -------------------------------------------------------------------------------- /rel/overlays/Procfile: -------------------------------------------------------------------------------- 1 | web: /app/bin/$GIGALIXIR_APP_NAME eval "StreamClosedCaptionerPhoenix.Release.migrate" && /app/bin/$GIGALIXIR_APP_NAME $GIGALIXIR_COMMAND 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./stream_closed_captioner_phoenix eval StreamClosedCaptionerPhoenix.Release.migrate 4 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Mailer do 2 | use Bamboo.Mailer, otp_app: :stream_closed_captioner_phoenix 3 | end 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "assets/node_modules", 4 | "**/node_modules/*" 5 | ], 6 | "include": [ 7 | "assets/js/**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /assets/js/utils/capitalize.js: -------------------------------------------------------------------------------- 1 | export function capitalize(sentence) { 2 | if (sentence.length === 0) return '' 3 | 4 | return sentence[0].toUpperCase() + sentence.substr(1) 5 | } 6 | -------------------------------------------------------------------------------- /assets/js/utils/graphql.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-request' 2 | 3 | export const GET_ME = gql` 4 | { 5 | me { 6 | extensionInstalled 7 | } 8 | } 9 | ` 10 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/heart_check.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.HeartCheck do 2 | use HeartCheck 3 | 4 | add :some_check do 5 | :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/stream_settings_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.StreamSettingsHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | end 4 | -------------------------------------------------------------------------------- /config/profanity/english.txt: -------------------------------------------------------------------------------- 1 | OMERGUDLUL123 2 | dyke 3 | fag 4 | faggot 5 | kike 6 | nigger 7 | wetback 8 | beaner 9 | chink 10 | coon 11 | coons 12 | coonnass 13 | rape 14 | retard 15 | retarded 16 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/email_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.EmailHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("email/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/terms_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.TermsHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("terms/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Cache do 2 | use Nebulex.Cache, 3 | otp_app: :stream_closed_captioner_phoenix, 4 | adapter: Nebulex.Adapters.Local 5 | end 6 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/privacy_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.PrivacyHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("privacy/*") 4 | end 5 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_html/view.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>View do 2 | use <%= inspect context.web_module %>, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Layouts do 2 | @moduledoc false 3 | use StreamClosedCaptionerPhoenixWeb, :html 4 | 5 | embed_templates "layouts/*" 6 | end 7 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_session_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserSessionHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("user_session/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_settings_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserSettingsHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("user_settings/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/announcements_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.AnnouncementsHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | 4 | embed_templates("announcements/*") 5 | end 6 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo do 2 | use EctoExtras.Repo 3 | 4 | use Ecto.Repo, 5 | otp_app: :stream_closed_captioner_phoenix, 6 | adapter: Ecto.Adapters.Postgres 7 | end 8 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/azure/cognitive_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Azure.CognitiveProvider do 2 | alias Azure.Cognitive.Translations 3 | 4 | @callback translate(String.t(), [String.t()], String.t()) :: Translations.t() 5 | end 6 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/bits_balance_debit_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.BitsBalanceDebitHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("bits_balance_debit/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_confirmation_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserConfirmationHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("user_confirmation/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_registration_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserRegistrationHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("user_registration/*") 4 | end 5 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_reset_password_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserResetPasswordHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("user_reset_password/*") 4 | end 5 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], 4 | subdirectories: ["priv/*/migrations"], 5 | plugins: [Phoenix.LiveView.HTMLFormatter], 6 | ] 7 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/terms_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.TermsController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220914051536_create_oban_peers.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.CreateObanPeers do 2 | use Ecto.Migration 3 | 4 | def up, do: Oban.Migrations.up(version: 11) 5 | 6 | def down, do: Oban.Migrations.down(version: 11) 7 | end 8 | -------------------------------------------------------------------------------- /assets/js/tailwind.js: -------------------------------------------------------------------------------- 1 | if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { 2 | document.documentElement.classList.add('dark'); 3 | } else { 4 | document.documentElement.classList.remove('dark'); 5 | } 6 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/privacy_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.PrivacyController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Image": { 4 | "Name": "ghcr.io/talk2megooseman/stream_closed_captioner_phoenix:latest" 5 | }, 6 | "Ports": [ 7 | { 8 | "ContainerPort": 4000, 9 | "HostPort": 80 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/cost.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.Cost do 2 | defstruct [ 3 | :amount, 4 | :type 5 | ] 6 | 7 | @type t :: %__MODULE__{ 8 | amount: non_neg_integer(), 9 | type: String.t() 10 | } 11 | use ExConstructor 12 | end 13 | -------------------------------------------------------------------------------- /assets/js/service/socket.js: -------------------------------------------------------------------------------- 1 | import { Socket } from "phoenix" 2 | 3 | /** 4 | * The socket connection 5 | * @type {Socket} 6 | */ 7 | let socket 8 | if (window.userToken) { 9 | socket = new Socket("/socket", { params: { token: window.userToken } }) 10 | socket.connect() 11 | } 12 | 13 | export default socket 14 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/extension/credentials.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Extension.Credentials do 2 | @type t :: %__MODULE__{ 3 | client_id: String.t(), 4 | token_secret: String.t(), 5 | jwt_token: any 6 | } 7 | defstruct [:client_id, :token_secret, :jwt_token] 8 | end 9 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/body_reader.ex: -------------------------------------------------------------------------------- 1 | defmodule BodyReader do 2 | def cache_raw_body(conn, opts) do 3 | with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do 4 | conn = update_in(conn.assigns[:raw_body], &[body | &1 || []]) 5 | 6 | {:ok, body, conn} 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/notion/block.ex: -------------------------------------------------------------------------------- 1 | defmodule Notion.Block do 2 | @moduledoc """ 3 | Simple API wrapper for Notion API 4 | """ 5 | import Notion.Base 6 | 7 | @doc """ 8 | """ 9 | def retrieve_block_children(page_id, params), 10 | do: get("blocks/#{page_id}/children", params) 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230105053116_create_announcements.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.CreateAnnouncements do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:announcements) do 6 | add :message, :text 7 | add :display, :boolean, default: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Helpers do 2 | @spec encode_url_and_params(binary | URI.t(), map() | list()) :: binary 3 | def encode_url_and_params(url, params \\ %{}) do 4 | url 5 | |> URI.parse() 6 | |> Map.put(:query, URI.encode_query(params)) 7 | |> URI.to_string() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/credentials.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.Credentials do 2 | defstruct [:client_id, :client_secret, :access_token] 3 | 4 | @type t :: %__MODULE__{ 5 | client_id: String.t(), 6 | client_secret: String.t(), 7 | access_token: String.t() 8 | } 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210615041739_add_access_token_to_users_table.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.AddAccessTokenToUsersTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :access_token, :string 7 | add :refresh_token, :string 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210803024103_add_blocklist_to_users_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.AddBlocklistToUsersTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:stream_settings) do 6 | add :blocklist, {:array, :string}, default: [], null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221218174655_add_auto_off_captions_to_user_settings.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.AddAutoOffCaptionsToUserSettings do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:stream_settings) do 6 | add :auto_off_captions, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix/captions_pipeline/profanity_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.CaptionsPipeline.ProfanityTest do 2 | use StreamClosedCaptionerPhoenix.DataCase, async: true 3 | 4 | # This runs the tests using the doc header on the function 5 | doctest StreamClosedCaptionerPhoenix.CaptionsPipeline.Profanity 6 | end 7 | -------------------------------------------------------------------------------- /priv/repo/migrations/20211221032820_add_turn_on_reminder_to_stream_settings.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.AddTurnOnReminderToStreamSettings do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:stream_settings) do 6 | add :turn_on_reminder, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/js/utils/event-emitter.js: -------------------------------------------------------------------------------- 1 | export const sendEvent = (name, data) => { 2 | const event = new CustomEvent(name, { detail: data }) 3 | 4 | window.dispatchEvent(event) 5 | } 6 | 7 | export const onEvent = (name, callback) => { 8 | window.addEventListener(name, callback, false) 9 | return () => window.removeEventListener(name, callback) 10 | } 11 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/announcement_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.AnnouncementAdmin do 2 | def index(_) do 3 | [ 4 | display: nil, 5 | message: nil 6 | ] 7 | end 8 | 9 | def form_fields(_) do 10 | [ 11 | display: nil, 12 | message: %{type: :richtext} 13 | ] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix do 2 | @moduledoc """ 3 | StreamClosedCaptionerPhoenix keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/plugs/auth_access_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.AuthAccessPipeline do 2 | use Guardian.Plug.Pipeline, otp_app: :stream_closed_captioner_phoenix 3 | 4 | plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"} 5 | plug Guardian.Plug.EnsureAuthenticated 6 | plug Guardian.Plug.LoadResource, allow_blank: true 7 | end 8 | -------------------------------------------------------------------------------- /assets/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base", 4 | "adjunct" 5 | ], 6 | "env": { 7 | "browser": true 8 | }, 9 | "rules": { 10 | "no-plusplus": [ 11 | "error", 12 | { 13 | "allowForLoopAfterthoughts": true 14 | } 15 | ], 16 | "no-underscore-dangle": "off", 17 | "import/prefer-default-export": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.LayoutsTest do 2 | use StreamClosedCaptionerPhoenixWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/showcase_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ShowcaseHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("showcase/*") 4 | 5 | def set_stream_thumnail_dimensions(url, width, height) do 6 | url 7 | |> String.replace("{width}", width) 8 | |> String.replace("{height}", height) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/dashboard_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.DashboardHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("dashboard/**/*") 4 | 5 | def display_translation_status(translation_active) when is_nil(translation_active), 6 | do: "Not Activated" 7 | 8 | def display_translation_status(_), do: "Activated" 9 | end 10 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/plugs/auth_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.AuthErrorHandler do 2 | import Plug.Conn 3 | 4 | @behaviour Guardian.Plug.ErrorHandler 5 | 6 | @impl Guardian.Plug.ErrorHandler 7 | def auth_error(conn, {type, _reason}, _opts) do 8 | body = Jason.encode!(%{message: to_string(type)}) 9 | send_resp(conn, 401, body) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/announcement.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Announcement do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "announcements" do 6 | field :display, :boolean 7 | field :message, :string 8 | end 9 | 10 | @doc false 11 | def changeset(announcement, attrs) do 12 | announcement 13 | |> cast(attrs, [:message, :display]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/extension_info.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.ExtensionInfo do 2 | defstruct [ 3 | :active, 4 | :id, 5 | :name, 6 | :version 7 | ] 8 | 9 | @type t :: %__MODULE__{ 10 | active: Boolean.t(), 11 | id: String.t(), 12 | name: String.t(), 13 | version: String.t() 14 | } 15 | 16 | use ExConstructor 17 | end 18 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/supporters_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.SupportersController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | @spec index(Plug.Conn.t(), any) :: Plug.Conn.t() 5 | def index(conn, _params) do 6 | {:ok, %{"data" => data}} = GoosemanApp.fetch_supporters() 7 | 8 | render(conn, "index.html", data: data) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/bits_balance_debit/show.html.heex: -------------------------------------------------------------------------------- 1 |

Show Bits balance debit

2 | 3 | 16 | 17 | <%= link "Back", to: ~p"/bits_balance_debits" %> 18 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/extension/captions_payload.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Extension.CaptionsPayload do 2 | @derive Jason.Encoder 3 | @type t :: %__MODULE__{ 4 | interim: String.t(), 5 | final: String.t(), 6 | delay: float(), 7 | translations: map() 8 | } 9 | defstruct interim: "", final: "", delay: 0, translations: nil 10 | 11 | use ExConstructor 12 | end 13 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/notion/page.ex: -------------------------------------------------------------------------------- 1 | defmodule Notion.Page do 2 | @moduledoc """ 3 | Simple API wrapper for Notion API 4 | """ 5 | import Notion.Base 6 | 7 | @doc """ 8 | Notion.Page.retrieve_page("08f40d1e-9a9b-48e7-9da2-0e2362f5e372") 9 | """ 10 | def retrieve_page(page_id), do: get("pages/#{page_id}") 11 | defdelegate retrieve_block_children(block_id, params \\ %{}), to: Notion.Block 12 | end 13 | -------------------------------------------------------------------------------- /assets/js/channels/captions.js: -------------------------------------------------------------------------------- 1 | import socket from '../service/socket'; 2 | 3 | // Now that you are connected, you can join channels with a topic: 4 | export const captionsChannel = socket.channel(`captions:${window.userId}`, {}); 5 | 6 | captionsChannel 7 | .join() 8 | .receive('ok', (resp) => { 9 | console.debug('Joined successfully'); 10 | }) 11 | .receive('error', (resp) => { 12 | console.debug('Unable to join'); 13 | }); 14 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/extension_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.ExtensionProvider do 2 | alias Twitch.Extension.Credentials 3 | 4 | @callback send_pubsub_message_for( 5 | Credentials.t(), 6 | String.t(), 7 | Twitch.Extension.CaptionsPayload.t() 8 | ) :: 9 | {:error, HTTPoison.Error.t()} 10 | | {:ok, HTTPoison.Response.t()} 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # StreamClosedCaptionerPhoenix.Repo.insert!(%StreamClosedCaptionerPhoenix.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /assets/js/controllers/dropdown_controller.js: -------------------------------------------------------------------------------- 1 | import { ApplicationController } from "stimulus-use" 2 | 3 | export default class extends ApplicationController { 4 | get menuTarget() { 5 | return this.element.querySelector('div[data-target="dropdown.menu"]') 6 | } 7 | 8 | connect() { 9 | this.toggleClass = this.data.get("class") || "hidden" 10 | } 11 | 12 | toggle() { 13 | this.menuTarget.classList.toggle(this.toggleClass) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure(timeout: :infinity) 2 | ExUnit.start() 3 | Ecto.Adapters.SQL.Sandbox.mode(StreamClosedCaptionerPhoenix.Repo, :manual) 4 | {:ok, _} = Application.ensure_all_started(:ex_machina) 5 | 6 | # Mocks services out using their provider 7 | Mox.defmock(Azure.MockCognitive, for: Azure.CognitiveProvider) 8 | Mox.defmock(Twitch.MockExtension, for: Twitch.ExtensionProvider) 9 | Mox.defmock(Twitch.MockHelix, for: Twitch.HelixProvider) 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20211102040347_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migrations.up() 6 | end 7 | 8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 9 | # necessary, regardless of which version we've migrated `up` to. 10 | def down do 11 | Oban.Migrations.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /assets/js/init_toast.js: -------------------------------------------------------------------------------- 1 | export const InitToast = { 2 | mounted() { 3 | init() 4 | } 5 | } 6 | 7 | const init = () => { 8 | const toastEl = document.querySelector('.toast') 9 | if (toastEl && toastEl.innerText !== '') { 10 | toastEl.classList.add("mr-4") 11 | toastEl.classList.remove("hidden") 12 | 13 | setTimeout(() => { 14 | toastEl.classList.remove("mr-4") 15 | toastEl.classList.add("hidden") 16 | }, 3000); 17 | } 18 | } 19 | 20 | init() 21 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/_notification.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | Notifications 7 | 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "sqltools.connections": [ 3 | { 4 | "previewLimit": 50, 5 | "server": "localhost", 6 | "port": 5432, 7 | "askForPassword": true, 8 | "driver": "PostgreSQL", 9 | "name": "development", 10 | "database": "stream-cc-development", 11 | "username": "postgres" 12 | } 13 | ], 14 | "elixirLinter.useStrict": true, 15 | "editor.formatOnSave": true, 16 | "cSpell.words": [ 17 | "Captioner" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/azure.ex: -------------------------------------------------------------------------------- 1 | defmodule Azure do 2 | use Nebulex.Caching 3 | 4 | def api_client, 5 | do: Application.get_env(:stream_closed_captioner_phoenix, :azure_cognitive_client) 6 | 7 | @spec perform_translations(String.t(), [String.t()], String.t()) :: 8 | Azure.Cognitive.Translations.t() 9 | def perform_translations(from_language, to_languages, text) do 10 | api_client().translate(from_language, to_languages, text) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_confirmation/new.html.heex: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for :user, ~p"/users/confirm", fn f -> %> 4 | <%= label f, :email %> 5 | <%= text_input f, :email, required: true %> 6 | 7 |
8 | <%= submit "Resend confirmation instructions" %> 9 |
10 | <% end %> 11 | 12 |

13 | <%= link "Register", to: ~p"/users/register" %> | 14 | <%= link "Log in", to: ~p"/users/log_in" %> 15 |

16 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/guardian.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Guardian do 2 | use Guardian, otp_app: :stream_closed_captioner_phoenix 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts 5 | 6 | def subject_for_token(resource, _claims) do 7 | sub = to_string(resource.id) 8 | {:ok, sub} 9 | end 10 | 11 | def resource_from_claims(claims) do 12 | id = claims["sub"] 13 | resource = Accounts.get_user!(id) 14 | {:ok, resource} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/announcements_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.AnnouncementsController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | def index(conn, _params) do 5 | pages = 6 | case Notion.Database.query_database("8f9f6076e708455fbb263b3ba9ca48db") do 7 | {:ok, response} -> get_in(response, ["results"]) 8 | _ -> [] 9 | end 10 | 11 | render(conn, "index.html", pages: pages) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_html/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | New <%= schema.human_singular %> 7 |
8 |
9 |
10 | <%%= render "form.html", Map.put(assigns, :action, Routes.<%= schema.route_helper %>_path(@conn, :create)) %> 11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 20 8 | target-branch: master 9 | labels: 10 | - "dependencies" 11 | - "mix" 12 | - package-ecosystem: npm 13 | directory: "/assets" 14 | schedule: 15 | interval: "daily" 16 | open-pull-requests-limit: 20 17 | target-branch: master 18 | labels: 19 | - "dependencies" 20 | - "npm" 21 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/live/page_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.PageLiveTest do 2 | use StreamClosedCaptionerPhoenixWeb.ConnCase, async: true 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | @tag :skip 7 | test "disconnected and connected render", %{conn: conn} do 8 | {:ok, page_live, disconnected_html} = live(conn, "/") 9 | assert disconnected_html =~ "Boilerplate Generator" 10 | assert render(page_live) =~ "Boilerplate Generator" 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /assets/js/controllers/index.js: -------------------------------------------------------------------------------- 1 | export { default as CaptionsController } from './captions_controller'; 2 | export { default as DarkmodeController } from './darkmode_controller' 3 | export { default as DropdownController } from './dropdown_controller'; 4 | export { default as ObsController } from './obs_controller'; 5 | export { default as TranslationController } from './translations_controller'; 6 | export { default as TwitchController } from './twitch_controller'; 7 | export { default as ZoomController } from './zoom_controller'; 8 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/extension_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.ExtensionChannel do 2 | defstruct [ 3 | :broadcaster_id, 4 | :broadcaster_name, 5 | :game_id, 6 | :game_name, 7 | :title 8 | ] 9 | 10 | @type t :: %__MODULE__{ 11 | game_name: String.t(), 12 | game_id: String.t(), 13 | title: String.t(), 14 | broadcaster_name: String.t(), 15 | broadcaster_id: String.t() 16 | } 17 | 18 | use ExConstructor 19 | end 20 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_html/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | Edit <%= schema.human_singular %> 7 |
8 |
9 |
10 | <%%= render "form.html", Map.put(assigns, :action, Routes.<%= schema.route_helper %>_path(@conn, :update, @<%= schema.singular %>)) %> 11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20211221041043_create_eventsub_subscriptions.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.CreateEventsubSubscriptions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:eventsub_subscriptions) do 6 | add :user_id, references(:users, on_delete: :delete_all), null: false 7 | add :type, :string 8 | add :subscription_id, :string 9 | 10 | timestamps() 11 | end 12 | 13 | create unique_index(:eventsub_subscriptions, [:subscription_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/azure/cognitive/translation.ex: -------------------------------------------------------------------------------- 1 | defmodule Azure.Cognitive.Translation do 2 | @type t :: %__MODULE__{ 3 | text: String.t(), 4 | name: String.t() 5 | } 6 | @derive Jason.Encoder 7 | defstruct [ 8 | :text, 9 | :name 10 | ] 11 | 12 | use ExConstructor 13 | 14 | def new(data, args \\ []) do 15 | res = super(data, args) 16 | name = StreamClosedCaptionerPhoenix.Settings.translatable_languages[data["to"]] 17 | %{res | name: name } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /assets/js/service/zoom-sequence.js: -------------------------------------------------------------------------------- 1 | import { isNil } from 'ramda'; 2 | 3 | export const setZoomSequence = (url, value = 1) => { 4 | const urlObj = new URL(url); 5 | const id = urlObj.searchParams.get('id'); 6 | 7 | localStorage.setItem(`zoom:${id}`, value); 8 | }; 9 | 10 | export const getZoomSequence = (url) => { 11 | const urlObj = new URL(url); 12 | const id = urlObj.searchParams.get('id'); 13 | 14 | const result = localStorage.getItem(`zoom:${id}`); 15 | if (!isNil(result)) { 16 | return parseInt(localStorage.getItem(`zoom:${id}`), 10); 17 | } 18 | 19 | return 1; 20 | }; 21 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/product_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.ProductData do 2 | alias Twitch.Helix.Cost 3 | 4 | defstruct [ 5 | :sku, 6 | :cost, 7 | :display_name, 8 | :in_development 9 | ] 10 | 11 | @type t :: %__MODULE__{ 12 | sku: String.t(), 13 | cost: non_neg_integer(), 14 | display_name: String.t(), 15 | in_development: boolean() 16 | } 17 | 18 | use ExConstructor 19 | 20 | def new(data, args \\ []) do 21 | res = super(data, args) 22 | %{res | cost: Cost.new(res.cost)} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/eventsub.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.EventSub do 2 | defstruct [ 3 | :condition, 4 | :cost, 5 | :created_at, 6 | :id, 7 | :status, 8 | :transport, 9 | :type, 10 | :version 11 | ] 12 | 13 | @type t :: %__MODULE__{ 14 | condition: map(), 15 | cost: integer(), 16 | created_at: String.t(), 17 | id: String.t(), 18 | status: String.t(), 19 | transport: map(), 20 | type: String.t(), 21 | version: String.t() 22 | } 23 | use ExConstructor 24 | end 25 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/azure/cognitive/translations.ex: -------------------------------------------------------------------------------- 1 | defmodule Azure.Cognitive.Translations do 2 | alias Azure.Cognitive.Translation 3 | 4 | @type t :: %__MODULE__{ 5 | translations: list(Translation.t()) 6 | } 7 | @derive Jason.Encoder 8 | defstruct [ 9 | :translations 10 | ] 11 | 12 | use ExConstructor 13 | 14 | def new(data, args \\ []) do 15 | res = super(data, args) 16 | %{res | translations: Enum.reduce(res.translations, %{}, fn translation, acc -> 17 | Map.put(acc, translation["to"], Translation.new(translation)) 18 | end)} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/dashboard/switch/_switch_off.html.eex: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /priv/static/images/patreon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/showcase_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ShowcaseController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | @spec index(Plug.Conn.t(), any) :: Plug.Conn.t() 5 | def index(conn, _params) do 6 | active_channel_ids = StreamClosedCaptionerPhoenixWeb.UserTracker.recently_active_channels() 7 | 8 | # Fetch information about the channel to display for Twitch API 9 | stream_list = Twitch.get_live_streams(active_channel_ids) 10 | 11 | # Send the data to the front end 12 | render(conn, "index.html", data: stream_list) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_balance_queries.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsBalanceQueries do 2 | import Ecto.Query, warn: false 3 | 4 | alias StreamClosedCaptionerPhoenix.Bits.BitsBalance 5 | 6 | def all(query \\ base()), do: query 7 | 8 | def with_user_id(query \\ base(), user_id) do 9 | query 10 | |> where([bits_balance], bits_balance.user_id == ^user_id) 11 | end 12 | 13 | def with_id(query \\ base(), id) do 14 | query 15 | |> where([bits_balance], bits_balance.id == ^id) 16 | end 17 | 18 | defp base do 19 | from(_ in BitsBalance, as: :bits_balance) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/dashboard/switch/_switch_on.html.eex: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/cards.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Components.Cards do 2 | @moduledoc """ 3 | Card components 4 | """ 5 | use Phoenix.Component 6 | 7 | attr(:shadow, :boolean, default: false) 8 | attr(:border, :boolean, default: false) 9 | slot(:inner_block, required: true) 10 | 11 | def card(assigns) do 12 | ~H""" 13 |
18 | <%= render_slot(@inner_block) %> 19 |
20 | """ 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ErrorViewTest do 2 | use StreamClosedCaptionerPhoenixWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | # import Phoenix.View 6 | 7 | # test "renders 404.html" do 8 | # assert render_to_string(StreamClosedCaptionerPhoenixWeb.ErrorView, "404.html", []) ~= 9 | # "Not Found" 10 | # end 11 | 12 | # test "renders 500.html" do 13 | # assert render_to_string(StreamClosedCaptionerPhoenixWeb.ErrorView, "500.html", []) ~= 14 | # "Internal Server Error" 15 | # end 16 | end 17 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/settings/translate_languages_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Settings.TranslateLanguageAdmin do 2 | alias StreamClosedCaptionerPhoenix.{Accounts, Settings} 3 | 4 | def get_user(%{user_id: id}) do 5 | id 6 | |> Accounts.get_user!() 7 | |> Map.get(:username) 8 | end 9 | 10 | def index(_) do 11 | [ 12 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 13 | language: nil 14 | ] 15 | end 16 | 17 | def form_fields(_) do 18 | [ 19 | user_id: %{update: :readonly}, 20 | language: %{choices: Settings.translateable_language_list()} 21 | ] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /priv/repo/migrations/20201205194222_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS citext", "" 6 | 7 | create table(:users_tokens) do 8 | add :user_id, references(:users, on_delete: :delete_all), null: false 9 | add :token, :binary, null: false 10 | add :context, :string, null: false 11 | add :sent_to, :string 12 | timestamps(updated_at: false) 13 | end 14 | 15 | create index(:users_tokens, [:user_id]) 16 | create unique_index(:users_tokens, [:context, :token]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ErrorHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("error/*") 4 | 5 | # If you want to customize a particular status code 6 | # for a certain format, you may uncomment below. 7 | # def render("500.html", _assigns) do 8 | # "Internal Server Error" 9 | # end 10 | 11 | # By default, Phoenix returns the status message from 12 | # the template name. For example, "404.html" becomes 13 | # "Not Found". 14 | def template_not_found(template, _assigns) do 15 | Phoenix.Controller.status_message_from_template(template) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/notion/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Notion.Utils do 2 | @moduledoc false 3 | 4 | def api_key, do: Application.get_env(:stream_closed_captioner_phoenix, :api_key) 5 | 6 | def notion_version, 7 | do: Application.get_env(:stream_closed_captioner_phoenix, :notion_version, "2021-05-13") 8 | 9 | def auth_header, do: [{"Authorization", "Bearer " <> api_key()}] 10 | def version_header, do: [{"Notion-Version", notion_version()}] 11 | def content_header, do: [{"Content-Type", "application/json"}] 12 | def request_headers, do: version_header() ++ auth_header() 13 | def post_request_headers, do: version_header() ++ auth_header() ++ content_header() 14 | end 15 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_balance_debit_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsBalanceDebitAdmin do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | 4 | def search_fields(_schema) do 5 | [ 6 | user: [:email, :username, :uid] 7 | ] 8 | end 9 | 10 | def ordering(_schema) do 11 | [desc: :created_at] 12 | end 13 | 14 | def get_user(%{user_id: id}) do 15 | id 16 | |> Accounts.get_user!() 17 | |> Map.get(:username) 18 | end 19 | 20 | def index(_) do 21 | [ 22 | id: nil, 23 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 24 | amount: nil, 25 | created_at: nil 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_balance_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsBalanceAdmin do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | 4 | def search_fields(_schema) do 5 | [ 6 | user: [:email, :username, :uid] 7 | ] 8 | end 9 | 10 | def ordering(_schema) do 11 | [desc: :updated_at] 12 | end 13 | 14 | def get_user(%{user_id: id}) do 15 | id 16 | |> Accounts.get_user!() 17 | |> Map.get(:username) 18 | end 19 | 20 | def index(_) do 21 | [ 22 | id: nil, 23 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 24 | balance: nil, 25 | created_at: nil, 26 | updated_at: nil 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_balance_debit.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsBalanceDebit do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "bits_balance_debits" do 6 | field :amount, :integer 7 | belongs_to :user, StreamClosedCaptionerPhoenix.Accounts.User 8 | 9 | timestamps(inserted_at: :created_at) 10 | end 11 | 12 | @doc false 13 | def changeset(bits_balance_debit, attrs) do 14 | bits_balance_debit 15 | |> cast(attrs, [:user_id, :amount]) 16 | |> foreign_key_constraint(:user_id, name: "fk_rails_51a42188b3") 17 | |> validate_required([:user_id, :amount]) 18 | |> validate_number(:amount, greater_than_or_equal_to: 500) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 4 | 5 | 7 | 8 | <%= @inner_content %> 9 |
10 | 11 | <%= if assigns[:live_action] in [:new, :edit] do %> 12 |
13 | <% end %> 14 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/resolvers/accounts_oauth.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Resolvers.AccountsOauth do 2 | def get_channel_info(_, %{id: _id}, %{ 3 | context: %{decoded_token: decoded_token} 4 | }) do 5 | id = Map.get(decoded_token, "channel_id") 6 | 7 | case StreamClosedCaptionerPhoenix.AccountsOauth.get_user_for_provider("twitch", id) do 8 | nil -> 9 | {:error, "Channel #{id} not found"} 10 | 11 | user -> 12 | {:ok, user} 13 | end 14 | end 15 | 16 | def get_channel_info(_parent, _args, _resolution) do 17 | {:error, 18 | "Access denied, missing or invalid token. Please try again with correct credentials."} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_live/form_component.html.leex: -------------------------------------------------------------------------------- 1 | <%%= f = form_for @changeset, "#", 2 | id: "<%= schema.singular %>-form", 3 | class: "block", 4 | phx_target: @myself, 5 | phx_change: "validate", 6 | phx_submit: "save" %> 7 | <%= for {label, input, error} <- inputs, input do %><%= if String.match?(input, ~r/checkbox/) do %> 8 |
9 | <%= input %> 10 | <%= label %> 11 | <%= error %> 12 |
13 | <% else %> 14 |
15 | <%= label %> 16 | <%= input %> 17 | <%= error %> 18 |
19 | <% end %><% end %> 20 |
21 | <%%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-primary btn-sm" %> 22 |
23 | 24 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/accounts/eventsub_subscription.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Accounts.EventsubSubscription do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "eventsub_subscriptions" do 6 | field :subscription_id, :string 7 | field :type, :string 8 | belongs_to :user, StreamClosedCaptionerPhoenix.Accounts.User 9 | 10 | timestamps() 11 | end 12 | 13 | @doc false 14 | def changeset(eventsub_subscription, attrs) do 15 | eventsub_subscription 16 | |> cast(attrs, [:type, :subscription_id, :user_id]) 17 | |> foreign_key_constraint(:user_id) 18 | |> unique_constraint(:subscription_id) 19 | |> validate_required([:type, :subscription_id, :user_id]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/bits_balance_debit_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.BitsBalanceDebitController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | alias StreamClosedCaptionerPhoenix.Bits 5 | 6 | def index(conn, _params) do 7 | user = conn.assigns.current_user 8 | 9 | bits_balance_debits = Bits.list_users_bits_balance_debits(user) 10 | render(conn, "index.html", bits_balance_debits: bits_balance_debits) 11 | end 12 | 13 | def show(conn, %{"id" => id}) do 14 | user = conn.assigns.current_user 15 | 16 | bits_balance_debit = Bits.get_users_bits_balance_debit!(user, id) 17 | render(conn, "show.html", bits_balance_debit: bits_balance_debit) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/component_library.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ComponentLibrary do 2 | defmacro __using__(_) do 3 | quote do 4 | import StreamClosedCaptionerPhoenixWeb.ComponentLibrary 5 | # Import additional component modules below 6 | import StreamClosedCaptionerPhoenixWeb.Components.Dropdowns 7 | import StreamClosedCaptionerPhoenixWeb.Components.Cards 8 | import StreamClosedCaptionerPhoenixWeb.Components.Tables 9 | end 10 | end 11 | 12 | @moduledoc """ 13 | This module is added and used in StreamClosedCaptionerPhoenixWeb. The idea is 14 | different component modules can be added and imported in the macro section above. 15 | """ 16 | use Phoenix.Component 17 | end 18 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/release.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :stream_closed_captioner_phoenix 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/resolvers/settings.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Resolvers.Settings do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | alias StreamClosedCaptionerPhoenix.Settings 4 | 5 | def get_translations_info(%Accounts.User{} = user, _, _resolution) do 6 | debit = StreamClosedCaptionerPhoenix.Bits.get_user_active_debit(user.id) 7 | time = Map.get(debit || %{}, :created_at) 8 | 9 | {:ok, 10 | %{ 11 | languages: Settings.get_formatted_translate_languages_by_user(user.id), 12 | activated: !is_nil(debit), 13 | created_at: format_datetime(time) 14 | }} 15 | end 16 | 17 | defp format_datetime(time) when is_nil(time), do: nil 18 | defp format_datetime(time), do: Timex.to_datetime(time, "Etc/UTC") 19 | end 20 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/transcripts/message.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Transcripts.Message do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "messages" do 6 | field :text, :string 7 | belongs_to :transcript, StreamClosedCaptionerPhoenix.Transcripts.Transcript 8 | 9 | timestamps(inserted_at: :created_at) 10 | end 11 | 12 | @doc false 13 | def changeset(message, attrs) do 14 | message 15 | |> cast(attrs, [:transcript_id, :text]) 16 | |> foreign_key_constraint(:transcript_id, name: "fk_rails_832df11d70") 17 | |> validate_required([:transcript_id, :text]) 18 | end 19 | 20 | @doc false 21 | def update_changeset(message, attrs) do 22 | message 23 | |> cast(attrs, [:text]) 24 | |> validate_required([:text]) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Show do 2 | use <%= inspect context.web_module %>, :live_view 3 | 4 | alias <%= inspect context.module %> 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def handle_params(%{"id" => id}, _, socket) do 13 | {:noreply, 14 | socket 15 | |> assign(:page_title, page_title(socket.assigns.live_action)) 16 | |> assign(:<%= schema.singular %>, <%= inspect context.alias %>.get_<%= schema.singular %>!(id))} 17 | end 18 | 19 | defp page_title(:show), do: "Show <%= schema.human_singular %>" 20 | defp page_title(:edit), do: "Edit <%= schema.human_singular %>" 21 | end 22 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_transaction_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsTransactionAdmin do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | 4 | def search_fields(_schema) do 5 | [ 6 | user: [:username, :purchaser_uid, :sku, :transaction_id] 7 | ] 8 | end 9 | 10 | def ordering(_schema) do 11 | [desc: :id] 12 | end 13 | 14 | def get_user(%{user_id: id}) do 15 | id 16 | |> Accounts.get_user!() 17 | |> Map.get(:username) 18 | end 19 | 20 | def index(_) do 21 | [ 22 | id: nil, 23 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 24 | amount: nil, 25 | display_name: nil, 26 | purchaser_uid: nil, 27 | sku: nil, 28 | time: nil, 29 | transaction_id: nil 30 | ] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220914052253_swap_primary_oban_indexes.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.SwapPrimaryObanIndexes do 2 | use Ecto.Migration 3 | @disable_ddl_transaction true 4 | @disable_migration_lock true 5 | 6 | def change do 7 | create_if_not_exists index( 8 | :oban_jobs, 9 | [:state, :queue, :priority, :scheduled_at, :id], 10 | concurrently: true, 11 | prefix: "public" 12 | ) 13 | 14 | drop_if_exists index( 15 | :oban_jobs, 16 | [:queue, :state, :priority, :scheduled_at, :id], 17 | concurrently: true, 18 | prefix: "public" 19 | ) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/supporters_html.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.SupportersHTML do 2 | use StreamClosedCaptionerPhoenixWeb, :html 3 | embed_templates("supporters/*") 4 | 5 | @spec filter_twitch_subscribers(list()) :: list() 6 | def filter_twitch_subscribers(subscribes) do 7 | Enum.filter(subscribes, fn x -> x["user"]["displayName"] != "Talk2meGooseman" end) 8 | end 9 | 10 | @spec filter_patreon_subscribers(list()) :: list() 11 | def filter_patreon_subscribers(subscribes) do 12 | Enum.filter(subscribes, fn x -> 13 | x["campaignLifetimeSupportCents"] > 0 14 | end) 15 | end 16 | 17 | def get_polite_status(support_value) do 18 | case support_value > 0 do 19 | true -> "Active Patron" 20 | _ -> "Former Patron" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "mix_task", 9 | "name": "mix (Default task)", 10 | "request": "launch", 11 | "projectDir": "${workspaceRoot}" 12 | }, 13 | { 14 | "type": "mix_task", 15 | "name": "mix test", 16 | "request": "launch", 17 | "task": "test", 18 | "taskArgs": [ 19 | "--trace" 20 | ], 21 | "startApps": true, 22 | "projectDir": "${workspaceRoot}", 23 | "requireFiles": [ 24 | "test/**/test_helper.exs", 25 | "test/**/*_test.exs" 26 | ] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :stream_closed_captioner_phoenix, StreamClosedCaptionerPhoenixWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json" 14 | 15 | # Do not print debug messages in production 16 | config :logger, level: :info 17 | 18 | config :stream_closed_captioner_phoenix, StreamClosedCaptionerPhoenixWeb.Endpoint, 19 | force_ssl: [rewrite_on: [:x_forwarded_proto]] 20 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/gooseman_app.ex: -------------------------------------------------------------------------------- 1 | defmodule GoosemanApp do 2 | def fetch_supporters() do 3 | Neuron.Config.set(url: "https://guzman.codes/api") 4 | 5 | query = """ 6 | { 7 | twitch { 8 | broadcasterSubscriptions(broadcasterId: "120750024") { 9 | user { 10 | id 11 | profileImageUrl 12 | displayName 13 | description 14 | } 15 | tier 16 | } 17 | } 18 | patreon { 19 | campaignMembers { 20 | fullName 21 | currentlyEntitledAmountCents 22 | campaignLifetimeSupportCents 23 | } 24 | } 25 | } 26 | """ 27 | 28 | case Neuron.query(query) do 29 | {:ok, %{body: body}} -> {:ok, body} 30 | _ -> {:error, "Request failed"} 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.User do 2 | defstruct [ 3 | :id, 4 | :login, 5 | :display_name, 6 | :type, 7 | :broadcaster_type, 8 | :description, 9 | :profile_image_url, 10 | :offline_image_url, 11 | :view_count, 12 | :email, 13 | :created_at 14 | ] 15 | 16 | @type t :: %__MODULE__{ 17 | id: String.t(), 18 | login: String.t(), 19 | display_name: String.t(), 20 | type: String.t(), 21 | broadcaster_type: String.t(), 22 | description: String.t(), 23 | profile_image_url: String.t(), 24 | offline_image_url: String.t(), 25 | view_count: non_neg_integer(), 26 | email: String.t(), 27 | created_at: String.t() 28 | } 29 | use ExConstructor 30 | end 31 | -------------------------------------------------------------------------------- /priv/static/cache_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "!comment!":"This file was auto-generated by `mix phx.digest`. Remove it and all generated artefacts with `mix phx.digest.clean --all`", 3 | "version":1, 4 | "latest":{"assets/app.css":"assets/app-3fb118a31ac92da99365f49ea6978665.css","assets/app.js":"assets/app-60ac421655d21e45b16dbeb37f7098a6.js"}, 5 | "digests":{"assets/app-3fb118a31ac92da99365f49ea6978665.css":{"digest":"3fb118a31ac92da99365f49ea6978665","logical_path":"assets/app.css","mtime":63846600355,"sha512":"555IGhA8APydGLp5AqRiaoclwgGf41YwcBeKGomvw06nq5jpiXrIEpX5JgXGiE/tyTqKKPrVrH5i7Q+PixxS0w==","size":381131},"assets/app-60ac421655d21e45b16dbeb37f7098a6.js":{"digest":"60ac421655d21e45b16dbeb37f7098a6","logical_path":"assets/app.js","mtime":63846600355,"sha512":"yTVxPkDC7lVT1/o/ex39PGQz9D+Zz6TFvfm6ZcmwNjXXegib+S2u5sI9EHcGHj9CEuPgkyXVot7sfexWXJh4+w==","size":257521}} 6 | } 7 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_html/form.html.eex: -------------------------------------------------------------------------------- 1 | <%%= form_for @changeset, @action, fn f -> %> 2 | <%%= if @changeset.action do %> 3 |
4 |

Oops, something went wrong! Please check the errors below.

5 |
6 | <%% end %> 7 | <%= for {label, input, error} <- inputs, input do %><%= if String.match?(input, ~r/checkbox/) do %> 8 |
9 | <%= input %> 10 | <%= label %> 11 | <%= error %> 12 |
13 | <% else %> 14 |
15 | <%= label %> 16 | <%= input %> 17 | <%= error %> 18 |
19 | <% end %><% end %> 20 |
21 | <%%= submit "Save", class: "btn btn-primary btn-sm" %> 22 | <%%= link "Back", to: Routes.<%= schema.route_helper %>_path(@conn, :index), class: "ml-2 btn btn-link btn-sm" %> 23 |
24 | <%% end %> 25 | -------------------------------------------------------------------------------- /assets/js/stimulus.js: -------------------------------------------------------------------------------- 1 | import { Application } from '@hotwired/stimulus'; 2 | import { CaptionsController } from './controllers'; 3 | import { DropdownController } from './controllers'; 4 | import { DarkmodeController } from './controllers'; 5 | import { ObsController } from './controllers'; 6 | import { TranslationController } from './controllers'; 7 | import { TwitchController } from './controllers'; 8 | import { ZoomController } from './controllers'; 9 | 10 | window.Stimulus = Application.start(); 11 | 12 | Stimulus.register('captions', CaptionsController); 13 | Stimulus.register('darkmode', DarkmodeController); 14 | Stimulus.register('dropdown', DropdownController); 15 | Stimulus.register('obs', ObsController); 16 | Stimulus.register('translations', TranslationController); 17 | Stimulus.register('twitch', TwitchController); 18 | Stimulus.register('zoom', ZoomController); 19 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.Stream do 2 | defstruct [ 3 | :game_id, 4 | :game_name, 5 | :id, 6 | :language, 7 | :started_at, 8 | :thumbnail_url, 9 | :title, 10 | :type, 11 | :user_id, 12 | :user_login, 13 | :user_name, 14 | :viewer_count 15 | ] 16 | 17 | @type t :: %__MODULE__{ 18 | game_id: String.t(), 19 | game_name: String.t(), 20 | id: String.t(), 21 | language: String.t(), 22 | started_at: String.t(), 23 | thumbnail_url: String.t(), 24 | title: String.t(), 25 | type: String.t(), 26 | user_id: String.t(), 27 | user_login: String.t(), 28 | user_name: String.t(), 29 | viewer_count: String.t() 30 | } 31 | 32 | use ExConstructor 33 | end 34 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/resolvers/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Resolvers.Accounts do 2 | def get_user(_parent, %{id: id}, _resolution) do 3 | case StreamClosedCaptionerPhoenix.Accounts.get_user!(id) do 4 | nil -> 5 | {:error, "User ID #{id} not found"} 6 | 7 | user -> 8 | {:ok, user} 9 | end 10 | end 11 | 12 | def get_me(_parent, _params, %{ 13 | context: %{current_user: current_user} 14 | }) 15 | when current_user != nil do 16 | case StreamClosedCaptionerPhoenix.Accounts.get_user!(current_user.id) do 17 | nil -> 18 | {:error, "Access Denied, no current user set"} 19 | 20 | user -> 21 | {:ok, user} 22 | end 23 | end 24 | 25 | def get_me(_parent, _args, _resolution) do 26 | {:error, "Access denied, missing current user"} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `StreamClosedCaptionerPhoenix.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "hello world!" 9 | 10 | def user_fixture(attrs \\ %{}) do 11 | {:ok, %{user: user}} = 12 | attrs 13 | |> Enum.into(%{ 14 | email: unique_user_email(), 15 | password: valid_user_password() 16 | }) 17 | |> StreamClosedCaptionerPhoenix.Accounts.register_user() 18 | 19 | user 20 | end 21 | 22 | def extract_user_token(fun) do 23 | captured = fun.(&"[TOKEN]#{&1}[TOKEN]") 24 | [_, token, _] = String.split(captured.text_body, "[TOKEN]") 25 | token 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_balance.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsBalance do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "bits_balances" do 6 | field :balance, :integer, default: 0 7 | belongs_to :user, StreamClosedCaptionerPhoenix.Accounts.User 8 | 9 | timestamps(inserted_at: :created_at) 10 | end 11 | 12 | @doc false 13 | def changeset(bits_balance, attrs) do 14 | bits_balance 15 | |> cast(attrs, [:user_id, :balance]) 16 | |> foreign_key_constraint(:user_id, name: "fk_rails_1a2fa97ecf") 17 | |> unique_constraint(:user_id, name: "index_bits_balances_on_user_id") 18 | |> validate_required([:user_id]) 19 | end 20 | 21 | @doc false 22 | def update_changeset(bits_balance, attrs) do 23 | bits_balance 24 | |> cast(attrs, [:balance]) 25 | |> validate_required([:balance]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/dropdowns.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Components.Dropdowns do 2 | @moduledoc """ 3 | Dropdown components 4 | """ 5 | use Phoenix.Component 6 | 7 | alias Phoenix.LiveView.JS 8 | 9 | def toggle_dropdown(id, js \\ %JS{}) do 10 | js 11 | |> JS.toggle( 12 | to: id, 13 | in: 14 | {"transition ease-out duration-150", "opacity-0 translate-y-1", 15 | "opacity-100 translate-y-0"}, 16 | out: 17 | {"transition ease-in duration-100", "opacity-100 translate-y-0", 18 | "opacity-0 translate-y-1"} 19 | ) 20 | end 21 | 22 | def close_dropdown(id, js \\ %JS{}) do 23 | js 24 | |> JS.hide( 25 | to: id, 26 | transition: 27 | {"transition ease-in duration-100", "opacity-100 translate-y-0", 28 | "opacity-0 translate-y-1"} 29 | ) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import StreamClosedCaptionerPhoenixWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :stream_closed_captioner_phoenix 24 | end 25 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/transcript.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title suffix=" · Closed Captions"> 9 | <%= assigns[:page_title] || "Stream Closed Captioner" %> 10 | 11 | <%= render_tags_all(assigns[:meta_tags] || %{}) %> 12 | 13 | 14 | 16 | 17 | 18 | 19 | <%= @inner_content %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/schema/middleware/authorized_introspection.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Schema.Middleware.AuthorizedIntrospection do 2 | @moduledoc """ 3 | Disable or restrict schema introspection to authorized requests 4 | """ 5 | @behaviour Absinthe.Plugin 6 | 7 | @impl Absinthe.Plugin 8 | def before_resolution(exec) do 9 | if Enum.find(exec.result.emitter.selections, fn %{name: field_name} -> 10 | field_name == "__schema" && Mix.env() != :dev 11 | end) do 12 | %{ 13 | exec 14 | | validation_errors: [ 15 | %Absinthe.Phase.Error{message: "Unauthorized", phase: __MODULE__} 16 | ] 17 | } 18 | else 19 | exec 20 | end 21 | end 22 | 23 | @impl Absinthe.Plugin 24 | def after_resolution(exec), do: exec 25 | 26 | @impl Absinthe.Plugin 27 | def pipeline(pipeline, _exec), do: pipeline 28 | end 29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/bits_balance_debit/index.html.heex: -------------------------------------------------------------------------------- 1 |

Listing Bits balance debits

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= for bits_balance_debit <- @bits_balance_debits do %> 14 | 15 | 16 | 17 | 18 | 23 | 24 | <% end %> 25 | 26 |
UserAmount
<%= bits_balance_debit.user_id %><%= bits_balance_debit.amount %> 19 | <%= link "Show", to: ~p"/bits_balance_debits/#{bits_balance_debit}" %> 20 | <%= link "Edit", to: Routes.bits_balance_debit_path(@conn, :edit, bits_balance_debit) %> 21 | <%= link "Delete", to: Routes.bits_balance_debit_path(@conn, :delete, bits_balance_debit), method: :delete, data: [confirm: "Are you sure?"] %> 22 |
27 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/transcripts/transcript.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Transcripts.Transcript do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "transcripts" do 6 | field :name, :string 7 | field :session, :string 8 | 9 | belongs_to :user, StreamClosedCaptionerPhoenix.Accounts.User 10 | has_many :messages, StreamClosedCaptionerPhoenix.Transcripts.Message 11 | 12 | timestamps(inserted_at: :created_at) 13 | end 14 | 15 | @doc false 16 | def changeset(transcript, attrs) do 17 | transcript 18 | |> cast(attrs, [:user_id, :name, :session]) 19 | |> foreign_key_constraint(:user_id, name: "fk_rails_d177bec369") 20 | |> validate_required([:user_id, :name, :session]) 21 | end 22 | 23 | @doc false 24 | def update_changeset(transcript, attrs) do 25 | transcript 26 | |> cast(attrs, [:name]) 27 | |> validate_required([:name]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_html/show.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | Show <%= schema.human_singular %> 7 |
8 |
9 |
10 | <%= for {k, _} <- schema.attrs do %> 11 |
12 | <%= Phoenix.Naming.humanize(Atom.to_string(k)) %>: 13 | <%%= @<%= schema.singular %>.<%= k %> %> 14 |
15 | <% end %> 16 |
17 | 21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/accounts/eventsub_subscription_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Accounts.EventsubSubscriptionAdmin do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | 4 | def search_fields(_schema) do 5 | [ 6 | user: [:email, :username, :uid] 7 | ] 8 | end 9 | 10 | def widgets(_schema, _conn) do 11 | [ 12 | %{ 13 | type: "tidbit", 14 | title: "Active EventSub Subscriptions", 15 | content: Twitch.get_event_subscriptions("") |> Enum.count(), 16 | order: 1, 17 | width: 3, 18 | icon: '' 19 | } 20 | ] 21 | end 22 | 23 | def get_user(%{user_id: id}) do 24 | id 25 | |> Accounts.get_user!() 26 | |> Map.get(:username) 27 | end 28 | 29 | def index(_) do 30 | [ 31 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 32 | subscription_id: nil, 33 | type: nil 34 | ] 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/channels/captions_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.CaptionsChannelTest do 2 | use StreamClosedCaptionerPhoenixWeb.ChannelCase, async: true 3 | 4 | import StreamClosedCaptionerPhoenix.Factory 5 | 6 | setup do 7 | [stream_settings, _] = insert_pair(:stream_settings, user: fn -> build(:user) end) 8 | 9 | {:ok, _, socket} = 10 | StreamClosedCaptionerPhoenixWeb.UserSocket 11 | |> socket("user_id", %{current_user: stream_settings.user}) 12 | |> subscribe_and_join( 13 | StreamClosedCaptionerPhoenixWeb.CaptionsChannel, 14 | "captions:#{stream_settings.user.id}" 15 | ) 16 | 17 | %{socket: socket} 18 | end 19 | 20 | # test "ping replies with status ok", %{socket: socket} do 21 | # ref = 22 | # push(socket, "publish", %{"interim" => "hello", "final" => "goodbye", "session" => "123"}) 23 | 24 | # assert_reply ref, :ok, %{"hello" => "there"} 25 | # end 26 | end 27 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/notion/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Notion.Parser do 2 | @moduledoc """ 3 | Generic parser to parse api response 4 | """ 5 | 6 | @type status_code :: integer 7 | @type headers :: map 8 | @type response :: 9 | {:ok, struct} 10 | | {:error, map, status_code} 11 | | any 12 | 13 | @doc """ 14 | Parses the response from API calls 15 | """ 16 | @spec parse(tuple) :: response 17 | def parse({:ok, %HTTPoison.Response{body: body, headers: _, status_code: status}}) 18 | when status in [200, 201], 19 | do: {:ok, parse_response_body(body)} 20 | 21 | def parse({:error, %HTTPoison.Error{id: _, reason: reason}}), do: {:error, %{reason: reason}} 22 | 23 | def parse({:ok, %HTTPoison.Response{body: body, headers: _, status_code: status}}), 24 | do: {:error, parse_response_body(body), status} 25 | 26 | def parse(response), do: response 27 | 28 | defp parse_response_body(body), do: Poison.decode!(body) 29 | end 30 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Parser do 2 | @moduledoc """ 3 | Generic parser to parse api response 4 | """ 5 | 6 | @type status_code :: integer 7 | @type headers :: map 8 | @type response :: 9 | {:ok, struct} 10 | | {:error, map, status_code} 11 | | any 12 | 13 | @doc """ 14 | Parses the response from API calls 15 | """ 16 | @spec parse(tuple) :: response 17 | def parse({:ok, %HTTPoison.Response{body: body, headers: _, status_code: status}}) 18 | when status in [200, 201], 19 | do: {:ok, parse_response_body(body)} 20 | 21 | def parse({:error, %HTTPoison.Error{id: _, reason: reason}}), do: {:error, %{reason: reason}} 22 | 23 | def parse({:ok, %HTTPoison.Response{body: body, headers: _, status_code: status}}), 24 | do: {:error, parse_response_body(body), status} 25 | 26 | def parse(response), do: response 27 | 28 | defp parse_response_body(body), do: Poison.decode!(body) 29 | end 30 | -------------------------------------------------------------------------------- /test/support/fixtures/transcripts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.TranscriptsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `StreamClosedCaptionerPhoenix.Transcripts` context. 5 | """ 6 | 7 | import StreamClosedCaptionerPhoenix.AccountsFixtures 8 | 9 | def transcript_fixture(attrs \\ %{}) do 10 | {:ok, transcript} = 11 | attrs 12 | |> Enum.into(%{ 13 | name: "some name", 14 | session: "some session", 15 | user_id: user_fixture().id 16 | }) 17 | |> StreamClosedCaptionerPhoenix.Transcripts.create_transcript() 18 | 19 | transcript 20 | end 21 | 22 | def message_fixture(attrs \\ %{}) do 23 | {:ok, message} = 24 | attrs 25 | |> Enum.into(%{ 26 | text: "some text", 27 | transcript_id: transcript_fixture().id 28 | }) 29 | |> StreamClosedCaptionerPhoenix.Transcripts.create_message() 30 | 31 | message 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/notion/base.ex: -------------------------------------------------------------------------------- 1 | defmodule Notion.Base do 2 | @moduledoc false 3 | alias NewRelic.Instrumented.HTTPoison 4 | 5 | alias Notion.Parser 6 | import Notion.Utils 7 | 8 | @base_url "https://api.notion.com/v1" 9 | 10 | def get(path_arg, query_params \\ %{}) do 11 | path_arg 12 | |> build_url(query_params) 13 | |> HTTPoison.get(request_headers()) 14 | |> Parser.parse() 15 | end 16 | 17 | def post(path_arg, body \\ %{}) do 18 | json_body = Jason.encode!(body) 19 | 20 | path_arg 21 | |> build_url() 22 | |> HTTPoison.post(json_body, post_request_headers()) 23 | |> Parser.parse() 24 | end 25 | 26 | defp build_url(path_arg, query_params \\ %{}) do 27 | query_params = process_params(query_params) 28 | 29 | "#{@base_url}/#{path_arg}?#{URI.encode_query(query_params)}" 30 | end 31 | 32 | def process_params(params) do 33 | %{} 34 | |> Map.merge(params) 35 | |> Map.delete(:__struct__) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Helix.Transaction do 2 | alias Twitch.Helix.ProductData 3 | 4 | defstruct [ 5 | :id, 6 | :timestamp, 7 | :broadcaster_id, 8 | :broadcaster_login, 9 | :broadcaster_name, 10 | :user_id, 11 | :user_login, 12 | :user_name, 13 | :product_type, 14 | :product_data 15 | ] 16 | 17 | @type t :: %__MODULE__{ 18 | id: String.t(), 19 | timestamp: String.t(), 20 | broadcaster_id: String.t(), 21 | broadcaster_login: String.t(), 22 | broadcaster_name: String.t(), 23 | user_id: String.t(), 24 | user_login: String.t(), 25 | user_name: String.t(), 26 | product_type: String.t(), 27 | product_data: String.t() 28 | } 29 | use ExConstructor 30 | 31 | def new(data, args \\ []) do 32 | res = super(data, args) 33 | %{res | product_data: ProductData.new(res.product_data)} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/resolvers/bits.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Resolvers.Bits do 2 | alias StreamClosedCaptionerPhoenix.{Accounts, Bits} 3 | 4 | def bits_balance(%Accounts.User{} = user, _args, _resolution) do 5 | bits_balance = Bits.get_bits_balance_for_user(user) 6 | 7 | case bits_balance do 8 | nil -> 9 | {:error, "Bits balance not found"} 10 | 11 | bits_balance -> 12 | {:ok, bits_balance} 13 | end 14 | end 15 | 16 | def process_bits_transaction(_parent, %{channel_id: channel_id}, %{ 17 | context: %{decoded_token: decoded_token} 18 | }) do 19 | case Bits.process_bits_transaction(channel_id, decoded_token) do 20 | {:ok, _} -> {:ok, %{message: "Transaction Successful"}} 21 | {:error, _, message, _} -> {:error, %{message: message}} 22 | end 23 | end 24 | 25 | def process_bits_transaction(_parent, _args, _resolution) do 26 | {:error, "Access denied, missing or invalid token"} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/plugs/maintenance.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Maintenance do 2 | @behaviour Plug 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts 5 | 6 | # MyAppWeb.Maintenance.begin() and end it via MyAppWeb.Maintenance.finish(). 7 | 8 | def begin, do: :persistent_term.put(__MODULE__, true) 9 | def finish, do: :persistent_term.erase(__MODULE__) 10 | 11 | @impl true 12 | def init(_), do: [] 13 | 14 | @impl true 15 | def call(conn, _opts) do 16 | user = conn.assigns[:current_user] 17 | 18 | if Accounts.is_admin?(user) do 19 | conn 20 | else 21 | case :persistent_term.get(__MODULE__, false) do 22 | false -> 23 | conn 24 | 25 | true -> 26 | conn 27 | |> Plug.Conn.send_resp( 28 | 503, 29 | "Sorry! The site is currently under maintenance, please come back in a little bit." 30 | ) 31 | |> Plug.Conn.halt() 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/schema/accounts_types.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Schema.AccountsTypes do 2 | use Absinthe.Schema.Notation 3 | 4 | alias StreamClosedCaptionerPhoenixWeb.Resolvers 5 | 6 | @desc "A user" 7 | object :user do 8 | field :uid, :id 9 | field :username, :string 10 | end 11 | 12 | @desc "Get information about a user" 13 | object :me do 14 | field :id, :id 15 | 16 | field :extension_installed, :boolean do 17 | resolve(&has_extension_installed?/3) 18 | end 19 | end 20 | 21 | object :accounts_queries do 22 | @desc "Fetch the current users information" 23 | field :me, :me do 24 | resolve(&Resolvers.Accounts.get_me/3) 25 | end 26 | end 27 | 28 | # Return a boolean indicating whether the user has the extension installed 29 | def has_extension_installed?(user, _args, _resolution) do 30 | status = StreamClosedCaptionerPhoenix.Accounts.user_has_extension_installed?(user) 31 | {:ok, status} 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210227232918_create_feature_flags_table.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Repo.Migrations.CreateFeatureFlagsTable do 2 | use Ecto.Migration 3 | 4 | # This migration assumes the default table name of "fun_with_flags_toggles" 5 | # is being used. If you have overriden that via configuration, you should 6 | # change this migration accordingly. 7 | 8 | def up do 9 | create table(:fun_with_flags_toggles, primary_key: false) do 10 | add :id, :bigserial, primary_key: true 11 | add :flag_name, :string, null: false 12 | add :gate_type, :string, null: false 13 | add :target, :string, null: false 14 | add :enabled, :boolean, null: false 15 | end 16 | 17 | create index( 18 | :fun_with_flags_toggles, 19 | [:flag_name, :gate_type, :target], 20 | unique: true, 21 | name: "fwf_flag_name_gate_target_idx" 22 | ) 23 | end 24 | 25 | def down do 26 | drop table(:fun_with_flags_toggles) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/jobs/send_chat_reminder.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Jobs.SendChatReminder do 2 | use Oban.Worker, queue: :default 3 | 4 | # %{broadcaster_user_id: "talk2megooseman", broadcaster_user_login: "talk2megooseman"} |> StreamClosedCaptionerPhoenix.Jobs.SendChatReminder.new(schedule_in: 10) |> Oban.insert() 5 | 6 | @impl Oban.Worker 7 | def perform(%Oban.Job{ 8 | args: %{ 9 | "broadcaster_user_id" => broadcaster_user_id, 10 | "broadcaster_user_login" => broadcaster_user_login 11 | }, 12 | errors: errors 13 | }) do 14 | if Enum.any?(errors) do 15 | :cancel 16 | else 17 | if !StreamClosedCaptionerPhoenixWeb.UserTracker.channel_active?(broadcaster_user_id) do 18 | Twitch.send_extension_chat_message( 19 | broadcaster_user_id, 20 | "Hey @#{broadcaster_user_login}, here is your friendly reminder to turn on Stream Closed Captioner." 21 | ) 22 | end 23 | 24 | :ok 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/dashboard_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.DashboardController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | alias StreamClosedCaptionerPhoenix.Bits 4 | alias StreamClosedCaptionerPhoenix.Repo 5 | alias StreamClosedCaptionerPhoenix.Settings 6 | 7 | def index(conn, _params) do 8 | current_user = 9 | conn.assigns.current_user 10 | |> Repo.preload([:stream_settings, :bits_balance]) 11 | 12 | twitch_enabled = current_user.provider === "twitch" && is_binary(current_user.uid) 13 | 14 | render(conn, "index.html", 15 | twitch_enabled: twitch_enabled, 16 | stream_settings: current_user.stream_settings, 17 | translation_active: Bits.get_user_active_debit(current_user.id), 18 | bits_balance: current_user.bits_balance.balance, 19 | translate_languages: Settings.get_formatted_translate_languages_by_user(current_user.id), 20 | announcement: StreamClosedCaptionerPhoenix.Announcement |> Repo.first() 21 | ) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | elixir: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | args: 9 | # Elixir Version: 1.9, 1.10, 1.10.4, ... 10 | VARIANT: "1.16" 11 | # Phoenix Version: 1.4.17, 1.5.4, ... 12 | PHOENIX_VERSION: "1.7.7" 13 | # Node Version: 10, 11, ... 14 | INSTALL_NODE: "true" 15 | NODE_VERSION: "18.15.0" 16 | volumes: 17 | - ..:/workspace:cached 18 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 19 | network_mode: service:db 20 | 21 | # Overrides default command so things don't shut down after the process ends. 22 | command: sleep infinity 23 | 24 | db: 25 | image: postgres:latest 26 | restart: unless-stopped 27 | volumes: 28 | - postgres-data:/var/lib/postgresql/data 29 | environment: 30 | POSTGRES_USER: postgres 31 | POSTGRES_PASSWORD: postgres 32 | POSTGRES_DB: app 33 | 34 | volumes: 35 | postgres-data: 36 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsTransaction do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "bits_transactions" do 6 | field :amount, :integer 7 | field :display_name, :string 8 | field :purchaser_uid, :string 9 | field :sku, :string 10 | field :time, :naive_datetime 11 | field :transaction_id, :string 12 | 13 | belongs_to :user, StreamClosedCaptionerPhoenix.Accounts.User 14 | end 15 | 16 | @doc false 17 | def changeset(bits_transaction, attrs) do 18 | bits_transaction 19 | |> cast(attrs, [ 20 | :transaction_id, 21 | :user_id, 22 | :time, 23 | :purchaser_uid, 24 | :sku, 25 | :amount, 26 | :display_name 27 | ]) 28 | |> unique_constraint(:transaction_id, name: "index_bits_transactions_on_transaction_id") 29 | |> validate_required([ 30 | :transaction_id, 31 | :user_id, 32 | :time, 33 | :purchaser_uid, 34 | :sku, 35 | :amount 36 | ]) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/emails.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Emails do 2 | import Bamboo.Email 3 | use Bamboo.Phoenix, view: StreamClosedCaptionerPhoenixWeb.EmailView 4 | 5 | @from "test@example.com" 6 | 7 | def welcome_email(%{email: email}) do 8 | base_email() 9 | |> subject("Welcome!") 10 | |> to(email) 11 | |> render("welcome.html", 12 | title: "Thank you for signing up", 13 | preheader: "Thank you for signing up to the app." 14 | ) 15 | |> premail() 16 | end 17 | 18 | defp base_email do 19 | new_email() 20 | |> from(@from) 21 | # Set default layout 22 | |> put_html_layout({StreamClosedCaptionerPhoenixWeb.Layouts, "email.html"}) 23 | # Set default text layout 24 | |> put_text_layout({StreamClosedCaptionerPhoenixWeb.Layouts, "email.text"}) 25 | end 26 | 27 | defp premail(email) do 28 | html = Premailex.to_inline_css(email.html_body) 29 | text = Premailex.to_text(email.html_body) 30 | 31 | email 32 | |> html_body(html) 33 | |> text_body(text) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/accounts/eventsub_subscription_queries.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Accounts.EventsubSubscriptionQueries do 2 | import Ecto.Query, warn: false 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts.EventsubSubscription 5 | 6 | def all(query \\ base()), do: query 7 | 8 | def with_user_id(query \\ base(), user_id) do 9 | query 10 | |> where([eventsub_subscription], eventsub_subscription.user_id == ^user_id) 11 | end 12 | 13 | def with_id(query \\ base(), id) do 14 | query 15 | |> where([eventsub_subscription], eventsub_subscription.id == ^id) 16 | end 17 | 18 | def with_subscription_id(query \\ base(), subscription_id) do 19 | query 20 | |> where([eventsub_subscription], eventsub_subscription.subscription_id == ^subscription_id) 21 | end 22 | 23 | def with_type(query \\ base(), type) do 24 | query 25 | |> where([eventsub_subscription], eventsub_subscription.type == ^type) 26 | end 27 | 28 | defp base do 29 | from(_ in EventsubSubscription, as: :eventsub_subscription) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/zoom.ex: -------------------------------------------------------------------------------- 1 | defmodule Zoom do 2 | alias NewRelic.Instrumented.HTTPoison 3 | 4 | @spec send_captions_to(String.t() | URI.t(), String.t(), Zoom.Params.t()) :: 5 | {:error, HTTPoison.Error.t()} 6 | | {:ok, 7 | %{ 8 | :__struct__ => 9 | HTTPoison.AsyncResponse | HTTPoison.MaybeRedirect | HTTPoison.Response, 10 | optional(:body) => any, 11 | optional(:headers) => list, 12 | optional(:id) => reference, 13 | optional(:redirect_url) => any, 14 | optional(:request) => HTTPoison.Request.t(), 15 | optional(:request_url) => any, 16 | optional(:status_code) => integer 17 | }} 18 | def send_captions_to(url, text, %Zoom.Params{seq: seq, lang: lang}) do 19 | headers = [ 20 | {"Accept", "*/*"}, 21 | {"Content-Type", "text/plain"} 22 | ] 23 | 24 | (url <> "&" <> URI.encode_query(%{seq: seq, lang: lang})) 25 | |> HTTPoison.post(text, headers) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_reset_password/new.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Forgot your password?
4 | <%= form_for :user, ~p"/users/reset_password", fn f -> %> 5 |
6 | <%= label f, :email, class: "form-label" %> 7 | <%= text_input f, :email, required: true, class: "form-input" %> 8 |
9 | 10 |
11 | <%= submit "Send password reset instructions", class: "btn btn-dark w-full" %> 12 |
13 | <% end %> 14 | 15 |
16 | I do rememebr my password 17 |
18 | 19 |
20 | <%= link "Register", to: ~p"/users/register", class: "btn btn-link btn-sm dark:text-blue-500" %> 21 | <%= link "Log in", to: ~p"/users/log_in", class: "btn btn-link btn-sm dark:text-blue-500" %> 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StreamClosedCaptionerPhoenix 2 | 3 | To start your Phoenix server: 4 | 5 | - Install dependencies with `mix deps.get` 6 | - Create and migrate your database with `mix ecto.setup` 7 | - Install Node.js dependencies with `npm install` inside the `assets` directory 8 | - Start Phoenix endpoint with `mix phx.server` 9 | 10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 11 | 12 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 13 | 14 | ## Env configs 15 | 16 | Done through service 17 | 18 | ## Debug Live View 19 | 20 | `liveSocket.enableDebug()` 21 | 22 | ## Migrate database 23 | 24 | `bin/stream_closed_captioner_phoenix eval "StreamClosedCaptionerPhoenix.Release.migrate"` 25 | 26 | ## Debugging on Elastic Beanstalk 27 | 28 | `eb logs` 29 | `eb ssh` to get into the EC2 machine 30 | `sudo -s` on the EC2 machine to run Docker commands and attach to the instance 31 | `docker ps` will list the running containers 32 | `docker exec -i -t container_name COMMAND` will connect you to the container in a Bash shell 33 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/http_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.HttpHelpers do 2 | def auth_request_headers(token), 3 | do: content_header() ++ auth_header(token) ++ client_id_header() 4 | 5 | def content_header, do: [{"Content-Type", "application/json"}] 6 | def client_id, do: System.get_env("TWITCH_CLIENT_ID") || "" 7 | def client_id_header, do: [{"Client-Id", client_id()}] 8 | def extension_version, do: System.get_env("EXTENSION_VERSION") || "1.6.4" 9 | 10 | def webhook_transport, 11 | do: %{ 12 | method: "webhook", 13 | callback: 14 | Application.get_env(:stream_closed_captioner_phoenix, :eventsub_callback_url) <> 15 | "/webhooks", 16 | secret: eventsub_secret() 17 | } 18 | 19 | def eventsub_secret, do: System.get_env("TWITCH_EVENTSUB_SECRET") || "" 20 | 21 | def client_secret, 22 | do: Application.get_env(:stream_closed_captioner_phoenix, :twitch_client_secret) || "" 23 | 24 | def token_secret, do: System.get_env("TWITCH_TOKEN_SECRET") || "" 25 | defp auth_header(token), do: [{"Authorization", "Bearer " <> token}] 26 | end 27 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/plugs/context.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Context do 2 | @behaviour Plug 3 | 4 | import Plug.Conn 5 | 6 | def init(opts), do: opts 7 | 8 | def call(conn, _) do 9 | context = 10 | %{} 11 | |> build_user_context(conn) 12 | |> build_token_context(conn) 13 | 14 | put_private(conn, :absinthe, %{context: context}) 15 | end 16 | 17 | defp build_user_context(context, conn) do 18 | with "" <> token <- StreamClosedCaptionerPhoenixWeb.UserAuth.fetch_cookie_user_token(conn), 19 | user <- 20 | StreamClosedCaptionerPhoenix.Accounts.get_user_by_session_token(token) do 21 | Map.merge(context, %{current_user: user}) 22 | else 23 | _ -> context 24 | end 25 | end 26 | 27 | defp build_token_context(context, conn) do 28 | with ["Bearer " <> token] <- get_req_header(conn, "authorization"), 29 | {:ok, decoded_token} <- Twitch.Jwt.verify_and_validate(token) do 30 | Map.merge(context, %{decoded_token: decoded_token}) 31 | else 32 | _ -> context 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_live/live_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect context.web_module %>.LiveHelpers do 2 | import Phoenix.LiveView.Helpers 3 | 4 | @doc """ 5 | Renders a component inside the `<%= inspect context.web_module %>.ModalComponent` component. 6 | 7 | The rendered modal receives a `:return_to` option to properly update 8 | the URL when the modal is closed. 9 | 10 | ## Examples 11 | 12 | <%%= live_modal @socket, <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent, 13 | id: @<%= schema.singular %>.id || :new, 14 | action: @live_action, 15 | <%= schema.singular %>: @<%= schema.singular %>, 16 | return_to: Routes.<%= schema.singular %>_index_path(@socket, :index) %> 17 | """ 18 | def live_modal(socket, component, opts) do 19 | path = Keyword.fetch!(opts, :return_to) 20 | title = Keyword.fetch!(opts, :title) 21 | modal_opts = [id: :modal, return_to: path, title: title, component: component, opts: opts] 22 | live_component(socket, <%= inspect context.web_module %>.ModalComponent, modal_opts) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/support/fixtures/bits_fixture.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.BitsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `StreamClosedCaptionerPhoenix.Bits` context. 5 | """ 6 | 7 | import StreamClosedCaptionerPhoenix.AccountsFixtures 8 | 9 | def bits_balance_fixture(attrs \\ %{}) do 10 | {:ok, bits_balance} = 11 | attrs 12 | |> Enum.into(%{ 13 | balance: 0, 14 | user_id: user_fixture().id 15 | }) 16 | |> StreamClosedCaptionerPhoenix.Bits.create_bits_balance() 17 | 18 | bits_balance 19 | end 20 | 21 | def bits_transaction_fixture(attrs \\ %{}) do 22 | {:ok, bits_transaction} = 23 | attrs 24 | |> Enum.into(%{ 25 | amount: 42, 26 | display_name: "some display_name", 27 | purchaser_uid: "some purchaser_uid", 28 | sku: "some sku", 29 | time: ~N[2010-04-17 14:00:00], 30 | transaction_id: "some transaction_id", 31 | user_id: user_fixture().id 32 | }) 33 | |> StreamClosedCaptionerPhoenix.Bits.create_bits_transaction() 34 | 35 | bits_transaction 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/types/deepgram.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Types.DeepgramAlternative do 2 | defstruct confidence: 0, 3 | transcript: nil 4 | 5 | use ExConstructor 6 | end 7 | 8 | defmodule StreamClosedCaptionerPhoenix.Types.DeepgramChannel do 9 | alias StreamClosedCaptionerPhoenix.Types.DeepgramAlternative 10 | defstruct alternatives: [] 11 | 12 | use ExConstructor 13 | 14 | def new(data, args \\ []) do 15 | res = super(data, args) 16 | 17 | %{ 18 | res 19 | | alternatives: 20 | Enum.map( 21 | res.alternatives, 22 | &DeepgramAlternative.new/1 23 | ) 24 | } 25 | end 26 | end 27 | 28 | defmodule StreamClosedCaptionerPhoenix.DeepgramResponse do 29 | alias StreamClosedCaptionerPhoenix.Types.DeepgramChannel 30 | 31 | defstruct duration: 0, 32 | start: nil, 33 | is_final: nil, 34 | speech_final: nil, 35 | channel: %{} 36 | 37 | use ExConstructor 38 | 39 | def new(data, args \\ []) do 40 | res = super(data, args) 41 | 42 | %{res | channel: DeepgramChannel.new(res.channel)} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_registration/_twitch_connect_button.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 4 | 13 | Twitch Glitch 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Continue with Twitch 27 | 28 | -------------------------------------------------------------------------------- /assets/js/controllers/darkmode_controller.js: -------------------------------------------------------------------------------- 1 | import { ApplicationController } from "stimulus-use" 2 | 3 | export default class extends ApplicationController { 4 | static targets = ["darkmodeOff", "darkmodeOn"] 5 | 6 | connect() { 7 | if (this.isDarkmode()) { 8 | this.darkmodeIconOn() 9 | } else { 10 | this.darkmodeIconOff() 11 | } 12 | } 13 | 14 | darkmodeIconOff() { 15 | this.darkmodeOnTarget.classList.remove('hidden') 16 | this.darkmodeOffTarget.classList.add('hidden') 17 | } 18 | 19 | darkmodeIconOn() { 20 | this.darkmodeOnTarget.classList.add('hidden') 21 | this.darkmodeOffTarget.classList.remove('hidden') 22 | } 23 | 24 | toggle() { 25 | if (this.isDarkmode()) { 26 | this.darkmodeIconOff() 27 | document.documentElement.classList.remove('dark') 28 | localStorage.theme = 'light' 29 | } else { 30 | this.darkmodeIconOn() 31 | document.documentElement.classList.add('dark') 32 | localStorage.theme = 'dark' 33 | } 34 | } 35 | 36 | isDarkmode() { 37 | return localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /assets/js/service/deepgram.js: -------------------------------------------------------------------------------- 1 | import { curry, isNil } from 'ramda'; 2 | 3 | const audio = { 4 | mediaRecorder: null 5 | } 6 | 7 | const constraints = { audio: true }; 8 | 9 | const onSuccess = function onSuccess(callback, stream) { 10 | audio.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); 11 | 12 | if (isNil(audio.mediaRecorder)) throw new Error('MediaRecorder is not initialized'); 13 | 14 | audio.mediaRecorder.start(1000); 15 | 16 | audio.mediaRecorder.ondataavailable = function onData(evt) { 17 | if (evt.data.size > 0) { 18 | callback(evt.data); 19 | } 20 | }; 21 | }; 22 | 23 | const curriedOnSuccess = curry(onSuccess); 24 | 25 | const onError = function onError() { }; 26 | 27 | export const startDeepgram = (callback) => { 28 | if (navigator.mediaDevices.getUserMedia) { 29 | navigator.mediaDevices.getUserMedia(constraints) 30 | .then( 31 | curriedOnSuccess(callback), 32 | onError, 33 | ); 34 | } 35 | }; 36 | 37 | export const stopDeepgram = () => { 38 | audio.mediaRecorder.stop(); 39 | delete audio.mediaRecorder; 40 | }; 41 | 42 | export const isDeepgramActive = () => audio.mediaRecorder?.state === 'recording'; 43 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/controllers/bits_balance_debit_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.BitsBalanceDebitControllerTest do 2 | import StreamClosedCaptionerPhoenix.Factory 3 | use StreamClosedCaptionerPhoenixWeb.ConnCase, async: true 4 | 5 | setup :register_and_log_in_user 6 | 7 | describe "index" do 8 | test "lists all bits_balance_debits", %{conn: conn} do 9 | conn = get(conn, Routes.bits_balance_debit_path(conn, :index)) 10 | assert html_response(conn, 200) =~ "Listing Bits balance debits" 11 | end 12 | end 13 | 14 | describe "show bits_balance_debit" do 15 | setup [:create_bits_balance_debit] 16 | 17 | test "redirects to show when data is valid", %{ 18 | conn: conn, 19 | bits_balance_debit: bits_balance_debit 20 | } do 21 | conn = get(conn, Routes.bits_balance_debit_path(conn, :show, bits_balance_debit.id)) 22 | assert html_response(conn, 200) =~ "Show Bits balance debit" 23 | end 24 | end 25 | 26 | defp create_bits_balance_debit(%{user: user}) do 27 | bits_balance_debit = insert(:bits_balance_debit, user: user) 28 | %{bits_balance_debit: bits_balance_debit} 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/fixtures/settings_fixture.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.SettingsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `StreamClosedCaptionerPhoenix.Settings` context. 5 | """ 6 | 7 | import StreamClosedCaptionerPhoenix.AccountsFixtures 8 | 9 | def stream_settings_fixture(attrs \\ %{}) do 10 | {:ok, settings} = 11 | attrs 12 | |> Enum.into(%{ 13 | caption_delay: 42, 14 | cc_box_size: true, 15 | filter_profanity: true, 16 | hide_text_on_load: true, 17 | language: "some language", 18 | pirate_mode: true, 19 | showcase: true, 20 | switch_settings_position: true, 21 | text_uppercase: true, 22 | user_id: user_fixture().id 23 | }) 24 | |> StreamClosedCaptionerPhoenix.Settings.create_stream_settings() 25 | 26 | settings 27 | end 28 | 29 | def translate_language_fixture(attrs \\ %{}) do 30 | {:ok, translate_language} = 31 | attrs 32 | |> Enum.into(%{language: "en", user_id: user_fixture().id}) 33 | |> StreamClosedCaptionerPhoenix.Settings.create_translate_language() 34 | 35 | translate_language 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/stream_settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.StreamSettingsController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | alias StreamClosedCaptionerPhoenix.Settings 5 | 6 | def edit(conn, _params) do 7 | user = conn.assigns.current_user 8 | stream_settings = Settings.get_stream_settings_by_user_id!(user.id) 9 | 10 | changeset = Settings.change_stream_settings(stream_settings) 11 | render(conn, "edit.html", stream_settings: stream_settings, changeset: changeset) 12 | end 13 | 14 | def update(conn, %{"stream_settings" => stream_settings_params}) do 15 | user = conn.assigns.current_user 16 | stream_settings = Settings.get_stream_settings_by_user_id!(user.id) 17 | 18 | case Settings.update_stream_settings(stream_settings, stream_settings_params) do 19 | {:ok, _} -> 20 | conn 21 | |> put_flash(:info, "Stream settings updated successfully.") 22 | |> redirect(to: Routes.caption_settings_index_path(conn, :index)) 23 | 24 | {:error, %Ecto.Changeset{} = changeset} -> 25 | render(conn, "edit.html", stream_settings: stream_settings, changeset: changeset) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_balance_debit_queries.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsBalanceDebitQueries do 2 | import Ecto.Query, warn: false 3 | 4 | @seconds_in_hours 3600 5 | 6 | alias StreamClosedCaptionerPhoenix.Bits.BitsBalanceDebit 7 | 8 | def all(query \\ base()), do: query 9 | 10 | def with_user_id(query \\ base(), user_id) do 11 | query 12 | |> where([bits_balance_debit], bits_balance_debit.user_id == ^user_id) 13 | end 14 | 15 | def with_id(query \\ base(), id) do 16 | query 17 | |> where([bits_balance_debit], bits_balance_debit.id == ^id) 18 | end 19 | 20 | def less_than_one_day_ago(query \\ base()) do 21 | one_day_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(@seconds_in_hours * -24) 22 | # set seconds to 0 23 | one_day_ago = 24 | NaiveDateTime.new!( 25 | one_day_ago.year, 26 | one_day_ago.month, 27 | one_day_ago.day, 28 | one_day_ago.hour, 29 | one_day_ago.minute, 30 | 0 31 | ) 32 | 33 | query 34 | |> where([bits_balance_debit], bits_balance_debit.created_at >= ^one_day_ago) 35 | end 36 | 37 | defp base do 38 | from(_ in BitsBalanceDebit, as: :bits_balance_debit) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title suffix=" · Closed Captions"> 9 | <%= assigns[:page_title] || "Stream Closed Captioner" %> 10 | 11 | <%= render_tags_all(assigns[:meta_tags] || %{}) %> 12 | 13 | 14 | 16 | 17 | 18 | 19 | <%= _header(assigns) %> 20 | <%= @inner_content %> 21 | 22 | <%= _footer(assigns) %> 23 | 25 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/live/live_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.LiveHelpers do 2 | import Phoenix.LiveView.Helpers 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts 5 | 6 | @doc """ 7 | Renders a component inside the `StreamClosedCaptionerPhoenixWeb.ModalComponent` component. 8 | 9 | The rendered modal receives a `:return_to` option to properly update 10 | the URL when the modal is closed. 11 | 12 | ## Examples 13 | 14 | <%= live_modal @socket, StreamClosedCaptionerPhoenixWeb.CaptionSettingsLive.FormComponent, 15 | id: @stream_settings.id || :new, 16 | action: @live_action, 17 | stream_settings: @stream_settings, 18 | return_to: Routes.stream_settings_index_path(@socket, :index) %> 19 | """ 20 | def live_modal(component, opts) do 21 | path = Keyword.fetch!(opts, :return_to) 22 | title = Keyword.fetch!(opts, :title) 23 | modal_opts = [id: :modal, return_to: path, title: title, component: component, opts: opts] 24 | live_component(StreamClosedCaptionerPhoenixWeb.ModalComponent, modal_opts) 25 | end 26 | 27 | def session_current_user(session) do 28 | session 29 | |> Map.get("user_token") 30 | |> Accounts.get_user_by_session_token() 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/schema/types/custom/json_type.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Schema.Types.Custom.JSON do 2 | @moduledoc """ 3 | The Json scalar type allows arbitrary JSON values to be passed in and out. 4 | Requires `{ :jason, "~> 1.1" }` package: https://github.com/michalmuskala/jason 5 | """ 6 | use Absinthe.Schema.Notation 7 | 8 | scalar :json, name: "Json" do 9 | description(""" 10 | The `Json` scalar type represents arbitrary json string data, represented as UTF-8 11 | character sequences. The Json type is most often used to represent a free-form 12 | human-readable json string. 13 | """) 14 | 15 | serialize(&encode/1) 16 | parse(&decode/1) 17 | end 18 | 19 | @spec decode(Absinthe.Blueprint.Input.String.t()) :: {:ok, term()} | :error 20 | @spec decode(Absinthe.Blueprint.Input.Null.t()) :: {:ok, nil} 21 | defp decode(%Absinthe.Blueprint.Input.String{value: value}) do 22 | case Jason.decode(value) do 23 | {:ok, result} -> {:ok, result} 24 | _ -> :error 25 | end 26 | end 27 | 28 | defp decode(%Absinthe.Blueprint.Input.Null{}) do 29 | {:ok, nil} 30 | end 31 | 32 | defp decode(_) do 33 | :error 34 | end 35 | 36 | defp encode(value), do: value 37 | end 38 | -------------------------------------------------------------------------------- /assets/js/utils/browser_compatibility.js: -------------------------------------------------------------------------------- 1 | function isChromium() { 2 | if (navigator.userAgentData.brands) { 3 | const hasGoogleChromeBrand = navigator.userAgentData.brands.find( 4 | (b) => b.brand === 'Google Chrome' 5 | ); 6 | 7 | if (hasGoogleChromeBrand) { 8 | return true; 9 | } 10 | } else { 11 | // Possibly older version of Chrome or Chromium that does not have 12 | // navigator.userAgentData.brands 13 | 14 | for ( 15 | let i = 0, u = 'Chromium', l = u.length; 16 | i < navigator.plugins.length; 17 | i++ 18 | ) { 19 | if ( 20 | navigator.plugins[i].name != null && 21 | navigator.plugins[i].name.substr(0, l) === u 22 | ) { 23 | return true; 24 | } 25 | } 26 | 27 | return false; 28 | } 29 | } 30 | 31 | const isEdge = () => { 32 | return navigator.userAgent && /(Edg\/|Edge)/.test(navigator.userAgent); 33 | }; 34 | 35 | const isChromeiOS = () => { 36 | return navigator.userAgent && navigator.userAgent.match('CriOS'); 37 | }; 38 | 39 | export const isBrowserCompatible = () => { 40 | return (!('webkitSpeechRecognition' in window) || 41 | navigator.userAgent.indexOf('Opera') !== -1 || 42 | isChromium() || 43 | isEdge() || 44 | isChromeiOS()) && 45 | !window.Cypress 46 | }; 47 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/schema/types/custom/datetime_type.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.Schema.Types.Custom.DateTime do 2 | use Absinthe.Schema.Notation 3 | 4 | scalar :datetime, name: "DateTime" do 5 | description(""" 6 | The `DateTime` scalar type represents a date and time in the UTC 7 | timezone. The DateTime appears in a JSON response as an ISO8601 formatted 8 | string, including UTC timezone ("Z"). The parsed date and time string will 9 | be converted to UTC and any UTC offset other than 0 will be rejected.. 10 | """) 11 | 12 | serialize(&encode/1) 13 | parse(&parse_datetime/1) 14 | end 15 | 16 | @spec parse_datetime(Absinthe.Blueprint.Input.String.t()) :: {:ok, DateTime.t()} | :error 17 | @spec parse_datetime(Absinthe.Blueprint.Input.Null.t()) :: {:ok, nil} 18 | defp parse_datetime(%Absinthe.Blueprint.Input.String{value: value}) do 19 | case DateTime.from_iso8601(value) do 20 | {:ok, datetime, 0} -> {:ok, datetime} 21 | {:ok, _datetime, _offset} -> :error 22 | _error -> :error 23 | end 24 | end 25 | 26 | defp parse_datetime(%Absinthe.Blueprint.Input.Null{}) do 27 | {:ok, nil} 28 | end 29 | 30 | defp parse_datetime(_) do 31 | :error 32 | end 33 | 34 | defp encode(value), do: DateTime.to_iso8601(value) 35 | end 36 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/extension.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Extension do 2 | import Helpers 3 | 4 | alias NewRelic.Instrumented.HTTPoison 5 | alias Twitch.ExtensionProvider 6 | alias Twitch.Extension.Credentials 7 | @behaviour ExtensionProvider 8 | 9 | @broadcaster :broadcaster 10 | @spec broadcaster_segment :: :broadcaster 11 | def broadcaster_segment, do: @broadcaster 12 | 13 | @global :global 14 | @spec global_segment :: :global 15 | def global_segment, do: @global 16 | 17 | @developer :developer 18 | @spec developer_segment :: :developer 19 | def developer_segment, do: @developer 20 | 21 | @impl ExtensionProvider 22 | def send_pubsub_message_for( 23 | %Credentials{} = %{client_id: client_id, jwt_token: token}, 24 | channel_id, 25 | message 26 | ) do 27 | headers = [ 28 | {"Content-Type", "application/json"}, 29 | {"Client-Id", client_id}, 30 | {"Authorization", "Bearer #{token}"} 31 | ] 32 | 33 | body = 34 | Jason.encode!(%{ 35 | message: Jason.encode!(message), 36 | content_type: "application/json", 37 | targets: ["broadcast"] 38 | }) 39 | 40 | encode_url_and_params("https://api.twitch.tv/extensions/message/" <> channel_id) 41 | |> HTTPoison.post(body, headers) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/gql_config.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.GqlConfig do 2 | def configuration do 3 | [ 4 | schema: StreamClosedCaptionerPhoenixWeb.Schema, 5 | pipeline: {__MODULE__, :absinthe_pipeline}, 6 | analyze_complexity: true, 7 | max_complexity: 50 8 | ] 9 | end 10 | 11 | def absinthe_pipeline(config, options) do 12 | options = Absinthe.Pipeline.options(options) 13 | 14 | config 15 | |> Absinthe.Plug.default_pipeline(options) 16 | |> Absinthe.Pipeline.insert_after( 17 | Absinthe.Phase.Document.Complexity.Result, 18 | {AbsintheSecurity.Phase.IntrospectionCheck, options} 19 | ) 20 | |> Absinthe.Pipeline.insert_after( 21 | Absinthe.Phase.Document.Result, 22 | {AbsintheSecurity.Phase.FieldSuggestionsCheck, options} 23 | ) 24 | |> Absinthe.Pipeline.insert_after( 25 | Absinthe.Phase.Document.Complexity.Result, 26 | {AbsintheSecurity.Phase.MaxAliasesCheck, options} 27 | ) 28 | |> Absinthe.Pipeline.insert_after( 29 | Absinthe.Phase.Document.Complexity.Result, 30 | {AbsintheSecurity.Phase.MaxDepthCheck, options} 31 | ) 32 | |> Absinthe.Pipeline.insert_after( 33 | Absinthe.Phase.Document.Complexity.Result, 34 | {AbsintheSecurity.Phase.MaxDirectivesCheck, options} 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/live/page_live.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.PageLive do 2 | use StreamClosedCaptionerPhoenixWeb, :live_view 3 | 4 | @impl true 5 | def mount(_params, _session, socket) do 6 | {:ok, assign(socket, query: "", results: %{})} 7 | end 8 | 9 | @impl true 10 | def handle_event("suggest", %{"q" => query}, socket) do 11 | {:noreply, assign(socket, results: search(query), query: query)} 12 | end 13 | 14 | @impl true 15 | def handle_event("search", %{"q" => query}, socket) do 16 | case search(query) do 17 | %{^query => vsn} -> 18 | {:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")} 19 | 20 | _ -> 21 | {:noreply, 22 | socket 23 | |> put_flash(:error, "No dependencies found matching \"#{query}\"") 24 | |> assign(results: %{}, query: query)} 25 | end 26 | end 27 | 28 | defp search(query) do 29 | if not StreamClosedCaptionerPhoenixWeb.Endpoint.config(:code_reloader) do 30 | raise "action disabled when not in development" 31 | end 32 | 33 | for {app, desc, vsn} <- Application.started_applications(), 34 | app = to_string(app), 35 | String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"), 36 | into: %{}, 37 | do: {app, vsn} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/transcripts/transcript_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Transcripts.TranscriptAdmin do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | 4 | def ordering(_schema) do 5 | [desc: :id] 6 | end 7 | 8 | def get_user(%{user_id: id}) do 9 | id 10 | |> Accounts.get_user!() 11 | |> Map.get(:username) 12 | end 13 | 14 | def index(_) do 15 | [ 16 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 17 | name: nil, 18 | session: nil 19 | ] 20 | end 21 | 22 | # def form_fields(_) do 23 | # [ 24 | # user_id: %{update: :readonly}, 25 | # caption_delay: nil, 26 | # cc_box_size: nil, 27 | # filter_profanity: nil, 28 | # hide_text_on_load: nil, 29 | # language: nil, 30 | # pirate_mode: nil, 31 | # showcase: nil, 32 | # switch_settings_position: nil, 33 | # text_uppercase: nil, 34 | # ] 35 | # end 36 | 37 | def scheduled_tasks(_) do 38 | [ 39 | %{ 40 | name: "Remove Old Transcripts", 41 | initial_value: nil, 42 | every: 15, 43 | action: fn _ -> 44 | # count = Bakery.Products.cache_product_count() 45 | # "count" will be passed to this function in its next run. 46 | {:ok, nil} 47 | end 48 | } 49 | ] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use StreamClosedCaptionerPhoenixWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import StreamClosedCaptionerPhoenixWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint StreamClosedCaptionerPhoenixWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(StreamClosedCaptionerPhoenix.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(StreamClosedCaptionerPhoenix.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_live/modal_component.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect context.web_module %>.ModalComponent do 2 | use <%= inspect context.web_module %>, :live_component 3 | 4 | @impl true 5 | def render(assigns) do 6 | ~L""" 7 | 25 | """ 26 | end 27 | 28 | @impl true 29 | def handle_event("close", _, socket) do 30 | {:noreply, push_patch(socket, to: socket.assigns.return_to)} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_live/show.html.leex: -------------------------------------------------------------------------------- 1 | <%%= if @live_action in [:edit] do %> 2 | <%%= live_modal @socket, <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent, 3 | id: @<%= schema.singular %>.id, 4 | title: @page_title, 5 | action: @live_action, 6 | <%= schema.singular %>: @<%= schema.singular %>, 7 | return_to: Routes.<%= schema.route_helper %>_show_path(@socket, :show, @<%= schema.singular %>) %> 8 | <%% end %> 9 | 10 |
11 |
12 |
13 |
14 |
15 | Show <%= schema.human_singular %> 16 |
17 |
18 |
19 | <%= for {k, _} <- schema.attrs do %> 20 |
21 | <%= Phoenix.Naming.humanize(Atom.to_string(k)) %>: 22 | <%%= @<%= schema.singular %>.<%= k %> %> 23 |
24 | <% end %> 25 |
26 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserRegistrationController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts 5 | alias StreamClosedCaptionerPhoenix.Accounts.User 6 | alias StreamClosedCaptionerPhoenixWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | changeset = Accounts.change_user_registration(%User{}) 10 | render(conn, "new.html", changeset: changeset) 11 | end 12 | 13 | @spec create(Plug.Conn.t(), map) :: Plug.Conn.t() 14 | def create(conn, %{"user" => user_params}) do 15 | case Accounts.register_user(user_params) do 16 | {:ok, %{user: user}} -> 17 | conn 18 | |> put_flash(:info, "User created successfully.") 19 | |> UserAuth.log_in_user(user) 20 | 21 | {:error, _, %Ecto.Changeset{} = changeset, _} -> 22 | render(conn, "new.html", changeset: changeset) 23 | end 24 | end 25 | 26 | def delete(conn, _params) do 27 | current_user = conn.assigns.current_user 28 | 29 | case Accounts.delete_user(current_user) do 30 | {:ok, _} -> 31 | conn 32 | |> put_flash(:info, "Account successfully deleted.") 33 | |> redirect(to: "/") 34 | 35 | {:error, reason} -> 36 | conn 37 | |> put_flash(:error, reason) 38 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors'); 2 | const plugin = require('tailwindcss/plugin'); 3 | 4 | module.exports = { 5 | darkMode: 'class', 6 | content: { 7 | content: ['./js/**/*.js', 8 | '../lib/*_web.ex', 9 | '../lib/*_web/**/*.*ex' 10 | ], 11 | options: { 12 | safelist: ['dark'], 13 | } 14 | }, 15 | plugins: [ 16 | require('nightwind'), 17 | require('@tailwindcss/aspect-ratio'), 18 | require('kutty'), 19 | require('@tailwindcss/typography'), 20 | require('@tailwindcss/forms'), 21 | plugin(({ addVariant }) => addVariant('phx-no-feedback', ['.phx-no-feedback&', '.phx-no-feedback &'])), 22 | plugin(({ addVariant }) => addVariant('phx-click-loading', ['.phx-click-loading&', '.phx-click-loading &'])), 23 | plugin(({ addVariant }) => addVariant('phx-submit-loading', ['.phx-submit-loading&', '.phx-submit-loading &'])), 24 | plugin(({ addVariant }) => addVariant('phx-change-loading', ['.phx-change-loading&', '.phx-change-loading &'])), 25 | ], 26 | theme: { 27 | extend: { 28 | colors: { 29 | green: colors.emerald, 30 | yellow: colors.amber, 31 | purple: colors.violet, 32 | } 33 | }, 34 | nightwind: { 35 | colors: { 36 | white: 'gray.800', 37 | red: { 38 | 100: 'red.100', 39 | }, 40 | yellow: { 41 | 100: 'yellow.100', 42 | }, 43 | }, 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/error/500.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title suffix=" · Closed Captions"> 9 | <%= assigns[:page_title] || "Stream Closed Captioner" %> 10 | 11 | 12 | 19 | 20 | 21 | 22 |
23 |
24 |

500

25 |

26 | Sorry, the servers have crashed. 27 |

28 |

29 | If this problem continues messag me on Twitter 30 | @Talk2meGooseman 31 | or go back to the homepage. 32 |

33 |
34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/live/modal_component.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ModalComponent do 2 | use StreamClosedCaptionerPhoenixWeb, :live_component 3 | 4 | @impl true 5 | def render(assigns) do 6 | ~H""" 7 | 32 | """ 33 | end 34 | 35 | @impl true 36 | def handle_event("close", _, socket) do 37 | {:noreply, push_patch(socket, to: socket.assigns.return_to)} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/session.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title suffix=" · Closed Captions"> 9 | <%= assigns[:page_title] || "Stream Closed Captioner" %> 10 | 11 | <%= render_tags_all(assigns[:meta_tags] || %{}) %> 12 | 13 | 14 | 16 | 17 | 18 | 19 | <%= _header(assigns) %> 20 | 21 |
22 | 25 | 28 | 29 | <%= @inner_content %> 30 |
31 | 32 | <%= _footer(assigns) %> 33 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/live/caption_settings_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.CaptionSettingsLive.FormComponent do 2 | use StreamClosedCaptionerPhoenixWeb, :live_component 3 | 4 | alias StreamClosedCaptionerPhoenix.Settings 5 | 6 | @impl true 7 | def update(%{stream_settings: stream_settings} = assigns, socket) do 8 | changeset = Settings.change_stream_settings(stream_settings) 9 | language_selection = Settings.spoken_languages() 10 | 11 | {:ok, 12 | socket 13 | |> assign(assigns) 14 | |> assign(:changeset, changeset) 15 | |> assign(:language_selection, language_selection)} 16 | end 17 | 18 | @impl true 19 | def handle_event("validate", %{"stream_settings" => stream_settings_params}, socket) do 20 | changeset = 21 | socket.assigns.stream_settings 22 | |> Settings.change_stream_settings(stream_settings_params) 23 | |> Map.put(:action, :validate) 24 | 25 | {:noreply, assign(socket, :changeset, changeset)} 26 | end 27 | 28 | def handle_event("save", %{"stream_settings" => stream_settings_params}, socket) do 29 | case Settings.update_stream_settings(socket.assigns.stream_settings, stream_settings_params) do 30 | {:ok, _stream_settings} -> 31 | {:noreply, 32 | socket 33 | |> put_flash(:info, "Stream settings updated successfully")} 34 | 35 | {:error, %Ecto.Changeset{} = changeset} -> 36 | {:noreply, assign(socket, :changeset, changeset)} 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/announcements/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Annoucements and News 4 |

5 |

Everything Stream Closed Captioner, where you can find out about news, updates, and 6 | features.

7 |
8 |
9 | <%= for page <- @pages do %> 10 |
11 |

12 | <%= get_in(page, ["properties", "Published", "date", "start"]) %> 13 |

14 |

15 | get_in(page, ["id"]) |> String.replace("-", "") } class="text-gray-900 hover:text-purple-700"> 16 | <%= get_in(page, ["properties", "Name", "title"]) |> List.first |> get_in(["plain_text"]) %> 17 | 18 |

19 |

20 | <%= get_in(page, ["properties", "tldr", "rich_text"]) |> List.first |> get_in(["plain_text"]) %> 21 |

22 | get_in(page, ["id"]) |> String.replace("-", "") } 24 | class="btn btn-primary btn-sm">Continue Reading 25 |
26 | <% end %> 27 |
28 |
29 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_reset_password/edit.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Reset Password
4 | <%= form_for @changeset, ~p"/users/reset_password/#{@token}", fn f -> %> 5 | <%= if @changeset.action do %> 6 |
7 | Oops, something went wrong! Please check the errors below. 8 |
9 | <% end %> 10 | 11 |
12 | <%= label f, :password, "New password", class: "form-label" %> 13 | <%= password_input f, :password, required: true, class: "form-input" %> 14 | <%= error_tag f, :password %> 15 |
16 | 17 |
18 | <%= label f, :password_confirmation, "Confirm new password", class: "form-label" %> 19 | <%= password_input f, :password_confirmation, required: true, class: "form-input" %> 20 | <%= error_tag f, :password_confirmation %> 21 |
22 | 23 |
24 | <%= submit "Reset password", class: "btn btn-dark w-full" %> 25 |
26 | <% end %> 27 | 28 |
29 | I do rememebr my password 30 |
31 | 32 |
33 | <%= link "Register", to: ~p"/users/register", class: "btn btn-link btn-sm dark:text-blue-500" %> 34 | <%= link "Log in", to: ~p"/users/log_in", class: "btn btn-link btn-sm dark:text-blue-500" %> 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix/jobs/send_chat_reminder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Jobs.SendChatReminderTest do 2 | import Mox 3 | use StreamClosedCaptionerPhoenix.DataCase, async: true 4 | use Oban.Testing, repo: StreamClosedCaptionerPhoenix.Repo 5 | 6 | alias StreamClosedCaptionerPhoenix.Jobs.SendChatReminder 7 | alias StreamClosedCaptionerPhoenixWeb.UserTracker 8 | 9 | setup do 10 | UserTracker.untrack(self(), "active_channels", "123") 11 | end 12 | 13 | test "it sends a chat message to the broadcaster if the channel is not active" do 14 | Twitch.MockHelix 15 | |> expect(:send_extension_chat_message, fn _creds, "123", message -> 16 | assert message == 17 | "Hey @talk2megooseman, here is your friendly reminder to turn on Stream Closed Captioner." 18 | 19 | {:ok, %{}} 20 | end) 21 | 22 | # Enqueue a job 23 | assert :ok = 24 | Oban.Testing.perform_job( 25 | SendChatReminder, 26 | %{broadcaster_user_id: "123", broadcaster_user_login: "talk2megooseman"}, 27 | [] 28 | ) 29 | 30 | verify!() 31 | end 32 | 33 | test "it does not send a chat message to the broadcaster if the channel is active" do 34 | UserTracker.track(self(), "active_channels", "123", %{ 35 | last_publish: System.system_time(:second) 36 | }) 37 | 38 | # Enqueue a job 39 | assert :ok = 40 | Oban.Testing.perform_job( 41 | SendChatReminder, 42 | %{broadcaster_user_id: "123", broadcaster_user_login: "talk2megooseman"}, 43 | [] 44 | ) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /assets/js/controllers/translations_controller.js: -------------------------------------------------------------------------------- 1 | import debugLogger from "debug" 2 | import { isBrowserCompatible } from "../utils" 3 | 4 | import { ApplicationController } from "stimulus-use" 5 | 6 | const debug = debugLogger("cc:obs-controller") 7 | 8 | export default class extends ApplicationController { 9 | static targets = ["translationStatus", "bitsBalance", "displayTranslations", "translationsList"] 10 | 11 | connect() { 12 | if (isBrowserCompatible()) { 13 | import("../channels").then(this.successfulSocketConnection) 14 | } 15 | } 16 | 17 | disconnect() { } 18 | 19 | successfulSocketConnection = ({ captionsChannel }) => { 20 | this.captionsChannel = captionsChannel 21 | 22 | this.captionsChannel.on("transaction", ({ balance }) => { 23 | this.bitsBalanceTarget.innerHTML = balance 24 | }) 25 | 26 | this.captionsChannel.on("translationActivated", ({ enabled, balance }) => { 27 | if (enabled) { 28 | this.translationStatusTarget.innerHTML = "Enabled" 29 | this.bitsBalanceTarget.innerHTML = balance 30 | } 31 | }) 32 | } 33 | 34 | onCaptionsReceived = ({ detail: { translations } }) => { 35 | if (translations) { 36 | this.displayTranslationsTarget.classList.remove("hidden") 37 | this.translationsListTarget.innerHTML = "" 38 | 39 | for (const lang in translations) { 40 | const { name, text } = translations[lang]; 41 | 42 | const liNode = document.createElement("li") 43 | liNode.classList.add('list-item') 44 | liNode.innerText = `${name}: ${text}` 45 | 46 | this.translationsListTarget.appendChild(liNode) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/channels/user_tracker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserTrackerTest do 2 | use StreamClosedCaptionerPhoenixWeb.ChannelCase, async: true 3 | 4 | alias StreamClosedCaptionerPhoenixWeb.UserTracker 5 | 6 | # setup do 7 | # on_exit(fn -> 8 | # for pid <- UserTracker.fetchers_pids() do 9 | # ref = Process.monitor(pid) 10 | # assert_receive {:DOWN, ^ref, _, _, _}, 1000 11 | # end 12 | # end) 13 | # end 14 | 15 | setup do 16 | UserTracker.untrack(self(), "active_channels", "123") 17 | end 18 | 19 | test "recently_active_channels/0 return empty list when no channels are active" do 20 | assert UserTracker.recently_active_channels() == [] 21 | end 22 | 23 | test "recently_active_channels/0 return list of active channels" do 24 | UserTracker.track(self(), "active_channels", "123", %{ 25 | last_publish: System.system_time(:second) 26 | }) 27 | 28 | assert UserTracker.recently_active_channels() == ["123"] 29 | end 30 | 31 | test "channel_active?/1 return false when channel is not present" do 32 | assert UserTracker.channel_active?("123") == false 33 | end 34 | 35 | test "channel_active?/1 return false when channel has no activity" do 36 | UserTracker.track(self(), "active_channels", "123", %{}) 37 | assert UserTracker.channel_active?("123") == false 38 | end 39 | 40 | test "channel_active?/1 return true when channel was recently active" do 41 | UserTracker.track(self(), "active_channels", "123", %{ 42 | last_publish: System.system_time(:second) 43 | }) 44 | 45 | assert UserTracker.channel_active?("123") == true 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /Dockerfile.prod: -------------------------------------------------------------------------------- 1 | FROM hexpm/elixir:1.14.0-erlang-25.0-alpine-3.18.0 AS build 2 | 3 | # install build dependencies 4 | RUN apt-get update -y && apt-get install -y build-essential git npm wget \ 5 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 6 | 7 | # prepare build dir 8 | WORKDIR /app 9 | 10 | # install hex + rebar 11 | RUN mix local.hex --force && \ 12 | mix local.rebar --force 13 | 14 | # set build ENV 15 | ENV MIX_ENV=prod 16 | 17 | # install mix dependencies 18 | COPY mix.exs mix.lock ./ 19 | COPY config config 20 | RUN mix deps.get --only $MIX_ENV 21 | RUN mix deps.compile 22 | 23 | # build assets 24 | COPY assets/package.json assets/package-lock.json ./assets/ 25 | RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error 26 | 27 | # assets -- copy asset files so purgecss doesnt remove css files 28 | COPY lib/stream_closed_captioner_phoenix_web/live/ lib/stream_closed_captioner_phoenix_web/live/ 29 | COPY lib/stream_closed_captioner_phoenix_web/controllers/ lib/stream_closed_captioner_phoenix_web/controllers/ 30 | 31 | COPY priv priv 32 | COPY assets assets 33 | 34 | # compile assets 35 | RUN mix assets.deploy 36 | RUN mix phx.digest 37 | 38 | # compile and build release 39 | COPY lib lib 40 | # uncomment COPY if rel/ exists 41 | # COPY rel rel 42 | RUN mix do compile, release 43 | 44 | # prepare release image 45 | FROM alpine:3.9 AS app 46 | RUN apk add --no-cache openssl ncurses-libs 47 | 48 | WORKDIR /app 49 | 50 | RUN chown nobody:nobody /app 51 | 52 | USER nobody:nobody 53 | 54 | COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/stream_closed_captioner_phoenix ./ 55 | 56 | ENV HOME=/app 57 | 58 | CMD ["bin/stream_closed_captioner_phoenix", "start"] 59 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/dashboard/indicators/twitch_enabled_indicator.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 12 |
13 |

Twitch Extension Captions 14 |

15 |

Available and ready to turn on

16 |
17 |
18 |
19 | 22 |
23 | <%= _switch_on(assigns) %> 24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/azure/cognitive.ex: -------------------------------------------------------------------------------- 1 | defmodule Azure.Cognitive do 2 | import Helpers 3 | 4 | use NewRelic.Tracer 5 | 6 | alias Azure.Cognitive.Translations 7 | alias Ecto.UUID 8 | alias NewRelic.Instrumented.HTTPoison 9 | @behaviour Azure.CognitiveProvider 10 | 11 | @impl Azure.CognitiveProvider 12 | 13 | @trace :translate 14 | def translate(from_language \\ "en", to_languages, text) 15 | when is_list(to_languages) and is_binary(text) do 16 | language_tuple_list = 17 | Enum.flat_map(to_languages, fn lang -> 18 | [code | _] = String.split(from_language, "-") 19 | 20 | if lang != code do 21 | [{:to, lang}] 22 | else 23 | [] 24 | end 25 | end) 26 | 27 | params = [ 28 | {"api-version", "3.0"}, 29 | {:profanityAction, "Marked"}, 30 | {:from, from_language} | language_tuple_list 31 | ] 32 | 33 | headers = [ 34 | {"Content-Type", "application/json; charset=UTF-8"}, 35 | {"Ocp-Apim-Subscription-Key", System.get_env("COGNITIVE_SERVICE_KEY")}, 36 | {"Ocp-Apim-Subscription-Region", "westus2"}, 37 | {"X-ClientTraceId", UUID.generate()} 38 | ] 39 | 40 | body = 41 | Jason.encode!([ 42 | %{ 43 | text: text 44 | } 45 | ]) 46 | 47 | NewRelic.add_attributes(translate: %{from: from_language, to: to_languages, text: text}) 48 | 49 | [translations] = 50 | "https://guzman.codes/azure_proxy/translate" 51 | |> encode_url_and_params(params) 52 | |> HTTPoison.post!(body, headers) 53 | |> Map.fetch!(:body) 54 | |> Jason.decode!() 55 | 56 | Translations.new(translations) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/accounts/user_queries.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Accounts.UserQueries do 2 | import Ecto.Query, warn: false 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts.User 5 | alias StreamClosedCaptionerPhoenix.Repo 6 | alias StreamClosedCaptionerPhoenix.Settings.StreamSettings 7 | 8 | def with_id(query \\ base(), id) do 9 | query 10 | |> where([user], user.id == ^id) 11 | |> limit(1) 12 | end 13 | 14 | def with_ids(query \\ base(), ids) do 15 | query 16 | |> where([user], user.id in ^ids) 17 | end 18 | 19 | def with_provider(query \\ base(), provider) do 20 | query 21 | |> where([user], user.provider == ^provider) 22 | end 23 | 24 | def with_uid(query \\ base(), uid) do 25 | query 26 | |> where([user], user.uid == ^uid) 27 | |> limit(1) 28 | end 29 | 30 | def select_id_user_pair(query \\ base()) do 31 | query 32 | |> select([user], {user.id, user}) 33 | |> limit(1) 34 | end 35 | 36 | def with_email(query \\ base(), email) do 37 | query 38 | |> where([user], user.email == ^email) 39 | end 40 | 41 | def query_users_with_settings() do 42 | query = 43 | from(u in User, 44 | join: ss in StreamSettings, 45 | on: ss.user_id == u.id, 46 | select: %{id: u.id} 47 | ) 48 | 49 | query 50 | end 51 | 52 | def get_users_without_settings() do 53 | query = 54 | from(u in User, 55 | left_join: ss in StreamSettings, 56 | on: ss.user_id == u.id, 57 | where: is_nil(ss.user_id), 58 | select: u.id 59 | ) 60 | 61 | Repo.all(query) 62 | end 63 | 64 | defp base do 65 | from(_ in User, as: :user) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | release: 4 | types: [published] 5 | env: 6 | IMAGE_NAME: stream_closed_captioner_phoenix 7 | jobs: 8 | # Push image to GitHub Packages. 9 | # See also https://docs.docker.com/docker-hub/builds/ 10 | push: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | packages: write 14 | contents: read 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Build image 20 | run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" 21 | 22 | - name: Log into registry 23 | # This is where you will update the PAT to GITHUB_TOKEN 24 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 25 | 26 | - name: Push image 27 | run: | 28 | IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME 29 | 30 | # Change all uppercase to lowercase 31 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 32 | # Strip git ref prefix from version 33 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 34 | # Strip "v" prefix from tag name 35 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 36 | # Use Docker `latest` tag convention 37 | [ "$VERSION" == "master" ] && VERSION=latest 38 | echo IMAGE_ID=$IMAGE_ID 39 | echo VERSION=$VERSION 40 | docker tag $IMAGE_NAME $IMAGE_ID:$VERSION 41 | docker tag $IMAGE_NAME $IMAGE_ID:latest 42 | docker push $IMAGE_ID --all-tags 43 | env: 44 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 45 | -------------------------------------------------------------------------------- /assets/js/controllers/zoom_controller.js: -------------------------------------------------------------------------------- 1 | import debugLogger from "debug" 2 | 3 | import { ApplicationController } from "stimulus-use" 4 | 5 | import { isNil, isEmpty } from "ramda" 6 | 7 | const debug = debugLogger("cc:obs-controller") 8 | 9 | export default class extends ApplicationController { 10 | static targets = ["offButton", "onButton", "errorMarker", "errorMessage"] 11 | 12 | connect() { 13 | this.url = undefined 14 | this.enabled = false 15 | } 16 | 17 | disconnect() { } 18 | 19 | onUrlChange(e) { 20 | this.url = e.target.value 21 | this.dispatch("state", { enabled: this.enabled, url: this.url }) 22 | } 23 | 24 | enable = () => { 25 | this.clearErrorMessage() 26 | 27 | if (this.enabled) { 28 | this.onButtonTarget.classList.add("hidden") 29 | this.offButtonTarget.classList.remove("hidden") 30 | this.errorMarkerTarget.classList.add("hidden") 31 | this.enabled = false 32 | } else { 33 | if (isEmpty(this.url) || isNil(this.url)) { 34 | this.errorMarkerTarget.classList.remove("hidden") 35 | this.displayErrorMessage("Please provide a URL for Zoom") 36 | return 37 | } 38 | 39 | this.onButtonTarget.classList.remove("hidden") 40 | this.offButtonTarget.classList.add("hidden") 41 | this.errorMarkerTarget.classList.add("hidden") 42 | this.enabled = true 43 | } 44 | 45 | this.dispatch("state", { enabled: this.enabled, url: this.url }) 46 | } 47 | 48 | clearErrorMessage() { 49 | this.errorMessageTarget.classList.add("hidden") 50 | this.errorMessageTarget.innerText = "" 51 | } 52 | 53 | displayErrorMessage(text) { 54 | this.errorMessageTarget.classList.remove("hidden") 55 | this.errorMessageTarget.innerText = text 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/supporters/index.html.heex: -------------------------------------------------------------------------------- 1 |

Stream Closed Captioner Supporters

2 | 3 |

Twitch Subscribers

4 |
5 | <%= for subscriber <- get_in(@data, ["twitch", "broadcasterSubscriptions"]) |> filter_twitch_subscribers do %> 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
<%= subscriber["user"]["displayName"] %>
15 |
<%= subscriber["user"]["description"] %>
16 |
17 |
18 |
19 | <% end %> 20 |
21 | 22 |

Patrons

23 |
24 | <%= for patron <- get_in(@data, ["patreon", "campaignMembers"]) |> filter_patreon_subscribers || [] do %> 25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 |
<%= patron["fullName"] %>
34 | <%= get_polite_status(patron["currentlyEntitledAmountCents"]) %> 35 |
36 |
37 |
38 | <% end %> 39 |
40 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/showcase/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

<%= length(@data) %> Live Twitch Streams

3 |

Discover streamers that are using Stream Closed Captioner on their channels 4 |

5 | <%= if Enum.empty?(@data) do %> 6 |
7 | No Twitch channels are live right now. 8 |
9 | <% end %> 10 |
11 | <%= for user_stream <- @data do %> 12 |
13 | user_stream.user_name }> 14 | Kutty 16 | 17 |

<%= user_stream.game_name %>

18 |

19 | user_stream.user_name } 20 | class="text-gray-900 hover:text-purple-700"><%= user_stream.title %> 21 |

22 | user_stream.user_name }> 23 |
24 |

<%= user_stream.user_name %>

25 |

Viewers: <%= user_stream.viewer_count %>

26 |
27 |
28 |
29 | <% end %> 30 |
31 |
32 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.Index do 2 | use <%= inspect context.web_module %>, :live_view 3 | 4 | alias <%= inspect context.module %> 5 | alias <%= inspect schema.module %> 6 | 7 | @impl true 8 | def mount(_params, _session, socket) do 9 | {:ok, assign(socket, :<%= schema.collection %>, list_<%= schema.plural %>())} 10 | end 11 | 12 | @impl true 13 | def handle_params(params, _url, socket) do 14 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 15 | end 16 | 17 | defp apply_action(socket, :edit, %{"id" => id}) do 18 | socket 19 | |> assign(:page_title, "Edit <%= schema.human_singular %>") 20 | |> assign(:<%= schema.singular %>, <%= inspect context.alias %>.get_<%= schema.singular %>!(id)) 21 | end 22 | 23 | defp apply_action(socket, :new, _params) do 24 | socket 25 | |> assign(:page_title, "New <%= schema.human_singular %>") 26 | |> assign(:<%= schema.singular %>, %<%= inspect schema.alias %>{}) 27 | end 28 | 29 | defp apply_action(socket, :index, _params) do 30 | socket 31 | |> assign(:page_title, "Listing <%= schema.human_plural %>") 32 | |> assign(:<%= schema.singular %>, nil) 33 | end 34 | 35 | @impl true 36 | def handle_event("delete", %{"id" => id}, socket) do 37 | <%= schema.singular %> = <%= inspect context.alias %>.get_<%= schema.singular %>!(id) 38 | {:ok, _} = <%= inspect context.alias %>.delete_<%= schema.singular %>(<%= schema.singular %>) 39 | 40 | {:noreply, assign(socket, :<%= schema.collection %>, list_<%=schema.plural %>())} 41 | end 42 | 43 | defp list_<%= schema.plural %> do 44 | <%= inspect context.alias %>.list_<%= schema.plural %>() 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/captions_pipeline/translations.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.CaptionsPipeline.Translations do 2 | use NewRelic.Tracer 3 | 4 | alias Azure.Cognitive.Translations 5 | alias StreamClosedCaptionerPhoenix.Accounts.User 6 | alias StreamClosedCaptionerPhoenix.Bits 7 | alias StreamClosedCaptionerPhoenix.Settings 8 | 9 | @trace :maybe_translate 10 | def maybe_translate(payload, key, %User{} = user) do 11 | text = Map.get(payload, key) 12 | 13 | if Bits.user_active_debit_exists?(user.id) do 14 | %Translations{translations: translations} = get_translations(user, text) 15 | %{payload | translations: translations} 16 | else 17 | to_languages = Settings.get_formatted_translate_languages_by_user(user.id) 18 | bits_balance = Bits.get_bits_balance_for_user(user) 19 | 20 | if Enum.empty?(to_languages) || bits_balance.balance < 500 do 21 | payload 22 | else 23 | activate_translations_for(user, payload, text) 24 | end 25 | end 26 | end 27 | 28 | defp activate_translations_for(%User{} = user, payload, text) do 29 | case Bits.activate_translations_for(user) do 30 | {:ok, _} -> 31 | translations = get_translations(user, text) 32 | %{payload | translations: translations} 33 | 34 | _ -> 35 | payload 36 | end 37 | end 38 | 39 | defp get_translations(%User{} = user, text) do 40 | {:ok, stream_settings} = Settings.get_stream_settings_by_user_id(user.id) 41 | 42 | from_language = stream_settings.language 43 | # Sort so keys are always in same order for consistent hashing 44 | to_languages = 45 | Settings.get_formatted_translate_languages_by_user(user.id) |> Map.keys() |> Enum.sort() 46 | 47 | Azure.perform_translations(from_language, to_languages, text) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field, opts \\ %{}) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: opts[:class], 15 | phx_feedback_for: input_id(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(StreamClosedCaptionerPhoenixWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(StreamClosedCaptionerPhoenixWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/live/transcirpts_live/show.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 |
9 | 10 | 11 | 12 | 13 | <%= Enum.join(@final_list, ". ") %> 14 | 15 | 16 | 17 | <%= @interim %> 18 | 19 | 20 |
21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 |
32 |
33 | 34 |
35 |
36 |
37 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/oauth.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Oauth do 2 | import Helpers 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts.User 5 | alias Twitch.Helix.Credentials 6 | alias Twitch.Parser 7 | alias NewRelic.Instrumented.HTTPoison 8 | 9 | def get_client_access_token() do 10 | credentials = get_credentials() 11 | 12 | headers = [ 13 | {"Content-Type", "application/json"}, 14 | {"Client-Id", credentials.client_id} 15 | ] 16 | 17 | params = %{ 18 | client_id: credentials.client_id, 19 | client_secret: credentials.client_secret, 20 | grant_type: "client_credentials", 21 | scope: "" 22 | } 23 | 24 | access_token = 25 | encode_url_and_params("https://id.twitch.tv/oauth2/token", params) 26 | |> HTTPoison.post!("", headers) 27 | |> Map.fetch!(:body) 28 | |> Jason.decode!() 29 | |> get_in(["access_token"]) 30 | 31 | Map.put(credentials, :access_token, access_token) 32 | end 33 | 34 | def get_users_access_token(%User{} = user) do 35 | credentials = get_credentials() 36 | 37 | case validate_token(user.access_token) do 38 | {:ok, _} -> 39 | Map.put(credentials, :access_token, user.access_token) 40 | 41 | {:error, _} -> 42 | nil 43 | # Refresh token 44 | end 45 | end 46 | 47 | defp validate_token(access_token) do 48 | headers = [ 49 | {"Authorization", "OAuth #{access_token}"} 50 | ] 51 | 52 | encode_url_and_params("https://id.twitch.tv/oauth2/validate") 53 | |> HTTPoison.get(headers) 54 | |> Parser.parse() 55 | end 56 | 57 | defp get_credentials, 58 | do: %Credentials{ 59 | client_id: Application.get_env(:stream_closed_captioner_phoenix, :twitch_client_id), 60 | client_secret: Application.get_env(:stream_closed_captioner_phoenix, :twitch_client_secret) 61 | } 62 | end 63 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/helix_provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.HelixProvider do 2 | alias Twitch.Helix.{Credentials, Stream, Transaction} 3 | 4 | @callback get_streams( 5 | Credentials.t(), 6 | list(String.t()), 7 | String.t() | nil 8 | ) :: list(Stream.t()) 9 | 10 | @callback get_transactions(Credentials.t()) :: list(Transaction.t()) 11 | 12 | @callback get_users_active_extensions(Credentials.t()) :: map() 13 | 14 | @callback send_extension_chat_message(Twitch.Extension.Credentials.t(), String.t(), String.t()) :: 15 | tuple() 16 | 17 | @callback get_live_channels( 18 | Credentials.t(), 19 | String.t() | nil 20 | ) :: [Channel.t()] 21 | 22 | @callback set_configuration_for( 23 | Twitch.Extension.Credentials.t(), 24 | atom(), 25 | String.t(), 26 | map() 27 | ) :: any 28 | 29 | @callback get_configuration_for( 30 | Twitch.Extension.Credentials.t(), 31 | atom(), 32 | String.t() 33 | ) :: {:ok, HTTPoison.Response.t()} 34 | 35 | @callback eventsub_subscribe( 36 | Credentials.t(), 37 | String.t(), 38 | String.t(), 39 | String.t(), 40 | String.t() 41 | ) :: {:ok, HTTPoison.Response.t()} 42 | 43 | @callback get_eventsub_subscriptions( 44 | %{:access_token => binary, optional(any) => any}, 45 | String.t(), 46 | String.t() | nil 47 | ) :: 48 | list 49 | 50 | @callback delete_eventsub_subscription( 51 | %{ 52 | :access_token => binary, 53 | optional(any) => any 54 | }, 55 | String.t() 56 | ) :: Integer.t() 57 | end 58 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/services/twitch/jwt.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitch.Jwt do 2 | alias Twitch.Extension.Credentials 3 | alias Twitch.Extension.Token 4 | 5 | def get_credentials, 6 | do: %Credentials{ 7 | client_id: System.get_env("TWITCH_CLIENT_ID") || "", 8 | token_secret: Application.get_env(:stream_closed_captioner_phoenix, :twitch_token_secret) 9 | } 10 | 11 | def verify_and_validate(token) do 12 | credentials = get_credentials() 13 | signer = create_signer(credentials) 14 | 15 | Token.verify_and_validate(token, signer) 16 | end 17 | 18 | @spec sign_token_for(:pubsub | :standard, String.t()) :: %Twitch.Extension.Credentials{ 19 | client_id: binary, 20 | token_secret: binary, 21 | jwt_token: term() 22 | } 23 | def sign_token_for(:standard, channel_id) do 24 | credentials = get_credentials() 25 | signer = create_signer(credentials) 26 | 27 | claims = %{ 28 | "role" => "external", 29 | "channel_id" => channel_id, 30 | "user_id" => "120750024" 31 | } 32 | 33 | token_with_claims = Token.generate_and_sign!(claims, signer) 34 | Map.put(credentials, :jwt_token, token_with_claims) 35 | end 36 | 37 | def sign_token_for(:pubsub, channel_id) do 38 | credentials = get_credentials() 39 | signer = create_signer(credentials) 40 | 41 | claims = %{ 42 | "role" => "external", 43 | "channel_id" => channel_id, 44 | "user_id" => "120750024", 45 | "pubsub_perms" => %{ 46 | "send" => [ 47 | "broadcast" 48 | ] 49 | } 50 | } 51 | 52 | token_with_claims = Token.generate_and_sign!(claims, signer) 53 | Map.put(credentials, :jwt_token, token_with_claims) 54 | end 55 | 56 | defp create_signer(credentials) do 57 | secret = credentials.token_secret |> Base.decode64!() 58 | Joken.Signer.create("HS256", secret) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/bits/bits_transaction_queries.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Bits.BitsTransactionQueries do 2 | import Ecto.Query, warn: false 3 | 4 | alias StreamClosedCaptionerPhoenix.Bits.BitsBalanceDebit 5 | alias StreamClosedCaptionerPhoenix.Bits.BitsTransaction 6 | 7 | def all(query \\ base()), do: query 8 | 9 | @spec with_user_id(any, any) :: Ecto.Query.t() 10 | def with_user_id(query \\ base(), user_id) do 11 | query 12 | |> where([bits_transaction], bits_transaction.user_id == ^user_id) 13 | end 14 | 15 | @spec with_id(any, any) :: Ecto.Query.t() 16 | def with_id(query \\ base(), id) do 17 | query 18 | |> where([bits_transaction], bits_transaction.id == ^id) 19 | end 20 | 21 | @spec with_transaction_id(any, String.t()) :: Ecto.Query.t() 22 | def with_transaction_id(query \\ base(), transaction_id) do 23 | query 24 | |> where([bits_transaction], bits_transaction.transaction_id == ^transaction_id) 25 | end 26 | 27 | defp base do 28 | from(_ in BitsTransaction, as: :bits_transaction) 29 | end 30 | 31 | def get_bits_transactions_and_debits_for_user(user_id) do 32 | transaction_query = 33 | from(t in BitsTransaction, 34 | where: t.user_id == ^user_id, 35 | select: %{ 36 | amount: t.amount, 37 | time: t.time, 38 | id: t.transaction_id, 39 | purchaser_id: t.purchaser_uid, 40 | action: "purchase" 41 | } 42 | ) 43 | 44 | union_all_query = 45 | from(d in BitsBalanceDebit, 46 | where: d.user_id == ^user_id, 47 | select: %{ 48 | amount: d.amount, 49 | time: d.created_at, 50 | id: "", 51 | purchaser_id: "", 52 | action: "debit" 53 | }, 54 | union_all: ^transaction_query 55 | ) 56 | 57 | from(s in subquery(union_all_query), order_by: [desc: s.time]) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.pretty_html/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | Listing <%= schema.human_plural %> 7 |
8 | <%%= link to: Routes.<%= schema.route_helper %>_path(@conn, :new), class: "btn btn-icon btn-sm btn-light" do %> 9 | 10 | <%% end %> 11 |
12 |
13 | 14 | 15 | 16 | <%= for {k, _} <- schema.attrs do %> 17 | <% end %> 18 | 19 | 20 | 21 | 22 | <%%= for <%= schema.singular %> <- @<%= schema.plural %> do %> 23 | 24 | <%= for {k, _} <- schema.attrs do %> 25 | <% end %> 26 | 31 | 32 | <%% end %> 33 | 34 |
<%= Phoenix.Naming.humanize(Atom.to_string(k)) %>
<%%= <%= schema.singular %>.<%= k %> %> 27 | <%%= link "Show", to: Routes.<%= schema.route_helper %>_path(@conn, :show, <%= schema.singular %>) %> 28 | <%%= link "Edit", to: Routes.<%= schema.route_helper %>_path(@conn, :edit, <%= schema.singular %>) %> 29 | <%%= link "Delete", to: Routes.<%= schema.route_helper %>_path(@conn, :delete, <%= schema.singular %>), method: :delete, data: [confirm: "Are you sure?"] %> 30 |
35 |
36 |
37 |
38 |
39 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/settings/translate_language.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Settings.TranslateLanguage do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | import Ecto.Query, warn: false 5 | 6 | alias StreamClosedCaptionerPhoenix.Repo 7 | 8 | schema "translate_languages" do 9 | field :language, :string 10 | belongs_to :user, StreamClosedCaptionerPhoenix.Accounts.User 11 | 12 | timestamps(inserted_at: :created_at) 13 | end 14 | 15 | @doc false 16 | def changeset(translate_language, attrs) do 17 | translate_language 18 | |> cast(attrs, [:user_id, :language]) 19 | |> foreign_key_constraint(:user_id, name: "fk_rails_e519515539") 20 | |> unique_constraint([:language, :user_id], 21 | name: "index_translate_languages_on_user_id_and_language" 22 | ) 23 | |> validate_required([:user_id, :language]) 24 | |> validate_inclusion(:language, StreamClosedCaptionerPhoenix.Settings.valid_language_codes()) 25 | |> validate_language_count() 26 | end 27 | 28 | @doc false 29 | def update_changeset(translate_language, attrs) do 30 | translate_language 31 | |> cast(attrs, [:language]) 32 | |> validate_required([:language]) 33 | |> unique_constraint([:language, :user_id], 34 | name: "index_translate_languages_on_user_id_and_language" 35 | ) 36 | |> validate_language_count() 37 | |> validate_inclusion(:language, StreamClosedCaptionerPhoenix.Settings.valid_language_codes()) 38 | end 39 | 40 | @doc false 41 | defp validate_language_count(changeset) do 42 | with %{user_id: user_id} <- changeset.changes do 43 | count = 44 | StreamClosedCaptionerPhoenix.Settings.TranslateLanguage 45 | |> where(user_id: ^user_id) 46 | |> Repo.count() 47 | 48 | if count >= 3 do 49 | add_error(changeset, :language, "cannot have more than 3 languages") 50 | else 51 | changeset 52 | end 53 | else 54 | _ -> changeset 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Accounts.UserNotifier do 2 | import Bamboo.Email 3 | 4 | defp deliver(to, subject, body) do 5 | require Logger 6 | Logger.debug(body) 7 | 8 | new_email( 9 | to: to, 10 | from: "erik.guzman@guzman.codes", 11 | subject: subject, 12 | text_body: body 13 | ) 14 | |> StreamClosedCaptionerPhoenix.Mailer.deliver_now() 15 | end 16 | 17 | @doc """ 18 | Deliver instructions to confirm account. 19 | """ 20 | def deliver_confirmation_instructions(user, url) do 21 | deliver(user.email, "Confirm your account", """ 22 | 23 | ============================== 24 | 25 | Hi #{user.email}, 26 | 27 | You can confirm your account by visiting the URL below: 28 | 29 | #{url} 30 | 31 | If you didn't create an account with us, please ignore this. 32 | 33 | ============================== 34 | """) 35 | end 36 | 37 | @doc """ 38 | Deliver instructions to reset a user password. 39 | """ 40 | def deliver_reset_password_instructions(user, url) do 41 | deliver(user.email, "Stream CC Password Reset Instructions", """ 42 | 43 | ============================== 44 | 45 | Hi #{user.email}, 46 | 47 | You can reset your password by visiting the URL below: 48 | 49 | #{url} 50 | 51 | If you didn't request this change, please ignore this. 52 | 53 | ============================== 54 | """) 55 | end 56 | 57 | @doc """ 58 | Deliver instructions to update a user email. 59 | """ 60 | def deliver_update_email_instructions(user, url) do 61 | deliver(user.email, "Stream CC Update Email Instructions", """ 62 | 63 | ============================== 64 | 65 | Hi #{user.email}, 66 | 67 | You can change your email by visiting the URL below: 68 | 69 | #{url} 70 | 71 | If you didn't request this change, please ignore this. 72 | 73 | ============================== 74 | """) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserConfirmationController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Accounts.get_user_by_email(email) do 12 | Accounts.deliver_user_confirmation_instructions( 13 | user, 14 | &Routes.user_confirmation_url(conn, :confirm, &1) 15 | ) 16 | end 17 | 18 | # Regardless of the outcome, show an impartial success/error message. 19 | conn 20 | |> put_flash( 21 | :info, 22 | "If your email is in our system and it has not been confirmed yet, " <> 23 | "you will receive an email with instructions shortly." 24 | ) 25 | |> redirect(to: "/") 26 | end 27 | 28 | # Do not log in the user after confirmation to avoid a 29 | # leaked token giving the user access to the account. 30 | def confirm(conn, %{"token" => token}) do 31 | case Accounts.confirm_user(token) do 32 | {:ok, _} -> 33 | conn 34 | |> put_flash(:info, "Account confirmed successfully.") 35 | |> redirect(to: "/") 36 | 37 | :error -> 38 | # If there is a current user and the account was already confirmed, 39 | # then odds are that the confirmation link was already visited, either 40 | # by some automation or by the user themselves, so we redirect without 41 | # a warning message. 42 | case conn.assigns do 43 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 44 | redirect(conn, to: "/") 45 | 46 | %{} -> 47 | conn 48 | |> put_flash(:error, "Account confirmation link is invalid or it has expired.") 49 | |> redirect(to: "/") 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use StreamClosedCaptionerPhoenix.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias StreamClosedCaptionerPhoenix.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import StreamClosedCaptionerPhoenix.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | StreamClosedCaptionerPhoenix.DataCase.setup_sandbox(tags) 32 | 33 | :ok 34 | end 35 | 36 | @doc """ 37 | Sets up the sandbox based on the test tags. 38 | """ 39 | def setup_sandbox(tags) do 40 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(StreamClosedCaptionerPhoenix.Repo, shared: not tags[:async]) 41 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 42 | end 43 | 44 | @doc """ 45 | A helper that transforms changeset errors into a map of messages. 46 | 47 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 48 | assert "password is too short" in errors_on(changeset).password 49 | assert %{password: ["password is too short"]} = errors_on(changeset) 50 | 51 | """ 52 | def errors_on(changeset) do 53 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 54 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 55 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 56 | end) 57 | end) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/error/404.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title suffix=" · Closed Captions"> 9 | <%= assigns[:page_title] || "Stream Closed Captioner" %> 10 | 11 | 12 | 19 | 20 | 21 | 22 |
23 |
24 |
25 |

26 | Error 404 27 |

28 |

29 | Oops! 30 | The page you're looking for isn't here. 31 |

32 |

33 | You might have the wrong address, or the page may 34 | have moved. 35 |

36 | 37 | Back to homepage 38 | 39 |
40 |
41 |
42 | 46 |
47 |
48 |
49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/channels/active_presence.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.ActivePresence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | """ 8 | 9 | # alias StreamClosedCaptionerPhoenix.Accounts 10 | 11 | @active_time_out 700 12 | 13 | use Phoenix.Presence, 14 | otp_app: :stream_closed_captioner_phoenix, 15 | pubsub_server: StreamClosedCaptionerPhoenix.PubSub 16 | 17 | # def fetch(_topic, presences) do 18 | # users = presences |> Map.keys() |> Accounts.get_users_map() 19 | 20 | # for {key, %{metas: metas}} <- presences, into: %{} do 21 | # {key, %{metas: metas, user: users[String.to_integer(key)]}} 22 | # end 23 | # end 24 | 25 | def recently_active_channels do 26 | StreamClosedCaptionerPhoenixWeb.ActivePresence.list("active_channels") 27 | |> Enum.reduce([], &reduced_user_list/2) 28 | end 29 | 30 | def channel_active?(channel_id) do 31 | StreamClosedCaptionerPhoenixWeb.ActivePresence.get_by_key("active_channels", channel_id) 32 | |> channel_recently_published?() 33 | end 34 | 35 | defp reduced_user_list({uid, %{metas: metas}}, acc) when is_binary(uid) do 36 | elapased_time = current_timestamp() - get_last_publish(metas) 37 | 38 | if currently_active(elapased_time) do 39 | [uid | acc] 40 | else 41 | acc 42 | end 43 | end 44 | 45 | defp reduced_user_list(_, acc), do: acc 46 | 47 | defp channel_recently_published?(%{metas: metas}) do 48 | elapased_time = current_timestamp() - get_last_publish(metas) 49 | currently_active(elapased_time) 50 | end 51 | 52 | defp channel_recently_published?([]), do: false 53 | 54 | defp current_timestamp, do: System.system_time(:second) 55 | 56 | defp get_last_publish(metas), 57 | do: metas |> List.first() |> Map.get(:last_publish, current_timestamp()) 58 | 59 | defp currently_active(elapsed_time), do: elapsed_time <= @active_time_out 60 | end 61 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix/settings/stream_settings_admin.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenix.Settings.StreamSettingsAdmin do 2 | alias StreamClosedCaptionerPhoenix.Accounts 3 | alias StreamClosedCaptionerPhoenix.Accounts.UserQueries 4 | 5 | def search_fields(_schema) do 6 | [ 7 | user: [:email, :username, :uid] 8 | ] 9 | end 10 | 11 | def task_stream_settings() do 12 | [ 13 | # %{ 14 | # name: "Users with out Stream Settings", 15 | # initial_value: 0, 16 | # every: 15, 17 | # action: fn _v -> 18 | # user_ids = UserQueries.get_users_without_settings() 19 | # {:ok, user_ids} 20 | # end 21 | # } 22 | ] 23 | end 24 | 25 | def widgets(_schema, _conn) do 26 | [ 27 | %{ 28 | type: "tidbit", 29 | title: "Users with out Stream Settings", 30 | content: UserQueries.get_users_without_settings() |> Enum.count(), 31 | order: 1, 32 | width: 4, 33 | icon: '' 34 | } 35 | ] 36 | end 37 | 38 | def plural_name(_) do 39 | "Stream Settings" 40 | end 41 | 42 | def ordering(_schema) do 43 | [asc: :id] 44 | end 45 | 46 | def get_user(%{user_id: id}) do 47 | id 48 | |> Accounts.get_user!() 49 | |> Map.get(:username) 50 | end 51 | 52 | def index(_) do 53 | [ 54 | user_id: %{name: "User", value: fn p -> get_user(p) end}, 55 | caption_delay: nil, 56 | cc_box_size: nil, 57 | filter_profanity: nil, 58 | hide_text_on_load: nil, 59 | language: nil, 60 | pirate_mode: nil, 61 | showcase: nil, 62 | switch_settings_position: nil, 63 | text_uppercase: nil 64 | ] 65 | end 66 | 67 | def form_fields(_) do 68 | [ 69 | user_id: %{update: :readonly}, 70 | caption_delay: nil, 71 | cc_box_size: nil, 72 | filter_profanity: nil, 73 | hide_text_on_load: nil, 74 | language: nil, 75 | pirate_mode: nil, 76 | showcase: nil, 77 | switch_settings_position: nil, 78 | text_uppercase: nil 79 | ] 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/stream_closed_captioner_phoenix_web/live/stream_settings_live_test.exs: -------------------------------------------------------------------------------- 1 | # # defmodule StreamClosedCaptionerPhoenixWeb.CaptionSettingsLiveTest do 2 | # use StreamClosedCaptionerPhoenixWeb.ConnCase 3 | 4 | # import StreamClosedCaptionerPhoenix.Factory 5 | # import Phoenix.LiveViewTest 6 | 7 | # alias StreamClosedCaptionerPhoenix.Settings 8 | 9 | # @create_attrs %{caption_delay: 42, filter_profanity: true} 10 | # @update_attrs %{caption_delay: 43, filter_profanity: false} 11 | # @invalid_attrs %{caption_delay: nil, filter_profanity: nil} 12 | 13 | # defp fixture(:stream_settings) do 14 | # insert(:stream_settings) 15 | # end 16 | 17 | # defp create_stream_settings(_) do 18 | # stream_settings = fixture(:stream_settings) 19 | # %{stream_settings: stream_settings} 20 | # end 21 | 22 | # describe "Show" do 23 | # setup [:create_stream_settings] 24 | 25 | # test "displays stream_settings", %{conn: conn, stream_settings: stream_settings} do 26 | # {:ok, _show_live, html} = 27 | # live(conn, Routes.stream_settings_show_path(conn, :show, stream_settings)) 28 | 29 | # assert html =~ "Show Stream settings" 30 | # end 31 | 32 | # test "updates stream_settings within modal", %{conn: conn, stream_settings: stream_settings} do 33 | # {:ok, show_live, _html} = 34 | # live(conn, Routes.stream_settings_show_path(conn, :show, stream_settings)) 35 | 36 | # assert show_live |> element("a", "Edit") |> render_click() =~ 37 | # "Edit Stream settings" 38 | 39 | # assert_patch(show_live, Routes.stream_settings_show_path(conn, :edit, stream_settings)) 40 | 41 | # assert show_live 42 | # |> form("#stream_settings-form", stream_settings: @invalid_attrs) 43 | # |> render_change() =~ "can't be blank" 44 | 45 | # {:ok, _, html} = 46 | # show_live 47 | # |> form("#stream_settings-form", stream_settings: @update_attrs) 48 | # |> render_submit() 49 | # |> follow_redirect(conn, Routes.stream_settings_show_path(conn, :show, stream_settings)) 50 | 51 | # assert html =~ "Stream settings updated successfully" 52 | # end 53 | # end 54 | # end 55 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserResetPasswordController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | 4 | alias StreamClosedCaptionerPhoenix.Accounts 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"user" => %{"email" => email}}) do 13 | if user = Accounts.get_user_by_email(email) do 14 | uri = %URI{scheme: "https", host: "stream-cc.gooseman.codes"} 15 | 16 | Accounts.deliver_user_reset_password_instructions( 17 | user, 18 | &Routes.user_reset_password_url(uri, :edit, &1) 19 | ) 20 | end 21 | 22 | # Regardless of the outcome, show an impartial success/error message. 23 | conn 24 | |> put_flash( 25 | :info, 26 | "If your email is in our system, you will receive instructions to reset your password shortly." 27 | ) 28 | |> redirect(to: "/") 29 | end 30 | 31 | def edit(conn, _params) do 32 | render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) 33 | end 34 | 35 | # Do not log in the user after reset password to avoid a 36 | # leaked token giving the user access to the account. 37 | def update(conn, %{"user" => user_params}) do 38 | case Accounts.reset_user_password(conn.assigns.user, user_params) do 39 | {:ok, _} -> 40 | conn 41 | |> put_flash(:info, "Password reset successfully.") 42 | |> redirect(to: Routes.user_session_path(conn, :new)) 43 | 44 | {:error, changeset} -> 45 | render(conn, "edit.html", changeset: changeset) 46 | end 47 | end 48 | 49 | defp get_user_by_reset_password_token(conn, _opts) do 50 | %{"token" => token} = conn.params 51 | 52 | if user = Accounts.get_user_by_reset_password_token(token) do 53 | conn |> assign(:user, user) |> assign(:token, token) 54 | else 55 | conn 56 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 57 | |> redirect(to: "/") 58 | |> halt() 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/components/layouts/_user_dropdown.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 11 | 35 |
36 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserSessionController do 2 | use StreamClosedCaptionerPhoenixWeb, :controller 3 | plug(Ueberauth) 4 | 5 | # alias Ueberauth.Strategy.Helpers 6 | 7 | alias StreamClosedCaptionerPhoenix.Accounts 8 | alias StreamClosedCaptionerPhoenix.AccountsOauth 9 | alias StreamClosedCaptionerPhoenixWeb.UserAuth 10 | 11 | def new(conn, _params) do 12 | render(conn, "new.html", error_message: nil) 13 | end 14 | 15 | def create(conn, %{"user" => user_params}) do 16 | %{"email" => email, "password" => password} = user_params 17 | 18 | if user = Accounts.get_user_by_email_and_password(email, password) do 19 | UserAuth.log_in_user(conn, user, user_params) 20 | else 21 | render(conn, "new.html", error_message: "Invalid email or password") 22 | end 23 | end 24 | 25 | def delete(conn, _params) do 26 | conn 27 | |> put_flash(:info, "Logged out successfully.") 28 | |> UserAuth.log_out_user() 29 | end 30 | 31 | def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do 32 | conn 33 | |> put_flash(:error, "Failed to authenticate.") 34 | |> redirect(to: "/") 35 | end 36 | 37 | def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do 38 | current_user = conn.assigns.current_user 39 | %{extra: %{raw_info: %{user: user}}, credentials: creds} = auth 40 | [user_info] = user["data"] 41 | 42 | map_creds = Map.from_struct(creds) 43 | 44 | case AccountsOauth.find_or_register_user_with_oauth(user_info, map_creds, current_user) do 45 | {:ok, %{user: user}} -> 46 | conn 47 | |> put_flash(:info, "Successfully authenticated.") 48 | |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) 49 | 50 | {:error, :user, _user_changeset, _} -> 51 | conn 52 | |> put_flash( 53 | :error, 54 | "Could not sign up with Twitch. Ensure you setup your account with an email." 55 | ) 56 | |> redirect(to: "/") 57 | 58 | {:error, reason} -> 59 | conn 60 | |> put_flash(:error, reason) 61 | |> redirect(to: "/") 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /config/dev.secret.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and secrets 2 | # from environment variables. You can also hardcode secrets, 3 | # although such is generally not recommended and you have to 4 | # remember to add this file to your .gitignore. 5 | import Config 6 | 7 | database_url = System.get_env("DATABASE_URL") 8 | 9 | config :stream_closed_captioner_phoenix, StreamClosedCaptionerPhoenix.Repo, 10 | # ssl: true, 11 | url: database_url, 12 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 13 | # The App was started from Rails which used the `schema_migrations` table with the same name but different schema 14 | # To continue with migrations from ecto from now on, we use choose a custom name for the ecto migrations 15 | # !!! From now on, migrations should only be done from Ecto !!! 16 | migration_source: "ecto_schema_migrations" 17 | 18 | secret_key_base = System.get_env("SECRET_KEY_BASE") 19 | 20 | config :stream_closed_captioner_phoenix, StreamClosedCaptionerPhoenixWeb.Endpoint, 21 | http: [ 22 | port: String.to_integer(System.get_env("PORT") || "4000"), 23 | transport_options: [socket_opts: [:inet6]] 24 | ], 25 | secret_key_base: secret_key_base 26 | 27 | config :ueberauth, Ueberauth.Strategy.Twitch.OAuth, 28 | client_id: System.get_env("TWITCH_CLIENT_ID"), 29 | client_secret: System.get_env("TWITCH_CLIENT_SECRET"), 30 | redirect_uri: "http://localhost:4000/auth/twitch/callback" 31 | 32 | # config :goth, 33 | # json: System.get_env("BAMBOO_EMAIL_CREDS") 34 | 35 | config :joken, default_signer: System.get_env("TWITCH_TOKEN_SECRET") 36 | 37 | config :stream_closed_captioner_phoenix, 38 | api_key: System.get_env("NOTION_API_KEY"), 39 | notion_version: System.get_env("NOTION_VERSION") 40 | 41 | config :stream_closed_captioner_phoenix, 42 | eventsub_callback_url: System.get_env("EVENTSUB_CALLBACK_URL") 43 | 44 | # ## Using releases (Elixir v1.9+) 45 | # 46 | # If you are doing OTP releases, you need to instruct Phoenix 47 | # to start each relevant endpoint: 48 | # 49 | # config :stream_closed_captioner_phoenix, StreamClosedCaptionerPhoenixWeb.Endpoint, server: true 50 | # 51 | # Then you can assemble a release by calling `mix release`. 52 | # See `mix help release` for more information. 53 | -------------------------------------------------------------------------------- /lib/stream_closed_captioner_phoenix_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule StreamClosedCaptionerPhoenixWeb.UserSocket do 2 | use Phoenix.Socket 3 | use Absinthe.Phoenix.Socket, schema: StreamClosedCaptionerPhoenixWeb.Schema 4 | 5 | alias StreamClosedCaptionerPhoenix.Accounts 6 | 7 | ## Channels 8 | # channel "room:*", StreamClosedCaptionerPhoenixWeb.RoomChannel 9 | 10 | channel("captions:*", StreamClosedCaptionerPhoenixWeb.CaptionsChannel) 11 | 12 | # Socket params are passed from the client and can 13 | # be used to verify and authenticate a user. After 14 | # verification, you can put default assigns into 15 | # the socket that will be set for all channels, ie 16 | # 17 | # {:ok, assign(socket, :user_id, verified_user_id)} 18 | # 19 | # To deny connection, return `:error`. 20 | # 21 | # See `Phoenix.Token` documentation for examples in 22 | # performing token verification on connect. 23 | @impl true 24 | def connect(%{"token" => token}, socket, _connect_info) do 25 | case Phoenix.Token.verify(socket, "user socket", token, max_age: 5_184_000) do 26 | {:ok, user_id} -> 27 | current_user = Accounts.get_user!(user_id) 28 | {:ok, assign(socket, :current_user, current_user)} 29 | 30 | {:error, _} -> 31 | :error 32 | end 33 | end 34 | 35 | def connect(%{"Authorization" => "Bearer " <> token} = _params, socket, _connect_info) do 36 | case Twitch.Jwt.verify_and_validate(token) do 37 | {:ok, _} -> 38 | socket = Absinthe.Phoenix.Socket.put_options(socket, context: %{}) 39 | {:ok, socket} 40 | 41 | {:error, _} -> 42 | :error 43 | end 44 | end 45 | 46 | def connect(_params, _socket, _connect_info), do: :error 47 | 48 | # Socket id's are topics that allow you to identify all sockets for a given user: 49 | # 50 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 51 | # 52 | # Would allow you to broadcast a "disconnect" event and terminate 53 | # all active sockets and channels for a given user: 54 | # 55 | # StreamClosedCaptionerPhoenixWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 56 | # 57 | # Returning `nil` makes this socket anonymous. 58 | @impl true 59 | def id(_socket), do: nil 60 | end 61 | --------------------------------------------------------------------------------