├── .credo.exs
├── .dockerignore
├── .env.example
├── .formatter.exs
├── .gitignore
├── .iex.exs
├── .tool-versions
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── css
│ └── app.css
├── js
│ └── app.ts
├── package.json
├── pnpm-lock.yaml
├── tailwind.config.js
└── vendor
│ ├── topbar.js
│ ├── video.js
│ └── videojs-youtube.js
├── config
├── config.exs
├── dev.exs
├── prod.exs
├── runtime.exs
└── test.exs
├── fiex
├── fly.toml
├── lib
├── algora.ex
├── algora
│ ├── accounts.ex
│ ├── accounts
│ │ ├── destination.ex
│ │ ├── entity.ex
│ │ ├── identity.ex
│ │ └── user.ex
│ ├── admin.ex
│ ├── admin
│ │ ├── generate_blurb_thumbnails.ex
│ │ └── replace_with_youtube.ex
│ ├── ads.ex
│ ├── ads
│ │ ├── ad.ex
│ │ ├── appearance.ex
│ │ ├── content_metrics.ex
│ │ ├── events.ex
│ │ ├── impression.ex
│ │ ├── product_review.ex
│ │ └── visit.ex
│ ├── application.ex
│ ├── cache.ex
│ ├── chat.ex
│ ├── chat
│ │ ├── events.ex
│ │ └── message.ex
│ ├── clipper.ex
│ ├── contact.ex
│ ├── contact
│ │ └── info.ex
│ ├── env.ex
│ ├── events.ex
│ ├── events
│ │ └── event.ex
│ ├── github.ex
│ ├── google.ex
│ ├── library.ex
│ ├── library
│ │ ├── channel.ex
│ │ ├── events.ex
│ │ ├── segment.ex
│ │ ├── subtitle.ex
│ │ ├── video.ex
│ │ └── video_thumbnail.ex
│ ├── mailer.ex
│ ├── ml.ex
│ ├── pipeline.ex
│ ├── pipeline
│ │ ├── README.md
│ │ ├── client_handler.ex
│ │ ├── demuxer.ex
│ │ ├── funnel.ex
│ │ ├── hls.ex
│ │ ├── hls
│ │ │ ├── ets_helper.ex
│ │ │ └── ll_controller.ex
│ │ ├── manager.ex
│ │ ├── manifest.ex
│ │ ├── sink.ex
│ │ ├── sink_bin.ex
│ │ ├── source_bin.ex
│ │ ├── storage.ex
│ │ ├── storage
│ │ │ ├── manifest.ex
│ │ │ ├── manifest_supervisor.ex
│ │ │ └── thumbnails.ex
│ │ └── supervisor.ex
│ ├── release.ex
│ ├── repo.ex
│ ├── restream.ex
│ ├── restream
│ │ └── websocket.ex
│ ├── shows.ex
│ ├── shows
│ │ └── show.ex
│ ├── stargazer.ex
│ ├── storage.ex
│ ├── terminate.ex
│ ├── util.ex
│ ├── workers
│ │ ├── hls_transmuxer.ex
│ │ ├── mp4_transmuxer.ex
│ │ └── transcriber.ex
│ └── youtube
│ │ ├── chat.ex
│ │ └── chat
│ │ ├── fetcher.ex
│ │ └── supervisor.ex
├── algora_web.ex
└── algora_web
│ ├── api_spec.ex
│ ├── api_spec
│ └── hls.ex
│ ├── channels
│ └── presence.ex
│ ├── components
│ ├── avatar.ex
│ ├── core_components.ex
│ ├── layouts.ex
│ ├── layouts
│ │ ├── app.html.heex
│ │ ├── live.html.heex
│ │ ├── live_bare.html.heex
│ │ ├── live_chat.html.heex
│ │ ├── root.html.heex
│ │ └── root_embed.html.heex
│ ├── rtmp_destination_icon_component.ex
│ └── tech_icon.ex
│ ├── controllers
│ ├── ad_redirect_controller.ex
│ ├── embed_popout_controller.ex
│ ├── error_html.ex
│ ├── fallback_controller.ex
│ ├── github_controller.ex
│ ├── hls_content_controller.ex
│ ├── oauth_callback_controller.ex
│ ├── oauth_login_controller.ex
│ ├── page_html.ex
│ ├── redirect_controller.ex
│ ├── show_calendar_controller.ex
│ ├── user_auth.ex
│ ├── video_popout_controller.ex
│ └── youtube_controller.ex
│ ├── embed
│ ├── endpoint.ex
│ └── router.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── live
│ ├── ad_live
│ │ ├── analytics.ex
│ │ ├── analytics.html.heex
│ │ ├── content_live.ex
│ │ ├── form_component.ex
│ │ ├── index.ex
│ │ ├── index.html.heex
│ │ ├── schedule.ex
│ │ ├── schedule.html.heex
│ │ ├── show.ex
│ │ └── show.html.heex
│ ├── ad_overlay_live.ex
│ ├── audience_live.ex
│ ├── channel_live.ex
│ ├── channel_live
│ │ ├── stream_form_component.ex
│ │ └── stream_form_component.html.heex
│ ├── chat_live.ex
│ ├── chat_popout.ex
│ ├── clipper_live.ex
│ ├── cossgpt_live.ex
│ ├── cossgpt_og_live.ex
│ ├── embed_live.ex
│ ├── hero_component.ex
│ ├── home_live.ex
│ ├── layout_component.ex
│ ├── nav.ex
│ ├── partner_live.ex
│ ├── player_component.ex
│ ├── settings_live.ex
│ ├── show_live
│ │ ├── form_component.ex
│ │ ├── index.ex
│ │ ├── index.html.heex
│ │ └── show.ex
│ ├── sign_in_live.ex
│ ├── studio_live.ex
│ ├── subscriptions_live.ex
│ ├── subtitle_live
│ │ ├── form_component.ex
│ │ ├── index.ex
│ │ ├── index.html.heex
│ │ ├── show.ex
│ │ └── show.html.heex
│ ├── tags_component.ex
│ ├── video_clipper_live.ex
│ └── video_live.ex
│ ├── plugs
│ ├── allow_iframe.ex
│ └── transform_docs.ex
│ ├── router.ex
│ ├── telemetry.ex
│ └── websockets
│ └── chat_socket.ex
├── mix.exs
├── mix.lock
├── observer
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
├── repo
│ ├── migrations
│ │ ├── .formatter.exs
│ │ ├── 20240229191000_init_oban.exs
│ │ ├── 20240229191100_init_core.exs
│ │ ├── 20240304213317_create_subtitles.exs
│ │ ├── 20240306163651_add_thumbnail_url_to_video.exs
│ │ ├── 20240306165832_remove_thumbnails_ready_from_video.exs
│ │ ├── 20240306230124_add_vertical_thumbnail_url_to_video.exs
│ │ ├── 20240310170243_add_fly_postgres_proc.exs
│ │ ├── 20240330185918_add_format_to_video.exs
│ │ ├── 20240331001729_add_transmuxed_from_to_video.exs
│ │ ├── 20240331022336_add_filename_to_video.exs
│ │ ├── 20240331181838_add_paths_to_video.exs
│ │ ├── 20240401001340_make_video_url_nullable.exs
│ │ ├── 20240401075738_add_description_to_video.exs
│ │ ├── 20240408000415_create_segments.exs
│ │ ├── 20240509132227_add_og_image_url_to_video.exs
│ │ ├── 20240510083721_add_solving_challenge_to_user.exs
│ │ ├── 20240519130701_create_destinations.exs
│ │ ├── 20240522125826_add_provider_refresh_token_to_identities.exs
│ │ ├── 20240522183524_create_entities.exs
│ │ ├── 20240522183934_modify_messages_for_entities.exs
│ │ ├── 20240523135647_create_events.exs
│ │ ├── 20240524154930_create_shows.exs
│ │ ├── 20240530121830_add_ordering_to_show.exs
│ │ ├── 20240602220449_update_show_description_type.exs
│ │ ├── 20240603112913_cascade_video_deletion.exs
│ │ ├── 20240719060446_add_platform_id_to_message.exs
│ │ ├── 20240729183655_create_ads.exs
│ │ ├── 20240729183917_create_ad_impressions.exs
│ │ ├── 20240730000856_create_ad_visits.exs
│ │ ├── 20240730160846_create_contact_info.exs
│ │ ├── 20240802162318_add_slug_to_ads.exs
│ │ ├── 20240805035256_add_border_color_to_ads.exs
│ │ ├── 20240809163713_create_ad_related_tables.exs
│ │ ├── 20240814021502_add_name_to_ads.exs
│ │ ├── 20240814024335_update_content_metrics_fields.exs
│ │ ├── 20240814033358_add_og_image_url_to_ads.exs
│ │ ├── 20240831185906_add_corrupted_to_video.exs
│ │ ├── 20240903140540_add_deleted_at_to_videos.exs
│ │ ├── 20240910030054_add_featured_to_users.exs
│ │ ├── 20240910150315_add_tags_to_users.exs
│ │ ├── 20240910150515_add_tags_to_videos.exs
│ │ ├── 20240926172045_rename_composite_asset_url_to_composite_asset_urls.exs
│ │ ├── 20240926215228_add_video_thumbnails_table.exs
│ │ ├── 20241211205543_add_gin_index_to_tags.exs
│ │ └── 20241217005445_convert_token_fields_to_text.exs
│ └── seeds.exs
└── static
│ ├── favicon.ico
│ ├── images
│ ├── analytics.png
│ ├── elixir.png
│ ├── in-video-ad.png
│ ├── live-billboard.png
│ ├── logo-1200px.png
│ ├── logo-192px.png
│ ├── logo-512px.png
│ ├── logo-gradient.png
│ ├── og
│ │ ├── cossgpt.png
│ │ ├── default.png
│ │ ├── home.png
│ │ └── partner.png
│ ├── partner-demo.png
│ ├── shows
│ │ ├── build-in-public.jpg
│ │ ├── coding-challenges.jpg
│ │ ├── coss-founder-podcast.jpg
│ │ ├── coss-office-hours.jpg
│ │ ├── eu-acc.jpg
│ │ ├── live-bounty-hunting.jpg
│ │ ├── request-for-comments.jpg
│ │ └── the-save-file.jpg
│ ├── sponsored-stream.png
│ └── tigris.svg
│ ├── manifest.json
│ └── robots.txt
├── rel
├── env.sh.eex
└── overlays
│ └── bin
│ ├── migrate
│ ├── migrate.bat
│ ├── server
│ └── server.bat
├── scripts
├── backfill.livemd
└── cossgpt.livemd
└── test
├── algora
├── ads_test.exs
└── pipeline
│ ├── README.md
│ ├── ets_helper_test.exs
│ ├── ll_controller_test.exs
│ └── storage_test.exs
├── support
├── conn_case.ex
└── data_case.ex
└── test_helper.exs
/.dockerignore:
--------------------------------------------------------------------------------
1 | .dockerignore
2 | # there are valid reasons to keep the .git, namely so that you can get the
3 | # current commit hash
4 | #.git
5 | .log
6 | tmp
7 |
8 | # Mix artifacts
9 | _build
10 | deps
11 | *.ez
12 | releases
13 |
14 | # Generate on crash by the VM
15 | erl_crash.dump
16 |
17 | # Static artifacts
18 | node_modules
19 |
20 | # Env files
21 | .env
22 | .env*.local
23 | .env.dev
24 | .env.prod
25 | .env.staging
26 |
27 | # Local files
28 | /tmp
29 | /.local
30 | /priv/cache
31 | *.patch
32 | /.fly
33 | /xref*
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL="postgresql://algora:password@localhost:5432/tv"
2 |
3 | GITHUB_CLIENT_ID=""
4 | GITHUB_CLIENT_SECRET=""
5 |
6 | AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev"
7 | AWS_REGION="auto"
8 | AWS_ACCESS_KEY_ID=""
9 | AWS_SECRET_ACCESS_KEY=""
10 |
11 | BUCKET_MEDIA=""
12 | BUCKET_ML=""
13 |
14 | RESTREAM_CLIENT_ID=""
15 | RESTREAM_CLIENT_SECRET=""
16 |
17 | REPLICATE_API_TOKEN=""
18 | HF_TOKEN=""
19 |
20 | EVENT_SINK_URL=""
21 |
22 | RESUME_RTMP=false
23 | RESUME_RTMP_ON_UNPUBLUSH=false
24 | RESUME_RTMP_TIMEOUT=3600
25 |
26 | SUPPORTS_H265=false
27 | TRANSCODE=4320p60@32000000|2160p60@16000000|1440p60@8000000|1440p30@4000000|720p30@2000000|360p30@1000000|180p30@500000
28 |
29 | FLAME_BACKEND=local
30 | FLAME_MIN=0
31 | FLAME_MAX=1
32 | FLAME_MAX_CONCURRENCY=10
33 | FLAME_IDLE_SHUTDOWN_AFTER=30
34 | #FLAME_MIX_TARGET=nvidia
35 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :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 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | algora-*.tar
24 |
25 | # Ignore assets that are produced by build tools.
26 | /priv/static/assets/
27 |
28 | # In case you use Node.js/npm, you want to ignore these.
29 | npm-debug.log
30 | /assets/node_modules/
31 |
32 | # Ignore env files
33 | .env
34 | .env*.local
35 | .env.dev
36 | .env.prod
37 | .env.staging
38 |
39 | # Ignore local files
40 | /tmp
41 | /.local
42 | /priv/cache
43 | *.patch
44 | /.fly
45 | /xref*
--------------------------------------------------------------------------------
/.iex.exs:
--------------------------------------------------------------------------------
1 | import Ecto.Query
2 | import Ecto.Changeset
3 |
4 | alias Algora.{Admin, Accounts, Library, Repo, Storage, Cache, ML, Shows}
5 |
6 | IEx.configure(inspect: [charlists: :as_lists])
7 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.17.3-otp-27
2 | erlang 27.1
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
2 | # Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | #
8 | # This file is based on these images:
9 | #
10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image
12 | # - https://pkgs.org/ - resource for finding needed packages
13 | # - Ex: hexpm/elixir:1.12.0-erlang-24.0.1-debian-bullseye-20210902-slim
14 | #
15 | ARG BUILDER_IMAGE="hexpm/elixir:1.17.3-erlang-26.2.5.5-debian-bookworm-20241016-slim"
16 | ARG RUNNER_IMAGE="debian:bookworm-20241016-slim"
17 |
18 | FROM ${BUILDER_IMAGE} as builder
19 |
20 | # install build dependencies
21 | RUN apt-get update -y && apt-get install -y build-essential git curl && apt-get clean && rm -f /var/lib/apt/lists/*_*
22 |
23 | # prepare build dir
24 | WORKDIR /app
25 |
26 | # install hex + rebar
27 | RUN mix local.hex --force && \
28 | mix local.rebar --force
29 |
30 | # set build ENV
31 | ENV MIX_ENV="prod"
32 |
33 | # install mix dependencies
34 | COPY mix.exs mix.lock ./
35 | RUN mix deps.get --only $MIX_ENV
36 | RUN mkdir config
37 |
38 | # copy compile-time config files before we compile dependencies
39 | # to ensure any relevant config change will trigger the dependencies
40 | # to be re-compiled.
41 | COPY config/config.exs config/${MIX_ENV}.exs config/
42 | RUN mix deps.compile
43 |
44 | COPY priv priv
45 |
46 | # Compile the release
47 | COPY lib lib
48 |
49 | # note: if your project uses a tool like https://purgecss.com/,
50 | # which customizes asset compilation based on what it finds in
51 | # your Elixir templates, you will need to move the asset compilation
52 | # step down so that `lib` is available.
53 | COPY assets assets
54 |
55 | # compile assets
56 | RUN mix assets.deploy
57 |
58 | RUN mix compile
59 |
60 | # Changes to config/runtime.exs don't require recompiling the code
61 | COPY config/runtime.exs config/
62 |
63 | COPY rel rel
64 | RUN mix release
65 |
66 | # start a new build stage so that the final image will only contain
67 | # the compiled release and other runtime necessities
68 | FROM ${RUNNER_IMAGE}
69 |
70 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ffmpeg imagemagick && apt-get clean && rm -f /var/lib/apt/lists/*_*
71 |
72 | # Set the locale
73 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
74 |
75 | ENV LANG en_US.UTF-8
76 | ENV LANGUAGE en_US:en
77 | ENV LC_ALL en_US.UTF-8
78 |
79 | WORKDIR "/app"
80 | RUN chown nobody /app
81 |
82 | # Needed because :image creates an hf cache folder
83 | # /nonexistent/.cache/bumblebee/huggingface/microsoft--resnet-50
84 | RUN mkdir /nonexistent
85 | RUN chown nobody /nonexistent
86 |
87 | # Only copy the final release from the build stage
88 | COPY --from=builder --chown=nobody:root /app/_build/prod/rel/algora ./
89 |
90 | USER nobody
91 |
92 | # Set the runtime ENV
93 | ENV ECTO_IPV6="true"
94 | ENV ERL_AFLAGS="-proto_dist inet6_tcp"
95 |
96 | CMD /app/bin/server
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@algora/tv",
3 | "version": "0.0.1",
4 | "description": "The interactive livestreaming & video sharing service for developers",
5 | "main": "app.ts",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "keywords": [
10 | "video",
11 | "hls",
12 | "rtmp",
13 | "video-sharing",
14 | "livestreaming"
15 | ],
16 | "author": "Algora PBC",
17 | "private": true,
18 | "devDependencies": {
19 | "@types/phoenix": "^1.6.4",
20 | "@types/phoenix_live_view": "^0.18.4"
21 | },
22 | "dependencies": {
23 | "@algora/hls.js": "1.5.17-algora.1",
24 | "vidstack": "^1.12.9"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 |
4 | const plugin = require("tailwindcss/plugin");
5 | const colors = require("tailwindcss/colors");
6 |
7 | const gray = {
8 | 50: "#f8f9fc",
9 | 100: "#f1f2f9",
10 | 200: "#e1e2ef",
11 | 300: "#cbcee1",
12 | 400: "#9497b8",
13 | 500: "#65688b",
14 | 600: "#484b6a",
15 | 700: "#343756",
16 | 800: "#1d1e3a",
17 | 900: "#100f29",
18 | 950: "#050217",
19 | };
20 |
21 | module.exports = {
22 | content: [
23 | "./js/**/*.js",
24 | "./js/**/*.ts",
25 | "../lib/*_web.ex",
26 | "../lib/*_web/**/*.*ex",
27 | ],
28 | theme: {
29 | extend: {
30 | colors: {
31 | gray,
32 | green: colors.emerald,
33 | purple: colors.indigo,
34 | yellow: colors.amber,
35 | },
36 | },
37 | },
38 | plugins: [
39 | require("@tailwindcss/forms"),
40 | // Allows prefixing tailwind classes with LiveView classes to add rules
41 | // only when LiveView classes are applied, for example:
42 | //
43 | //
44 | //
45 | plugin(({ addVariant }) =>
46 | addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])
47 | ),
48 | plugin(({ addVariant }) =>
49 | addVariant("phx-click-loading", [
50 | ".phx-click-loading&",
51 | ".phx-click-loading &",
52 | ])
53 | ),
54 | plugin(({ addVariant }) =>
55 | addVariant("phx-submit-loading", [
56 | ".phx-submit-loading&",
57 | ".phx-submit-loading &",
58 | ])
59 | ),
60 | plugin(({ addVariant }) =>
61 | addVariant("phx-change-loading", [
62 | ".phx-change-loading&",
63 | ".phx-change-loading &",
64 | ])
65 | ),
66 | ],
67 | };
68 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :algora,
11 | title: "Algora TV",
12 | description:
13 | "Algora TV is an interactive livestreaming & video sharing service for developers.",
14 | admin_emails: ["zafer@algora.io", "ioannis@algora.io"],
15 | ecto_repos: [Algora.Repo.Local],
16 | rtmp_port: 9006,
17 | rtmp_path: "live"
18 |
19 | # Configures the endpoint
20 | config :algora, AlgoraWeb.Endpoint,
21 | url: [host: "localhost"],
22 | secret_key_base: "H04mI/fsBvjCX3HO+P2bxFEM7PG3SaGTV+DE1f/BbTVG9oiOXSXsq+3tjDXxRXSe",
23 | pubsub_server: Algora.PubSub,
24 | live_view: [signing_salt: "fMm4VTD0Mkn/AB41KV+GwgofkocpAGOf"],
25 | render_errors: [
26 | formats: [html: AlgoraWeb.ErrorHTML, json: AlgoraWeb.ErrorJSON],
27 | layout: false
28 | ]
29 |
30 | config :algora, AlgoraWeb.Embed.Endpoint,
31 | url: [host: "localhost"],
32 | secret_key_base: "H04mI/fsBvjCX3HO+P2bxFEM7PG3SaGTV+DE1f/BbTVG9oiOXSXsq+3tjDXxRXSe",
33 | pubsub_server: Algora.PubSub,
34 | live_view: [signing_salt: "fMm4VTD0Mkn/AB41KV+GwgofkocpAGOf"],
35 | render_errors: [
36 | formats: [html: AlgoraWeb.ErrorHTML, json: AlgoraWeb.ErrorJSON],
37 | layout: false
38 | ]
39 |
40 | config :algora, Oban,
41 | repo: Algora.Repo.Local,
42 | queues: [default: 10]
43 |
44 | config :esbuild,
45 | version: "0.17.11",
46 | tv: [
47 | args:
48 | ~w(js/app.ts --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
49 | cd: Path.expand("../assets", __DIR__),
50 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
51 | ]
52 |
53 | # Configure tailwind (the version is required)
54 | config :tailwind,
55 | version: "3.4.0",
56 | tv: [
57 | args: ~w(
58 | --config=tailwind.config.js
59 | --input=css/app.css
60 | --output=../priv/static/assets/app.css
61 | ),
62 | cd: Path.expand("../assets", __DIR__)
63 | ]
64 |
65 | # Configures Elixir's Logger
66 | config :logger, :console,
67 | format: "$time $metadata[$level] $message\n",
68 | metadata: [:request_id]
69 |
70 | # Use Jason for JSON parsing in Phoenix
71 | config :phoenix, :json_library, Jason
72 |
73 | config :nx, default_backend: EXLA.Backend
74 |
75 | # ueberauth config
76 | config :ueberauth, Ueberauth,
77 | providers: [
78 | google: {Ueberauth.Strategy.Google, [
79 | prompt: "consent",
80 | access_type: "offline",
81 | default_scope: "email https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/yt-analytics.readonly"
82 | ]}
83 | ]
84 |
85 | config :ueberauth, Ueberauth.Strategy.Google.OAuth,
86 | client_id: System.get_env("GOOGLE_CLIENT_ID"),
87 | client_secret: System.get_env("GOOGLE_CLIENT_SECRET"),
88 | redirect_uri: System.get_env("GOOGLE_REDIRECT_URI")
89 |
90 | # Import environment specific config. This must remain at the bottom
91 | # of this file so it overrides the configuration defined above.
92 | import_config "#{config_env()}.exs"
93 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | config :algora, mode: :prod
4 |
5 | # For production, don't forget to configure the url host
6 | # to something meaningful, Phoenix uses this information
7 | # when generating URLs.
8 | #
9 | # Note we also include the path to a cache manifest
10 | # containing the digested version of static files. This
11 | # manifest is generated by the `mix phx.digest` task,
12 | # which you should run after static files are built and
13 | # before starting your production server.
14 | config :algora, AlgoraWeb.Endpoint,
15 | url: [host: "tv.algora.io", port: 80],
16 | cache_static_manifest: "priv/static/cache_manifest.json"
17 |
18 | config :algora, AlgoraWeb.Embed.Endpoint,
19 | url: [host: "tv.algora.io", port: 81],
20 | cache_static_manifest: "priv/static/cache_manifest.json"
21 |
22 | config :algora, :docs, url: "https://docs.tv.algora.io"
23 |
24 | # Do not print debug messages in production
25 | config :logger, level: :info
26 |
27 | config :swoosh, :api_client, Algora.Finch
28 |
29 | # ## SSL Support
30 | #
31 | # To get SSL working, you will need to add the `https` key
32 | # to the previous section and set your `:url` port to 443:
33 | #
34 | # config :algora, AlgoraWeb.Endpoint,
35 | # ...,
36 | # url: [host: "example.com", port: 443],
37 | # https: [
38 | # ...,
39 | # port: 443,
40 | # cipher_suite: :strong,
41 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
42 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
43 | # ]
44 | #
45 | # The `cipher_suite` is set to `:strong` to support only the
46 | # latest and more secure SSL ciphers. This means old browsers
47 | # and clients may not be supported. You can set it to
48 | # `:compatible` for wider support.
49 | #
50 | # `:keyfile` and `:certfile` expect an absolute path to the key
51 | # and cert in disk or a relative path inside priv, for example
52 | # "priv/ssl/server.key". For all supported SSL configuration
53 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
54 | #
55 | # We also recommend setting `force_ssl` in your endpoint, ensuring
56 | # no data is ever sent via http, always redirecting to https:
57 | #
58 | # config :algora, AlgoraWeb.Endpoint,
59 | # force_ssl: [hsts: true]
60 | #
61 | # Check `Plug.SSL` for all available options in `force_ssl`.
62 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | config :algora,
4 | mode: :dev,
5 | resume_rtmp: System.get_env("RESUME_RTMP", "false") == "true",
6 | resume_rtmp_on_unpublish: System.get_env("RESUME_RTMP_ON_UNPUBLUSH", "false") == "true",
7 | resume_rtmp_timeout: System.get_env("RESUME_RTMP_TIMEOUT", "3200"),
8 | supports_h265: System.get_env("SUPPORTS_H265", "false") == "true",
9 | transcode: (case System.get_env("TRANSCODE") do
10 | "" -> nil
11 | other -> other
12 | end),
13 | transcode_backend: nil,
14 | rtmp_port: String.to_integer(System.get_env("RTMP_PORT", "9006"))
15 |
16 | config :algora, :flame,
17 | flame_backend: FLAME.LocalBackend,
18 | min: String.to_integer(System.get_env("FLAME_MIN", "0")),
19 | max: String.to_integer(System.get_env("FLAME_MAX", "1")),
20 | max_concurrency: String.to_integer(System.get_env("FLAME_MAX_CONCURRENCY", "10")),
21 | idle_shutdown_after: String.to_integer(System.get_env("FLAME_IDLE_SHUTDOWN_AFTER", "30")),
22 | log: String.to_atom(System.get_env("FLAME_LOG", "debug"))
23 |
24 | # Configure your database
25 | #
26 | # The MIX_TEST_PARTITION environment variable can be used
27 | # to provide built-in test partitioning in CI environment.
28 | # Run `mix help test` for more information.
29 | config :algora, Algora.Repo,
30 | url: System.get_env("TEST_DATABASE_URL"),
31 | show_sensitive_data_on_connection_error: true,
32 | pool: Ecto.Adapters.SQL.Sandbox,
33 | pool_size: 10
34 |
35 | config :algora, Algora.Repo.Local,
36 | url: System.get_env("TEST_DATABASE_URL"),
37 | show_sensitive_data_on_connection_error: true,
38 | pool: Ecto.Adapters.SQL.Sandbox,
39 | pool_size: 10,
40 | priv: "priv/repo"
41 |
42 | config :algora, Oban, testing: :inline
43 |
44 | # We don't run a server during test. If one is required,
45 | # you can enable the server option below.
46 | config :algora, AlgoraWeb.Endpoint,
47 | http: [ip: {127, 0, 0, 1}, port: 4002],
48 | server: false
49 |
50 | config :algora, AlgoraWeb.Embed.Endpoint,
51 | http: [ip: {127, 0, 0, 1}, port: 4003],
52 | server: false
53 |
54 | # Print only warnings and errors during test
55 | config :logger, level: :warning
56 |
57 | # Initialize plugs at runtime for faster test compilation
58 | config :phoenix, :plug_init_mode, :runtime
59 |
60 | # Disable swoosh api client as it is only required for production adapters.
61 | config :swoosh, :api_client, false
62 |
--------------------------------------------------------------------------------
/fiex:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | fly ssh console --pty --select -C "/app/bin/algora remote"
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for algora-media on 2024-02-08T18:25:10+02:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = 'algora-media'
7 | primary_region = 'lax'
8 | kill_signal = 'SIGTERM'
9 | kill_timeout = '5s'
10 |
11 | [experimental]
12 | auto_rollback = true
13 |
14 | [build]
15 |
16 | [deploy]
17 | release_command = '/app/bin/migrate'
18 |
19 | [env]
20 | DNS_CLUSTER_QUERY = 'algora-media.internal'
21 | PRIMARY_REGION = 'lax'
22 | PHX_HOST = 'tv.algora.io'
23 | PORT = '4000'
24 |
25 | [mounts]
26 | source="algora_media_tmp"
27 | destination="/data"
28 |
29 | [[services]]
30 | protocol = 'tcp'
31 | internal_port = 4000
32 | processes = ['app']
33 |
34 | [[services.ports]]
35 | port = 80
36 | handlers = ['http']
37 | force_https = true
38 |
39 | [[services.ports]]
40 | port = 443
41 | handlers = ['tls', 'http']
42 |
43 | [services.concurrency]
44 | type = 'connections'
45 | hard_limit = 100000
46 | soft_limit = 2000
47 |
48 | [[services.tcp_checks]]
49 | interval = '15s'
50 | timeout = '2s'
51 | grace_period = '20s'
52 |
53 | [[services]]
54 | protocol = 'tcp'
55 | internal_port = 4001
56 | processes = ['app']
57 |
58 | [[services.ports]]
59 | port = 81
60 | handlers = ['http']
61 | force_https = true
62 |
63 | [[services.ports]]
64 | port = 444
65 | handlers = ['tls', 'http']
66 |
67 | [services.concurrency]
68 | type = 'connections'
69 | hard_limit = 100000
70 | soft_limit = 10000
71 |
72 | [[services.tcp_checks]]
73 | interval = '15s'
74 | timeout = '2s'
75 | grace_period = '20s'
76 |
77 | [[services]]
78 | protocol = 'tcp'
79 | internal_port = 9006
80 | processes = ['app']
81 |
82 | [[services.ports]]
83 | port = 9006
84 |
85 | [services.concurrency]
86 | type = 'connections'
87 | hard_limit = 100000
88 | soft_limit = 10000
89 |
90 |
--------------------------------------------------------------------------------
/lib/algora.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora do
2 | @moduledoc """
3 | The main interface for shared functionality.
4 | """
5 | require Logger
6 |
7 | @doc """
8 | Looks up `Application` config or raises if keyspace is not configured.
9 | """
10 | def config([main_key | rest] = keyspace) when is_list(keyspace) do
11 | main = Application.fetch_env!(:algora, main_key)
12 |
13 | Enum.reduce(rest, main, fn next_key, current ->
14 | case Keyword.fetch(current, next_key) do
15 | {:ok, val} -> val
16 | :error -> raise ArgumentError, "no config found under #{inspect(keyspace)}"
17 | end
18 | end)
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/algora/accounts/destination.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Accounts.Destination do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "destinations" do
6 | field :rtmp_url, :string
7 | field :stream_key, :string, redact: true
8 | field :active, :boolean, default: true
9 | belongs_to :user, Algora.Accounts.User
10 |
11 | timestamps()
12 | end
13 |
14 | def changeset(destination, attrs) do
15 | destination
16 | |> cast(attrs, [:rtmp_url, :stream_key, :active])
17 | |> validate_required([:rtmp_url, :stream_key])
18 | |> validate_rtmp_url()
19 | end
20 |
21 | defp validate_rtmp_url(changeset) do
22 | validate_change(changeset, :rtmp_url, fn :rtmp_url, rtmp_url ->
23 | case valid_rtmp_url?(rtmp_url) do
24 | :ok ->
25 | []
26 |
27 | {:error, message} ->
28 | [rtmp_url: message]
29 | end
30 | end)
31 | end
32 |
33 | defp valid_rtmp_url?(url) do
34 | case URI.parse(url) do
35 | %URI{scheme: scheme, host: host} when scheme in ["rtmp", "rtmps"] ->
36 | case :inet.gethostbyname(to_charlist(host)) do
37 | {:ok, _} -> :ok
38 | {:error, _} -> {:error, "must be a valid URL"}
39 | end
40 |
41 | _ ->
42 | {:error, "must be a valid URL starting with rtmp:// or rtmps://"}
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/algora/accounts/entity.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Accounts.Entity do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Algora.Accounts.User
6 |
7 | schema "entities" do
8 | field :name, :string
9 | field :handle, :string
10 | field :avatar_url, :string
11 | field :platform, :string
12 | field :platform_id, :string
13 | field :platform_meta, :map, default: %{}
14 |
15 | belongs_to :user, User
16 |
17 | timestamps()
18 | end
19 |
20 | @doc false
21 | def changeset(entity, attrs) do
22 | entity
23 | |> cast(attrs, [
24 | :user_id,
25 | :name,
26 | :handle,
27 | :avatar_url,
28 | :platform,
29 | :platform_id,
30 | :platform_meta
31 | ])
32 | |> validate_required([:handle, :platform, :platform_id, :platform_meta])
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/algora/accounts/identity.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Accounts.Identity do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Algora.Accounts.{Identity, User}
6 |
7 | # providers
8 | @github "github"
9 | @restream "restream"
10 |
11 | @derive {Inspect, except: [:provider_token, :provider_refresh_token, :provider_meta]}
12 | schema "identities" do
13 | field :provider, :string
14 | field :provider_token, :string
15 | field :provider_refresh_token, :string
16 | field :provider_email, :string
17 | field :provider_login, :string
18 | field :provider_name, :string, virtual: true
19 | field :provider_id, :string
20 | field :provider_meta, :map
21 |
22 | belongs_to :user, User
23 |
24 | timestamps()
25 | end
26 |
27 | @doc """
28 | sets up a changeset for oauth.
29 | for now, its just Google. Perhaps, more providers in the future?
30 | """
31 | def changeset(identity, attrs) do
32 | identity
33 | |> cast(attrs, [:provider, :provider_id, :provider_token, :provider_email, :provider_login, :provider_refresh_token])
34 | |> validate_required([:provider, :provider_token, :provider_id, :provider_email])
35 | |> validate_length(:provider, min: 1)
36 | end
37 |
38 | @doc """
39 | A user changeset for github registration.
40 | """
41 | def github_registration_changeset(info, primary_email, emails, token) do
42 | params = %{
43 | "provider_token" => token,
44 | "provider_id" => to_string(info["id"]),
45 | "provider_login" => info["login"],
46 | "provider_name" => info["name"] || info["login"],
47 | "provider_email" => primary_email
48 | }
49 |
50 | %Identity{provider: @github, provider_meta: %{"user" => info, "emails" => emails}}
51 | |> cast(params, [
52 | :provider_token,
53 | :provider_email,
54 | :provider_login,
55 | :provider_name,
56 | :provider_id
57 | ])
58 | |> validate_required([:provider_token, :provider_email, :provider_name, :provider_id])
59 | |> validate_length(:provider_meta, max: 10_000)
60 | end
61 |
62 | @doc """
63 | A user changeset for restream oauth.
64 | """
65 | def restream_oauth_changeset(info, user_id, %{token: token, refresh_token: refresh_token}) do
66 | params = %{
67 | "provider_token" => token,
68 | "provider_refresh_token" => refresh_token,
69 | "provider_id" => to_string(info["id"]),
70 | "provider_login" => info["username"],
71 | "provider_name" => info["username"],
72 | "provider_email" => info["email"],
73 | "user_id" => user_id
74 | }
75 |
76 | %Identity{provider: @restream, provider_meta: %{"user" => info}}
77 | |> cast(params, [
78 | :provider_token,
79 | :provider_refresh_token,
80 | :provider_email,
81 | :provider_login,
82 | :provider_name,
83 | :provider_id,
84 | :user_id
85 | ])
86 | |> validate_required([
87 | :provider_token,
88 | :provider_refresh_token,
89 | :provider_email,
90 | :provider_name,
91 | :provider_id,
92 | :user_id
93 | ])
94 | |> validate_length(:provider_meta, max: 10_000)
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/lib/algora/ads/ad.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.Ad do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "ads" do
6 | field :slug, :string
7 | field :name, :string
8 | field :status, Ecto.Enum, values: [:inactive, :active]
9 | field :verified, :boolean, default: false
10 | field :website_url, :string
11 | field :composite_asset_urls, {:array, :string}
12 | field :asset_url, :string
13 | field :logo_url, :string
14 | field :qrcode_url, :string
15 | field :og_image_url, :string
16 | field :start_date, :naive_datetime
17 | field :end_date, :naive_datetime
18 | field :total_budget, :integer
19 | field :daily_budget, :integer
20 | field :tech_stack, {:array, :string}
21 | field :user_id, :id
22 | field :border_color, :string
23 | field :scheduled_for, :utc_datetime, virtual: true
24 |
25 | timestamps()
26 | end
27 |
28 | @doc false
29 | def changeset(ad, attrs) do
30 | ad
31 | |> cast(attrs, [
32 | :slug,
33 | :name,
34 | :verified,
35 | :website_url,
36 | :composite_asset_urls,
37 | :asset_url,
38 | :logo_url,
39 | :qrcode_url,
40 | :og_image_url,
41 | :start_date,
42 | :end_date,
43 | :total_budget,
44 | :daily_budget,
45 | :tech_stack,
46 | :status,
47 | :border_color
48 | ])
49 | |> validate_required([
50 | :slug,
51 | :website_url,
52 | :border_color
53 | ])
54 | |> validate_format(:border_color, ~r/^#([0-9A-F]{3}){1,2}$/i,
55 | message: "must be a valid hex color code"
56 | )
57 | |> unique_constraint(:slug)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/algora/ads/appearance.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.Appearance do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias Algora.Library.Video
5 | alias Algora.Ads.Ad
6 |
7 | schema "ad_appearances" do
8 | field :airtime, :integer
9 |
10 | belongs_to :ad, Ad
11 | belongs_to :video, Video
12 |
13 | timestamps()
14 | end
15 |
16 | def changeset(appearance, attrs) do
17 | appearance
18 | |> cast(attrs, [:airtime, :ad_id, :video_id])
19 | |> validate_required([:airtime, :ad_id, :video_id])
20 | |> foreign_key_constraint(:ad_id)
21 | |> foreign_key_constraint(:video_id)
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/algora/ads/content_metrics.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.ContentMetrics do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias Algora.Library.Video
5 |
6 | schema "content_metrics" do
7 | field :algora_stream_url, :string
8 | field :twitch_stream_url, :string
9 | field :youtube_video_url, :string
10 | field :twitter_video_url, :string
11 | field :twitch_avg_concurrent_viewers, :integer
12 | field :twitch_views, :integer
13 | field :youtube_views, :integer
14 | field :twitter_views, :integer
15 |
16 | belongs_to :video, Video
17 |
18 | timestamps()
19 | end
20 |
21 | def changeset(content_metrics, attrs) do
22 | content_metrics
23 | |> cast(attrs, [
24 | :algora_stream_url,
25 | :twitch_stream_url,
26 | :youtube_video_url,
27 | :twitter_video_url,
28 | :twitch_avg_concurrent_viewers,
29 | :twitch_views,
30 | :youtube_views,
31 | :twitter_views,
32 | :video_id
33 | ])
34 | |> validate_required([:video_id])
35 | |> foreign_key_constraint(:video_id)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/algora/ads/events.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.Events do
2 | defmodule AdCreated do
3 | defstruct ad: nil
4 | end
5 |
6 | defmodule AdUpdated do
7 | defstruct ad: nil
8 | end
9 |
10 | defmodule AdDeleted do
11 | defstruct ad: nil
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/algora/ads/impression.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.Impression do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "ad_impressions" do
6 | field :duration, :integer
7 | field :viewers_count, :integer
8 | field :ad_id, :id
9 | field :video_id, :id
10 |
11 | timestamps()
12 | end
13 |
14 | @doc false
15 | def changeset(impression, attrs) do
16 | impression
17 | |> cast(attrs, [:duration, :viewers_count, :ad_id, :video_id])
18 | |> validate_required([:duration, :viewers_count, :ad_id])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/algora/ads/product_review.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.ProductReview do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias Algora.Library.Video
5 | alias Algora.Ads.Ad
6 |
7 | schema "product_reviews" do
8 | field :clip_from, :integer
9 | field :clip_to, :integer
10 | field :thumbnail_url, :string
11 |
12 | belongs_to :ad, Ad
13 | belongs_to :video, Video
14 |
15 | timestamps()
16 | end
17 |
18 | def changeset(product_review, attrs) do
19 | product_review
20 | |> cast(attrs, [:clip_from, :clip_to, :thumbnail_url, :ad_id, :video_id])
21 | |> validate_required([:clip_from, :clip_to, :ad_id, :video_id])
22 | |> foreign_key_constraint(:ad_id)
23 | |> foreign_key_constraint(:video_id)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/algora/ads/visit.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Ads.Visit do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "ad_visits" do
6 | field :ad_id, :id
7 | field :video_id, :id
8 |
9 | timestamps()
10 | end
11 |
12 | @doc false
13 | def changeset(visit, attrs) do
14 | visit
15 | |> cast(attrs, [:ad_id, :video_id])
16 | |> validate_required([:ad_id])
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/algora/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | topologies = Application.get_env(:libcluster, :topologies) || []
11 |
12 | tcp_server_options = %{
13 | port: Algora.config([:rtmp_port]),
14 | listen_options: [
15 | :binary,
16 | packet: :raw,
17 | active: false,
18 | ip: {0, 0, 0, 0}
19 | ],
20 | handle_new_client: &Algora.Pipeline.Manager.handle_new_client/3
21 | }
22 |
23 | :ok = :syn.add_node_to_scopes([:pipelines])
24 |
25 | children = [
26 | Algora.Env,
27 | {Cluster.Supervisor, [topologies, [name: Algora.ClusterSupervisor]]},
28 | {Task.Supervisor, name: Algora.TaskSupervisor},
29 | # Start the supervisor for tracking manifest uploads
30 | {DynamicSupervisor, strategy: :one_for_one, name: Algora.Pipeline.Storage.ManifestSupervisor},
31 | # Start the supervisor for rtmp to hls pipeline
32 | {DynamicSupervisor, strategy: :one_for_one, name: Algora.Pipeline.Supervisor},
33 | # Start the RPC server
34 | {Fly.RPC, []},
35 | # Start the Ecto repository
36 | Algora.Repo.Local,
37 | # Start the supervisor for LSN tracking
38 | {Fly.Postgres.LSN.Supervisor, repo: Algora.Repo.Local},
39 | # Start the Oban system
40 | {Oban, Application.fetch_env!(:algora, Oban)},
41 | # Start the Telemetry supervisor
42 | AlgoraWeb.Telemetry,
43 | # Pipeline flame pool
44 | if Algora.config([:flame, :backend]) != FLAME.LocalBackend do
45 | {FLAME.Pool,
46 | name: Algora.Pipeline.Pool,
47 | backend: Algora.config([:flame_backend]),
48 | min: Algora.config([:flame, :min]),
49 | max: Algora.config([:flame, :max]),
50 | max_concurrency: Algora.config([:flame, :max_concurrency]),
51 | idle_shutdown_after: Algora.config([:flame, :idle_shutdown_after]),
52 | log: Algora.config([:flame, :log]),
53 | }
54 | end,
55 | # Start the PubSub system
56 | {Phoenix.PubSub, name: Algora.PubSub},
57 | # Start presence
58 | AlgoraWeb.Presence,
59 | {Finch, name: Algora.Finch},
60 | # Clustering setup
61 | {DNSCluster, query: Application.get_env(:algora, :dns_cluster_query) || :ignore},
62 | # Start the Endpoints (http/https)
63 | AlgoraWeb.Endpoint,
64 | AlgoraWeb.Embed.Endpoint,
65 | # Start the LL-HLS controller registry
66 | {Registry, keys: :unique, name: Algora.LLControllerRegistry},
67 | # Start the RTMP server
68 | %{
69 | id: Membrane.RTMPServer,
70 | start: {Membrane.RTMPServer, :start_link, [tcp_server_options]}
71 | },
72 | Algora.Stargazer,
73 | Algora.Terminate,
74 | ExMarcel.TableWrapper,
75 | Algora.Youtube.Chat.Supervisor
76 | # Start a worker by calling: Algora.Worker.start_link(arg)
77 | # {Algora.Worker, arg}
78 | ] |> Enum.filter(& &1)
79 |
80 | :ets.new(:videos_to_tables, [:public, :set, :named_table])
81 | :ets.new(:videos_to_folder_paths, [:public, :set, :named_table])
82 |
83 | # See https://hexdocs.pm/elixir/Supervisor.html
84 | # for other strategies and supported options
85 | opts = [strategy: :one_for_one, name: Algora.Supervisor]
86 | Supervisor.start_link(children, opts)
87 | end
88 |
89 | # Tell Phoenix to update the endpoint configuration
90 | # whenever the application is updated.
91 | @impl true
92 | def config_change(changed, _new, removed) do
93 | AlgoraWeb.Endpoint.config_change(changed, removed)
94 | AlgoraWeb.Embed.Endpoint.config_change(changed, removed)
95 | :ok
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/lib/algora/cache.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Cache do
2 | def refetch(key, f) do
3 | result = f.()
4 | key |> path() |> write(result)
5 | result
6 | end
7 |
8 | def fetch(key, f) do
9 | case key |> path() |> read() do
10 | {:ok, result} -> result
11 | {:error, _} -> refetch(key, f)
12 | end
13 | end
14 |
15 | def path(key) do
16 | path = key |> String.split("/") |> Enum.map(&Slug.slugify/1)
17 |
18 | dir =
19 | case Algora.config([:mode]) do
20 | :prod -> "/data"
21 | _ -> :code.priv_dir(:algora)
22 | end
23 |
24 | Path.join([dir, "cache"] ++ path)
25 | end
26 |
27 | defp write(path, content) do
28 | File.mkdir_p!(Path.dirname(path))
29 | File.write(path, :erlang.term_to_binary(content))
30 | end
31 |
32 | defp read(path) do
33 | case File.read(path) do
34 | {:ok, binary} -> {:ok, :erlang.binary_to_term(binary)}
35 | {:error, error} -> {:error, error}
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/algora/chat/events.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Chat.Events do
2 | defmodule MessageSent do
3 | defstruct message: nil
4 | end
5 |
6 | defmodule MessageDeleted do
7 | defstruct message: nil
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/algora/chat/message.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Chat.Message do
2 | use Ecto.Schema
3 | alias Algora.Accounts.{User, Entity}
4 | alias Algora.Library.Video
5 | import Ecto.Changeset
6 |
7 | @derive {Jason.Encoder,
8 | only: [
9 | :body,
10 | :platform,
11 | :sender_handle,
12 | :sender_name,
13 | :sender_avatar_url,
14 | :inserted_at
15 | ]}
16 | schema "messages" do
17 | field :body, :string
18 | field :platform_id, :string
19 | field :platform, :string, virtual: true
20 | field :sender_handle, :string, virtual: true
21 | field :sender_name, :string, virtual: true
22 | field :sender_avatar_url, :string, virtual: true
23 | field :channel_id, :integer, virtual: true
24 | belongs_to :entity, Entity
25 | belongs_to :user, User
26 | belongs_to :video, Video
27 |
28 | timestamps()
29 | end
30 |
31 | @doc false
32 | def changeset(message, attrs) do
33 | message
34 | |> cast(attrs, [:body, :platform_id])
35 | |> validate_required([:body])
36 | end
37 |
38 | def put_entity(%Ecto.Changeset{} = changeset, %Entity{} = entity) do
39 | put_assoc(changeset, :entity, entity)
40 | end
41 |
42 | def put_user(%Ecto.Changeset{} = changeset, %User{} = user) do
43 | put_assoc(changeset, :user, user)
44 | end
45 |
46 | def put_video(%Ecto.Changeset{} = changeset, %Video{} = video) do
47 | put_assoc(changeset, :video, video)
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/algora/contact.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Contact do
2 | @moduledoc """
3 | The Contact context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias Algora.Repo
8 |
9 | alias Algora.Contact.Info
10 |
11 | @doc """
12 | Returns the list of contact_info.
13 |
14 | ## Examples
15 |
16 | iex> list_contact_info()
17 | [%Info{}, ...]
18 |
19 | """
20 | def list_contact_info do
21 | Repo.all(Info)
22 | end
23 |
24 | @doc """
25 | Gets a single info.
26 |
27 | Raises `Ecto.NoResultsError` if the Info does not exist.
28 |
29 | ## Examples
30 |
31 | iex> get_info!(123)
32 | %Info{}
33 |
34 | iex> get_info!(456)
35 | ** (Ecto.NoResultsError)
36 |
37 | """
38 | def get_info!(id), do: Repo.get!(Info, id)
39 |
40 | @doc """
41 | Creates a info.
42 |
43 | ## Examples
44 |
45 | iex> create_info(%{field: value})
46 | {:ok, %Info{}}
47 |
48 | iex> create_info(%{field: bad_value})
49 | {:error, %Ecto.Changeset{}}
50 |
51 | """
52 | def create_info(attrs \\ %{}) do
53 | %Info{}
54 | |> Info.changeset(attrs)
55 | |> Repo.insert()
56 | end
57 |
58 | @doc """
59 | Updates a info.
60 |
61 | ## Examples
62 |
63 | iex> update_info(info, %{field: new_value})
64 | {:ok, %Info{}}
65 |
66 | iex> update_info(info, %{field: bad_value})
67 | {:error, %Ecto.Changeset{}}
68 |
69 | """
70 | def update_info(%Info{} = info, attrs) do
71 | info
72 | |> Info.changeset(attrs)
73 | |> Repo.update()
74 | end
75 |
76 | @doc """
77 | Deletes a info.
78 |
79 | ## Examples
80 |
81 | iex> delete_info(info)
82 | {:ok, %Info{}}
83 |
84 | iex> delete_info(info)
85 | {:error, %Ecto.Changeset{}}
86 |
87 | """
88 | def delete_info(%Info{} = info) do
89 | Repo.delete(info)
90 | end
91 |
92 | @doc """
93 | Returns an `%Ecto.Changeset{}` for tracking info changes.
94 |
95 | ## Examples
96 |
97 | iex> change_info(info)
98 | %Ecto.Changeset{data: %Info{}}
99 |
100 | """
101 | def change_info(%Info{} = info, attrs \\ %{}) do
102 | Info.changeset(info, attrs)
103 | end
104 | end
105 |
--------------------------------------------------------------------------------
/lib/algora/contact/info.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Contact.Info do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "contact_info" do
6 | field :email, :string
7 | field :website_url, :string
8 | field :revenue, :string
9 | field :company_location, :string
10 |
11 | timestamps()
12 | end
13 |
14 | @doc false
15 | def changeset(info, attrs) do
16 | info
17 | |> cast(attrs, [:email, :website_url, :revenue, :company_location])
18 | |> validate_required([:email])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/algora/env.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Env do
2 | require Logger
3 | use GenServer
4 |
5 | def start_link(state) do
6 | GenServer.start_link(__MODULE__, Map.merge(%{transcode?: false}, Map.new(state)),
7 | name: __MODULE__
8 | )
9 | end
10 |
11 | @impl true
12 | def init(state) do
13 | {:ok, state}
14 | end
15 |
16 | @impl true
17 | def handle_call({:update, values}, _from, state) do
18 | {:reply, :ok, state |> Map.merge(Map.new(values))}
19 | end
20 |
21 | @impl true
22 | def handle_call(:list, _from, state) do
23 | {:reply, state, state}
24 | end
25 |
26 | def handle_call({:get, key}, _from, state) do
27 | {:reply, state |> Map.get(key), state}
28 | end
29 |
30 | def list() do
31 | GenServer.call(__MODULE__, :list)
32 | end
33 |
34 | def update(values) do
35 | GenServer.call(__MODULE__, {:update, values})
36 | end
37 |
38 | def get(key) do
39 | GenServer.call(__MODULE__, {:get, key})
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/algora/events/event.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Events.Event do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | schema "events" do
6 | field :actor_id, :string
7 | field :user_id, :integer
8 | field :video_id, :integer
9 | field :channel_id, :integer
10 | field :show_id, :integer
11 | field :user_handle, :string, virtual: true
12 | field :user_display_name, :string, virtual: true
13 | field :user_email, :string, virtual: true
14 | field :user_avatar_url, :string, virtual: true
15 | field :user_github_handle, :string, virtual: true
16 | field :user_meta, :string, virtual: true
17 | field :first_video_id, :integer, virtual: true
18 | field :first_video_title, :string, virtual: true
19 | field :name, Ecto.Enum, values: [:subscribed, :unsubscribed, :watched, :rsvpd, :unrsvpd]
20 |
21 | timestamps()
22 | end
23 |
24 | def changeset(event, attrs) do
25 | event
26 | |> cast(attrs, [:actor_id, :user_id, :video_id, :channel_id, :name])
27 | |> validate_required([:actor_id, :name])
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/algora/google.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Google do
2 | @moduledoc """
3 | This module contains Google-related functions.
4 | For now, it only contains the function to referesh the user token for the YouTube integration
5 | Perhaps, as time goes on, it'll contain more.
6 | """
7 |
8 | alias GoogleApi.YouTube.V3, as: YouTube
9 | alias Algora.Accounts
10 | alias Algora.Accounts.User
11 |
12 | def refresh_access_token(refresh_token) do
13 | body =
14 | URI.encode_query(%{
15 | client_id: client_id(),
16 | client_secret: client_secret(),
17 | refresh_token: refresh_token,
18 | grant_type: "refresh_token"
19 | })
20 |
21 | headers = [
22 | {"Content-Type", "application/x-www-form-urlencoded"}
23 | ]
24 |
25 | res = HTTPoison.post("https://oauth2.googleapis.com/token", body, headers)
26 |
27 | with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- res,
28 | %{"access_token" => token} = decoded_body <- Jason.decode!(body) do
29 | new_refresh_token = Map.get(decoded_body, "refresh_token", refresh_token)
30 | {:ok, %{token: token, refresh_token: new_refresh_token}}
31 | else
32 | {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason}
33 | %{} = res -> {:error, {:bad_response, res}}
34 | end
35 | end
36 |
37 | def upload_video(user = %User{}, path, %{
38 | title: title,
39 | description: description,
40 | privacy_status: privacy_status
41 | }) do
42 | conn = Accounts.get_google_token(user) |> YouTube.Connection.new()
43 |
44 | YouTube.Api.Videos.youtube_videos_insert_simple(
45 | conn,
46 | ["snippet", "status"],
47 | "multipart",
48 | %YouTube.Model.Video{
49 | snippet: %YouTube.Model.VideoSnippet{
50 | title: title,
51 | description: description
52 | },
53 | status: %YouTube.Model.VideoStatus{
54 | privacyStatus: privacy_status
55 | }
56 | },
57 | path
58 | )
59 | end
60 |
61 | defp client_id,
62 | do: Application.fetch_env!(:ueberauth, Ueberauth.Strategy.Google.OAuth)[:client_id]
63 |
64 | defp client_secret,
65 | do: Application.fetch_env!(:ueberauth, Ueberauth.Strategy.Google.OAuth)[:client_secret]
66 | end
67 |
--------------------------------------------------------------------------------
/lib/algora/library/channel.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Library.Channel do
2 | defstruct user_id: nil,
3 | handle: nil,
4 | name: nil,
5 | tagline: nil,
6 | avatar_url: nil,
7 | external_homepage_url: nil,
8 | twitter_url: nil,
9 | is_live: nil,
10 | bounties_count: nil,
11 | orgs_contributed: nil,
12 | tech: nil,
13 | solving_challenge: nil,
14 | tags: []
15 | end
16 |
--------------------------------------------------------------------------------
/lib/algora/library/events.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Library.Events do
2 | defmodule LivestreamStarted do
3 | defstruct video: nil, resume: false
4 | end
5 |
6 | defmodule LivestreamEnded do
7 | defstruct video: nil, resume: false
8 | end
9 |
10 | defmodule ThumbnailsGenerated do
11 | defstruct video: nil
12 | end
13 |
14 | defmodule ProcessingQueued do
15 | defstruct video: nil
16 | end
17 |
18 | defmodule ProcessingProgressed do
19 | defstruct video: nil, stage: nil, pct: nil
20 | end
21 |
22 | defmodule ProcessingCompleted do
23 | defstruct video: nil, action: nil, url: nil
24 | end
25 |
26 | defmodule ProcessingFailed do
27 | defstruct video: nil, attempt: nil, max_attempts: nil
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/algora/library/segment.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Library.Segment do
2 | alias Algora.Library
3 | alias Algora.Library.{Segment, Subtitle}
4 | use Ecto.Schema
5 | import Ecto.Changeset
6 |
7 | schema "segments" do
8 | field :start, :float
9 | field :end, :float
10 | field :body, :string
11 | field :embedding, {:array, :float}
12 | belongs_to :video, Library.Video
13 | belongs_to :starting_subtitle, Library.Subtitle
14 | belongs_to :ending_subtitle, Library.Subtitle
15 |
16 | timestamps()
17 | end
18 |
19 | @doc false
20 | def changeset(segment, attrs) do
21 | segment
22 | |> cast(attrs, [:body, :start, :end])
23 | |> validate_required([:body, :start, :end])
24 | end
25 |
26 | def init([]), do: nil
27 |
28 | def init(subtitles) do
29 | body = subtitles |> Enum.map_join("", fn %Subtitle{body: body} -> body end)
30 | starting_subtitle = subtitles |> Enum.at(0)
31 | ending_subtitle = subtitles |> Enum.at(-1)
32 |
33 | %Segment{
34 | body: body,
35 | start: starting_subtitle.start,
36 | end: ending_subtitle.end,
37 | video_id: starting_subtitle.video_id,
38 | starting_subtitle_id: starting_subtitle.id,
39 | ending_subtitle_id: ending_subtitle.id
40 | }
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/algora/library/subtitle.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Library.Subtitle do
2 | alias Algora.Library
3 | use Ecto.Schema
4 | import Ecto.Changeset
5 |
6 | schema "subtitles" do
7 | field :start, :float
8 | field :end, :float
9 | field :body, :string
10 | belongs_to :video, Library.Video
11 |
12 | timestamps()
13 | end
14 |
15 | @doc false
16 | def changeset(subtitle, attrs) do
17 | subtitle
18 | |> cast(attrs, [:body, :start, :end])
19 | |> validate_required([:body, :start, :end])
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/algora/library/video_thumbnail.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Library.VideoThumbnail do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Algora.Library.Video
6 |
7 | schema "video_thumbnails" do
8 | field :minutes, :integer
9 | field :thumbnail_url, :string
10 |
11 | belongs_to :video, Video
12 |
13 | timestamps()
14 | end
15 |
16 | def put_video(%Ecto.Changeset{} = changeset, %Video{} = video) do
17 | put_assoc(changeset, :video, video)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/algora/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Mailer do
2 | use Swoosh.Mailer, otp_app: :algora
3 | end
4 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/README.md:
--------------------------------------------------------------------------------
1 | # Algora Media Processing Pipeline
2 |
3 | The code in this directory is built upon the work from the [Membrane Framework](https://github.com/membraneframework). Their efforts provided a robust starting point, and we have made modifications and additions to better suit our project’s objectives.
4 |
5 | We would like to explicitly acknowledge and thank the authors and maintainers of the Membrane Framework for making their work available to the community under an open source license. They have laid the groundwork that enabled us to build and innovate further.
6 |
7 | ## License
8 |
9 | This subdirectory, as with the rest of the codebase, is licensed under the terms of the [AGPLv3 License](https://github.com/algora-io/tv/blob/main/LICENSE). The original codebase and plugins can be found [here](https://github.com/membraneframework/membrane_core), which are licensed under the [Apache License 2.0](https://github.com/membraneframework/membrane_core/blob/master/LICENSE).
10 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/client_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.ClientHandler do
2 | @moduledoc """
3 | An implementation of `Membrane.RTMPServer.ClienHandlerBehaviour` compatible with the
4 | `Membrane.RTMP.Source` element.
5 | """
6 |
7 | @behaviour Membrane.RTMPServer.ClientHandler
8 |
9 | defstruct []
10 |
11 | @impl true
12 | def handle_init(%{pipeline: pid}) do
13 | %{
14 | source_pid: nil,
15 | buffered: [],
16 | pipeline: pid
17 | }
18 | end
19 |
20 | @impl true
21 | def handle_info({:send_me_data, source_pid}, state) do
22 | buffers_to_send = Enum.reverse(state.buffered)
23 | state = %{state | source_pid: source_pid, buffered: []}
24 | Enum.each(buffers_to_send, fn buffer -> send_data(state.source_pid, buffer) end)
25 | state
26 | end
27 |
28 | @impl true
29 | def handle_info(_other, state) do
30 | state
31 | end
32 |
33 | @impl true
34 | def handle_data_available(payload, state) do
35 | if state.source_pid do
36 | :ok = send_data(state.source_pid, payload)
37 | state
38 | else
39 | %{state | buffered: [payload | state.buffered]}
40 | end
41 | end
42 |
43 | @impl true
44 | def handle_connection_closed(state) do
45 | if state.source_pid != nil, do: send(state.source_pid, :connection_closed)
46 | state
47 | end
48 |
49 | @impl true
50 | def handle_delete_stream(state) do
51 | if state.source_pid != nil, do: send(state.source_pid, :delete_stream)
52 | send(state.pipeline, :delete_stream)
53 | state
54 | end
55 |
56 | @impl true
57 | def handle_metadata(message, state) do
58 | send(state.pipeline, message)
59 | state
60 | end
61 |
62 | defp send_data(pid, payload) do
63 | send(pid, {:data, payload})
64 | :ok
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/funnel.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.Funnel do
2 | @moduledoc """
3 | Element that can be used for collecting data from multiple inputs and sending it through one
4 | output. When a new input connects in the `:playing` state, the funnel sends
5 | `Membrane.Funnel.NewInputEvent` via output.
6 | """
7 | use Membrane.Filter
8 |
9 | alias Membrane.Funnel
10 |
11 | def_input_pad :input, accepted_format: _any, flow_control: :auto, availability: :on_request
12 | def_output_pad :output, accepted_format: _any, flow_control: :auto
13 |
14 | def_options end_of_stream: [spec: :on_last_pad | :on_first_pad | :never | :notify, default: :on_last_pad]
15 |
16 | @impl true
17 | def handle_init(_ctx, opts) do
18 | {[], %{end_of_stream: opts.end_of_stream}}
19 | end
20 |
21 | @impl true
22 | def handle_buffer(Pad.ref(:input, _id), buffer, _ctx, state) do
23 | {[buffer: {:output, buffer}], state}
24 | end
25 |
26 | @impl true
27 | def handle_pad_added(Pad.ref(:input, _id), %{playback_state: :playing}, state) do
28 | {[event: {:output, %Funnel.NewInputEvent{}}], state}
29 | end
30 |
31 | @impl true
32 | def handle_pad_added(Pad.ref(:input, _id), _ctx, state) do
33 | {[], state}
34 | end
35 |
36 | @impl true
37 | def handle_end_of_stream(Pad.ref(:input, _id), _ctx, %{end_of_stream: :notify} = state) do
38 | {[notify_parent: :end_of_stream], state}
39 | end
40 |
41 | @impl true
42 | def handle_end_of_stream(Pad.ref(:input, _id), _ctx, %{end_of_stream: :never} = state) do
43 | {[], state}
44 | end
45 |
46 | @impl true
47 | def handle_end_of_stream(Pad.ref(:input, _id), ctx, %{end_of_stream: :on_first_pad} = state) do
48 | if ctx.pads.output.end_of_stream? do
49 | {[], state}
50 | else
51 | {[end_of_stream: :output], state}
52 | end
53 | end
54 |
55 | @impl true
56 | def handle_end_of_stream(Pad.ref(:input, _id), ctx, %{end_of_stream: :on_last_pad} = state) do
57 | if ctx |> inputs_data() |> Enum.all?(& &1.end_of_stream?) do
58 | {[end_of_stream: :output], state}
59 | else
60 | {[], state}
61 | end
62 | end
63 |
64 | defp inputs_data(ctx) do
65 | Enum.flat_map(ctx.pads, fn
66 | {Pad.ref(:input, _id), data} -> [data]
67 | _output -> []
68 | end)
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/manager.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.Manager do
2 | use GenServer
3 |
4 | @app "live"
5 |
6 | def handle_new_client(_client_ref, "", ""), do:
7 | {__MODULE__.Abort, "Invalid stream key and app"}
8 |
9 | def handle_new_client(client_ref, stream_key, ""), do:
10 | handle_new_client(client_ref, @app, stream_key)
11 |
12 | def handle_new_client(client_ref, @app, stream_key) do
13 | params = %{
14 | client_ref: client_ref,
15 | app: @app,
16 | stream_key: stream_key,
17 | video_uuid: nil
18 | }
19 |
20 | {:ok, pid} =
21 | with true <- Algora.config([:resume_rtmp]),
22 | {pid, metadata} when is_pid(pid) <- :syn.lookup(:pipelines, stream_key) do
23 | :ok = __MODULE__.resume_rtmp(pid, %{ params | video_uuid: metadata[:video_uuid] })
24 | {:ok, pid}
25 | else
26 | _ ->
27 | if Algora.config([:flame, :backend]) == FLAME.LocalBackend do
28 | Algora.Pipeline.Supervisor.start_child([self(), params])
29 | else
30 | FLAME.place_child(Algora.Pipeline.Pool, {__MODULE__, [self(), params]})
31 | end
32 | end
33 |
34 | {Algora.Pipeline.ClientHandler, %{pipeline: pid}}
35 | end
36 |
37 | def resume_rtmp(pipeline, params) when is_pid(pipeline) do
38 | GenServer.call(pipeline, {:resume_rtmp, params})
39 | end
40 |
41 | def start_link([pid, initial]) do
42 | GenServer.start_link(__MODULE__, [pid, initial])
43 | end
44 |
45 | def init([parent_pid, params]) do
46 | Process.flag(:trap_exit, true)
47 | {:ok, _sup, pid} = Membrane.Pipeline.start_link(Algora.Pipeline, params)
48 | send(parent_pid, {:started, pid})
49 | {:ok, %{ pid: pid, params: params }}
50 | end
51 |
52 | def handle_info({:EXIT, _pid, reason}, state) do
53 | {:stop, reason, state}
54 | end
55 |
56 | def handle_info(message, state) do
57 | send(state.pid, message)
58 | {:noreply, state}
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/storage/manifest.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.Storage.Manifest do
2 | use GenServer, restart: :transient
3 |
4 | require Membrane.Logger
5 |
6 | @delta_suffix_regex ~r/_delta.m3u8$/
7 | @delay Algora.Pipeline.segment_duration() * 1000
8 |
9 | def start_link([video]) do
10 | GenServer.start_link(__MODULE__, video)
11 | end
12 |
13 | @impl true
14 | def init(video) do
15 | Process.flag(:trap_exit, true)
16 | {:ok, %{video: video, manifests: %{}}}
17 | end
18 |
19 | @impl true
20 | def handle_cast({:upload, name, contents, upload_opts}, %{manifests: manifests} = state) do
21 | if String.match?(name, @delta_suffix_regex) do
22 | {:noreply, state}
23 | else
24 | with {timer_ref, _upload} <- manifests[name] do
25 | {:ok, :cancel} = :timer.cancel(timer_ref)
26 | end
27 |
28 | {:ok, timer_ref} = :timer.send_after(@delay, {
29 | :upload_immediate, name,
30 | })
31 |
32 | manifests = Map.put(state.manifests, name, {timer_ref, {contents, upload_opts}})
33 | {:noreply, %{state | manifests: manifests }}
34 | end
35 | end
36 |
37 | @impl true
38 | def handle_info({:upload_immediate, name}, state) do
39 | state = with {_timer, {contents, upload_opts}} <- state.manifests[name] do
40 | {:ok, state} = upload!(name, contents, upload_opts, state)
41 | state
42 | else
43 | _ -> state
44 | end
45 |
46 | {:noreply, state}
47 | end
48 |
49 | def handle_info({:EXIT, _pid, reason}, state) do
50 | {:stop, reason, state}
51 | end
52 |
53 | @impl true
54 | def terminate(reason, state) do
55 | Membrane.Logger.info("#{__MODULE__} terminating because of #{inspect(reason)}")
56 | Enum.all?(state.manifests, fn({name, {_timer, {contents, upload_opts}}}) ->
57 | {:ok, _state} = upload!(name, contents, upload_opts, state)
58 | true
59 | end) && :ok
60 | end
61 |
62 | defp upload!(name, contents, upload_opts, state) do
63 | path = "#{state.video.uuid}/#{name}"
64 | manifests = with {:ok, _} <- Algora.Storage.upload(contents, path, upload_opts) do
65 | Membrane.Logger.info("Uploaded manifest #{path}")
66 | Map.delete(state.manifests, name)
67 | else
68 | err ->
69 | Membrane.Logger.error("Failed to upload #{path}: #{inspect(err)}")
70 | state.manifests
71 | end
72 |
73 |
74 | {:ok, %{ state | manifests: manifests }}
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/storage/manifest_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.Storage.ManifestSupervisor do
2 | use DynamicSupervisor
3 |
4 | def start_link(init_arg) do
5 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
6 | end
7 |
8 | def start_child(video) do
9 | spec = %{start: {Algora.Pipeline.Storage.Manifest, :start_link, [video]}}
10 | DynamicSupervisor.start_child(__MODULE__, spec)
11 | end
12 |
13 | @impl true
14 | def init(init_arg) do
15 | DynamicSupervisor.init(strategy: :one_for_one, extra_arguments: [init_arg])
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/storage/thumbnails.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.Storage.Thumbnails do
2 | @moduledoc false
3 |
4 | require Membrane.Logger
5 | alias Algora.Library
6 |
7 | @thumbnail_markers [
8 | %{minutes: 0, segment_sn: 0},
9 | %{minutes: 1, segment_sn: 6},
10 | %{minutes: 2, segment_sn: 14},
11 | %{minutes: 4, segment_sn: 28},
12 | %{minutes: 8, segment_sn: 57},
13 | %{minutes: 16, segment_sn: 120}
14 | ]
15 |
16 | @pubsub Algora.PubSub
17 |
18 | def store_thumbnail(video, video_header, contents, marker) do
19 | with {:ok, video_thumbnail} <- Library.store_thumbnail(video, video_header <> contents, marker),
20 | {:ok, video} <- Library.store_og_image(video, marker) do
21 | if (is_first_marker?(marker)) do
22 | Library.update_thumbnail_url(video, video_thumbnail)
23 | end
24 | broadcast_thumbnails_generated!(video)
25 | else
26 | _ ->
27 | Membrane.Logger.error("Could not generate thumbnails for video #{video.id}")
28 | end
29 | end
30 |
31 | def find_marker(segment_sn) do
32 | Enum.find(@thumbnail_markers, fn marker ->
33 | marker.segment_sn == segment_sn
34 | end)
35 | end
36 |
37 | def is_first_marker?(marker) do
38 | List.first(@thumbnail_markers) == marker
39 | end
40 |
41 | def is_last_marker?(marker) do
42 | List.last(@thumbnail_markers) == marker
43 | end
44 |
45 | defp broadcast_thumbnails_generated!(video) do
46 | # HACK: this shouldn't be necessary
47 | # atm we need it because initially the video does not have the user field set
48 | video = Library.get_video!(video.id)
49 |
50 | Phoenix.PubSub.broadcast!(
51 | @pubsub,
52 | Library.topic_livestreams(),
53 | {__MODULE__, %Library.Events.ThumbnailsGenerated{video: video}}
54 | )
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/algora/pipeline/supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Pipeline.Supervisor do
2 | use DynamicSupervisor
3 |
4 | def resume_rtmp(pipeline, params) when is_pid(pipeline) do
5 | GenServer.call(pipeline, {:resume_rtmp, params})
6 | end
7 |
8 | def start_link(init_arg) do
9 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
10 | end
11 |
12 | def start_child(init_arg) do
13 | spec = Supervisor.child_spec({Algora.Pipeline.Manager, init_arg}, restart: :transient)
14 | DynamicSupervisor.start_child(__MODULE__, spec)
15 | end
16 |
17 | @impl true
18 | def init(init_arg) do
19 | DynamicSupervisor.init(strategy: :simple_one_for_one, extra_arguments: [init_arg])
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/algora/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | @app :algora
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/algora/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local do
2 | use Ecto.Repo,
3 | otp_app: :algora,
4 | adapter: Ecto.Adapters.Postgres
5 |
6 | @env Mix.env()
7 |
8 | # Dynamically configure the database url based on runtime and build
9 | # environments.
10 | def init(_type, config) do
11 | # url = Fly.Postgres.rewrite_database_url!(config)
12 | # dbg(url)
13 |
14 | Fly.Postgres.config_repo_url(config, @env)
15 | end
16 | end
17 |
18 | defmodule Algora.Repo do
19 | use Fly.Repo, local_repo: Algora.Repo.Local
20 | end
21 |
--------------------------------------------------------------------------------
/lib/algora/restream.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Restream do
2 | def authorize_url(state) do
3 | query =
4 | URI.encode_query(
5 | client_id: client_id(),
6 | state: state,
7 | response_type: "code",
8 | redirect_uri: redirect_uri()
9 | )
10 |
11 | "https://api.restream.io/login?#{query}"
12 | end
13 |
14 | def websocket_url(token), do: "wss://chat.api.restream.io/ws?accessToken=#{token}"
15 |
16 | def exchange_access_token(opts) do
17 | code = Keyword.fetch!(opts, :code)
18 | state = Keyword.fetch!(opts, :state)
19 |
20 | state
21 | |> fetch_exchange_response(code)
22 | |> fetch_user_info()
23 | end
24 |
25 | defp fetch_exchange_response(_state, code) do
26 | body =
27 | URI.encode_query(%{
28 | grant_type: "authorization_code",
29 | redirect_uri: redirect_uri(),
30 | code: code
31 | })
32 |
33 | headers = [
34 | {"Content-Type", "application/x-www-form-urlencoded"},
35 | {"Authorization", "Basic " <> Base.encode64("#{client_id()}:#{secret()}")}
36 | ]
37 |
38 | resp = HTTPoison.post("https://api.restream.io/oauth/token", body, headers)
39 |
40 | with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- resp,
41 | %{"access_token" => token, "refresh_token" => refresh_token} <- Jason.decode!(body) do
42 | {:ok, %{token: token, refresh_token: refresh_token}}
43 | else
44 | {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason}
45 | %{} = resp -> {:error, {:bad_response, resp}}
46 | end
47 | end
48 |
49 | defp fetch_user_info({:error, _reason} = error), do: error
50 |
51 | defp fetch_user_info({:ok, %{token: token} = tokens}) do
52 | headers = [{"Authorization", "Bearer #{token}"}]
53 |
54 | case HTTPoison.get("https://api.restream.io/v2/user/profile", headers) do
55 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
56 | {:ok, %{info: Jason.decode!(body), tokens: tokens}}
57 |
58 | {:ok, %HTTPoison.Response{status_code: status_code, body: body}} ->
59 | {:error, {status_code, body}}
60 |
61 | {:error, %HTTPoison.Error{reason: reason}} ->
62 | {:error, reason}
63 | end
64 | end
65 |
66 | def refresh_access_token(refresh_token) do
67 | body =
68 | URI.encode_query(%{
69 | grant_type: "refresh_token",
70 | refresh_token: refresh_token
71 | })
72 |
73 | headers = [
74 | {"Content-Type", "application/x-www-form-urlencoded"},
75 | {"Authorization", "Basic " <> Base.encode64("#{client_id()}:#{secret()}")}
76 | ]
77 |
78 | resp = HTTPoison.post("https://api.restream.io/oauth/token", body, headers)
79 |
80 | with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- resp,
81 | %{"access_token" => token, "refresh_token" => refresh_token} <- Jason.decode!(body) do
82 | {:ok, %{token: token, refresh_token: refresh_token}}
83 | else
84 | {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason}
85 | %{} = resp -> {:error, {:bad_response, resp}}
86 | end
87 | end
88 |
89 | defp client_id, do: Algora.config([:restream, :client_id])
90 | defp secret, do: Algora.config([:restream, :client_secret])
91 | defp redirect_uri, do: "#{AlgoraWeb.Endpoint.url()}/oauth/callbacks/restream"
92 | end
93 |
--------------------------------------------------------------------------------
/lib/algora/shows.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Shows do
2 | import Ecto.Query, warn: false
3 | alias Algora.Repo
4 |
5 | alias Algora.Shows.Show
6 | alias Algora.Accounts.User
7 |
8 | def list_shows() do
9 | from(s in Show)
10 | |> Repo.all()
11 | end
12 |
13 | def list_featured_shows(limit \\ 100) do
14 | from(s in Show,
15 | join: u in User,
16 | on: s.user_id == u.id,
17 | limit: ^limit,
18 | where: not is_nil(s.ordering),
19 | select_merge: %{
20 | channel_handle: u.handle,
21 | channel_name: coalesce(u.name, u.handle),
22 | channel_avatar_url: u.avatar_url,
23 | channel_twitter_url: u.twitter_url
24 | },
25 | order_by: [{:desc, s.ordering}, {:desc, s.id}]
26 | )
27 | |> Repo.all()
28 | end
29 |
30 | def get_show!(id), do: Repo.get!(Show, id)
31 |
32 | def get_show_by_fields!(fields), do: Repo.get_by!(Show, fields)
33 |
34 | def create_show(attrs \\ %{}) do
35 | %Show{}
36 | |> Show.changeset(attrs)
37 | |> Repo.insert()
38 | end
39 |
40 | def update_show(%Show{} = show, attrs) do
41 | show
42 | |> Show.changeset(attrs)
43 | |> Repo.update()
44 | end
45 |
46 | def delete_show(%Show{} = show) do
47 | Repo.delete(show)
48 | end
49 |
50 | def change_show(%Show{} = show, attrs \\ %{}) do
51 | Show.changeset(show, attrs)
52 | end
53 |
54 | def list_videos do
55 | Repo.all(Show)
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/algora/shows/show.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Shows.Show do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Algora.Accounts.User
6 |
7 | schema "shows" do
8 | field :title, :string
9 | field :description, :string
10 | field :slug, :string
11 | field :scheduled_for, :naive_datetime
12 | field :image_url, :string
13 | field :og_image_url, :string
14 | field :url, :string
15 | field :ordering, :integer
16 | field :channel_handle, :string, virtual: true
17 | field :channel_name, :string, virtual: true
18 | field :channel_avatar_url, :string, virtual: true
19 | field :channel_twitter_url, :string, virtual: true
20 |
21 | belongs_to :user, User
22 |
23 | timestamps()
24 | end
25 |
26 | @doc false
27 | def changeset(show, attrs) do
28 | show
29 | |> cast(attrs, [:title, :description, :slug, :scheduled_for, :image_url, :url])
30 | |> validate_required([:title, :slug])
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/algora/stargazer.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Stargazer do
2 | require Logger
3 | use GenServer
4 |
5 | @url "https://api.github.com/repos/algora-io/tv"
6 | @poll_interval :timer.minutes(10)
7 |
8 | def start_link(cmd) do
9 | GenServer.start_link(__MODULE__, cmd, name: __MODULE__)
10 | end
11 |
12 | @impl true
13 | def init(cmd) do
14 | {:ok, schedule_fetch(%{count: nil}, cmd, 0)}
15 | end
16 |
17 | @impl true
18 | def handle_info(cmd, state) do
19 | count = fetch_count() || state.count
20 | {:noreply, schedule_fetch(%{state | count: count}, cmd)}
21 | end
22 |
23 | defp schedule_fetch(state, cmd, after_ms \\ @poll_interval) do
24 | Process.send_after(self(), cmd, after_ms)
25 | state
26 | end
27 |
28 | defp fetch_count() do
29 | with {:ok, %Finch.Response{status: 200, body: body}} <-
30 | :get
31 | |> Finch.build(@url)
32 | |> Finch.request(Algora.Finch),
33 | {:ok, %{"stargazers_count" => count}} <- Jason.decode(body) do
34 | count
35 | else
36 | _ -> nil
37 | end
38 | end
39 |
40 | def count() do
41 | GenServer.call(__MODULE__, :get_count)
42 | end
43 |
44 | @impl true
45 | def handle_call(:get_count, _from, state) do
46 | {:reply, state.count, state}
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/algora/storage.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Storage do
2 | def endpoint_url do
3 | %{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{})
4 | "#{scheme}#{host}"
5 | end
6 |
7 | def bucket(), do: Algora.config([:buckets, :media])
8 |
9 | def to_absolute(type, uuid, uri) do
10 | if URI.parse(uri).scheme do
11 | uri
12 | else
13 | to_absolute_uri(type, uuid, uri)
14 | end
15 | end
16 |
17 | defp to_absolute_uri(:video, uuid, uri),
18 | do: "#{endpoint_url()}/#{bucket()}/#{uuid}/#{uri}"
19 |
20 | defp to_absolute_uri(:clip, uuid, uri),
21 | do: "#{endpoint_url()}/#{bucket()}/clips/#{uuid}/#{uri}"
22 |
23 | def upload_to_bucket(contents, remote_path, bucket, opts \\ []) do
24 | op = Algora.config([:buckets, bucket]) |> ExAws.S3.put_object(remote_path, contents, opts)
25 | ExAws.request(op, [])
26 | end
27 |
28 | def upload_from_filename_to_bucket(
29 | local_path,
30 | remote_path,
31 | bucket,
32 | cb \\ fn _ -> nil end,
33 | opts \\ []
34 | ) do
35 | %{size: size} = File.stat!(local_path)
36 |
37 | chunk_size = 5 * 1024 * 1024
38 |
39 | ExAws.S3.Upload.stream_file(local_path, [{:chunk_size, chunk_size}])
40 | |> Stream.map(fn chunk ->
41 | cb.(%{stage: :persisting, done: chunk_size, total: size})
42 | chunk
43 | end)
44 | |> ExAws.S3.upload(Algora.config([:buckets, bucket]), remote_path, opts)
45 | |> ExAws.request([])
46 | end
47 |
48 | def upload(contents, remote_path, opts \\ []) do
49 | upload_to_bucket(contents, remote_path, :media, opts)
50 | end
51 |
52 | def upload_from_filename(local_path, remote_path, cb \\ fn _ -> nil end, opts \\ []) do
53 | upload_from_filename_to_bucket(
54 | local_path,
55 | remote_path,
56 | :media,
57 | cb,
58 | opts
59 | )
60 | end
61 |
62 | def update_object!(bucket, object, opts) do
63 | bucket = Algora.config([:buckets, bucket])
64 |
65 | with {:ok, %{body: body}} <- ExAws.S3.get_object(bucket, object) |> ExAws.request(),
66 | {:ok, res} <- ExAws.S3.put_object(bucket, object, body, opts) |> ExAws.request() do
67 | res
68 | else
69 | err -> err
70 | end
71 | end
72 |
73 | def remove(remote_path, opts \\ []) do
74 | remove_from_bucket(remote_path, :media, opts)
75 | end
76 |
77 | def remove_from_bucket(remote_path, bucket, opts) do
78 | ExAws.S3.delete_object(Algora.config([:buckets, bucket]), remote_path, opts)
79 | |> ExAws.request([])
80 | end
81 |
82 | end
83 |
--------------------------------------------------------------------------------
/lib/algora/terminate.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Terminate do
2 | use GenServer
3 |
4 | @terminate_interval :timer.hours(1)
5 |
6 | def start_link(_) do
7 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
8 | end
9 |
10 | @impl true
11 | def init(state) do
12 | schedule_terminate()
13 | {:ok, state}
14 | end
15 |
16 | @impl true
17 | def handle_info(:terminate, state) do
18 | Algora.Library.terminate_interrupted_streams()
19 | schedule_terminate()
20 | {:noreply, state}
21 | end
22 |
23 | defp schedule_terminate() do
24 | Process.send_after(self(), :terminate, @terminate_interval)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/algora/util.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Util do
2 | @common_words [
3 | "a",
4 | "add",
5 | "again",
6 | "air",
7 | "also",
8 | "an",
9 | "and",
10 | "are",
11 | "as",
12 | "ask",
13 | "at",
14 | "be",
15 | "but",
16 | "by",
17 | "can",
18 | "do",
19 | "does",
20 | "each",
21 | "end",
22 | "even",
23 | "for",
24 | "from",
25 | "get",
26 | "got",
27 | "had",
28 | "have",
29 | "he",
30 | "here",
31 | "his",
32 | "how",
33 | "i",
34 | "if",
35 | "in",
36 | "is",
37 | "it",
38 | "kind",
39 | "men",
40 | "must",
41 | "my",
42 | "near",
43 | "need",
44 | "of",
45 | "off",
46 | "on",
47 | "one",
48 | "or",
49 | "other",
50 | "our",
51 | "out",
52 | "put",
53 | "said",
54 | "self",
55 | "set",
56 | "some",
57 | "such",
58 | "tell",
59 | "that",
60 | "the",
61 | "their",
62 | "they",
63 | "this",
64 | "to",
65 | "try",
66 | "us",
67 | "use",
68 | "want",
69 | "was",
70 | "we're",
71 | "we",
72 | "well",
73 | "went",
74 | "were",
75 | "what",
76 | "which",
77 | "why",
78 | "will",
79 | "with",
80 | "you're",
81 | "you",
82 | "your"
83 | ]
84 |
85 | def common_word?(s), do: Enum.member?(@common_words, s)
86 |
87 | def random_string do
88 | binary = <<
89 | System.system_time(:nanosecond)::64,
90 | :erlang.phash2({node(), self()})::16,
91 | :erlang.unique_integer()::16
92 | >>
93 |
94 | binary
95 | |> Base.url_encode64()
96 | |> String.replace(["/", "+"], "-")
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/lib/algora/workers/hls_transmuxer.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Workers.HLSTransmuxer do
2 | use Oban.Worker, queue: :default, max_attempts: 3, unique: [period: 86_400]
3 |
4 | alias Algora.Library
5 | import Ecto.Query, warn: false
6 |
7 | require Logger
8 |
9 | @impl Oban.Worker
10 | def perform(%Oban.Job{args: %{"video_id" => video_id}} = job) do
11 | video = Library.get_video!(video_id)
12 | build_transmuxer(job, video)
13 | await_transmuxer(video)
14 | end
15 |
16 | defp build_transmuxer(job, %Library.Video{} = video) do
17 | job_pid = self()
18 |
19 | Task.async(fn ->
20 | try do
21 | hls_video =
22 | Library.transmux_to_hls(video, fn progress ->
23 | send(job_pid, {:progress, progress})
24 | end)
25 |
26 | send(job_pid, {:complete, hls_video})
27 | rescue
28 | e ->
29 | send(job_pid, {:error, e, job})
30 | reraise e, __STACKTRACE__
31 | end
32 | end)
33 | end
34 |
35 | defp await_transmuxer(video, stage \\ :retrieving, done \\ 0) do
36 | receive do
37 | {:progress, %{stage: stage_now, done: done_now, total: total}} ->
38 | Library.broadcast_processing_progressed!(stage, video, min(1, done / total))
39 | done_total = if(stage == stage_now, do: done, else: 0)
40 | await_transmuxer(video, stage_now, done_total + done_now)
41 |
42 | {:complete, video} ->
43 | Library.broadcast_processing_progressed!(stage, video, 1)
44 | Library.broadcast_processing_completed!(:upload, video, video.url)
45 | {:ok, video.url}
46 |
47 | {:error, e, %Oban.Job{attempt: attempt, max_attempts: max_attempts}} ->
48 | Library.broadcast_processing_failed!(video, attempt, max_attempts)
49 | {:error, e}
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/algora/workers/mp4_transmuxer.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Workers.MP4Transmuxer do
2 | use Oban.Worker, queue: :default, max_attempts: 3, unique: [period: 86_400]
3 |
4 | alias Algora.Library
5 | import Ecto.Query, warn: false
6 |
7 | require Logger
8 |
9 | @impl Oban.Worker
10 | def perform(%Oban.Job{args: %{"video_id" => video_id}} = job) do
11 | video = Library.get_video!(video_id)
12 | build_transmuxer(job, video)
13 | await_transmuxer(video)
14 | end
15 |
16 | defp build_transmuxer(job, %Library.Video{} = video) do
17 | job_pid = self()
18 |
19 | Task.async(fn ->
20 | try do
21 | mp4_video =
22 | Library.transmux_to_mp4(video, fn progress ->
23 | send(job_pid, {:progress, progress})
24 | end)
25 |
26 | send(job_pid, {:complete, mp4_video})
27 | rescue
28 | e ->
29 | send(job_pid, {:error, e, job})
30 | reraise e, __STACKTRACE__
31 | end
32 | end)
33 | end
34 |
35 | defp await_transmuxer(video, stage \\ :retrieving, done \\ 0) do
36 | receive do
37 | {:progress, %{stage: stage_now, done: done_now, total: total}} ->
38 | Library.broadcast_processing_progressed!(stage, video, min(1, done / total))
39 | done_total = if(stage == stage_now, do: done, else: 0)
40 | await_transmuxer(video, stage_now, done_total + done_now)
41 |
42 | {:complete, %Library.Video{url: url}} ->
43 | Library.broadcast_processing_progressed!(stage, video, 1)
44 | Library.broadcast_processing_completed!(:download, video, url)
45 | {:ok, url}
46 |
47 | {:error, e, %Oban.Job{attempt: attempt, max_attempts: max_attempts}} ->
48 | Library.broadcast_processing_failed!(video, attempt, max_attempts)
49 | {:error, e}
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/algora/workers/transcriber.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Workers.Transcriber do
2 | use Oban.Worker, queue: :default, max_attempts: 1, unique: [period: 86_400]
3 |
4 | alias Algora.Library
5 | import Ecto.Query, warn: false
6 |
7 | require Logger
8 |
9 | @impl Oban.Worker
10 | def perform(%Oban.Job{args: %{"video_id" => video_id}} = job) do
11 | video = Library.get_video!(video_id)
12 | build_transcriber(job, video)
13 | await_transcriber(video)
14 | end
15 |
16 | defp build_transcriber(job, %Library.Video{} = video) do
17 | job_pid = self()
18 |
19 | Task.async(fn ->
20 | try do
21 | prediction =
22 | Library.transcribe_video(video, fn progress ->
23 | send(job_pid, {:progress, progress})
24 | end)
25 |
26 | _output =
27 | await_prediction(prediction.id, fn progress ->
28 | send(job_pid, {:progress, progress})
29 | end)
30 |
31 | send(job_pid, {:complete, video})
32 | rescue
33 | e ->
34 | send(job_pid, {:error, e, job})
35 | reraise e, __STACKTRACE__
36 | end
37 | end)
38 | end
39 |
40 | defp await_prediction(id, cb) do
41 | case Replicate.Predictions.get(id) do
42 | {:ok, %Replicate.Predictions.Prediction{status: "succeeded", output: output}} ->
43 | {:ok, resp} = Finch.build(:get, output) |> Finch.request(Algora.Finch)
44 | Jason.decode!(resp.body)
45 |
46 | {:ok, %Replicate.Predictions.Prediction{logs: logs}} ->
47 | cb.(%{stage: logs |> String.split("\n") |> Enum.at(-1), done: 1, total: 1})
48 | :timer.sleep(1000)
49 | await_prediction(id, cb)
50 |
51 | error ->
52 | error
53 | end
54 | end
55 |
56 | defp await_transcriber(video, stage \\ :retrieving, done \\ 0) do
57 | receive do
58 | {:progress, %{stage: stage_now, done: done_now, total: total}} ->
59 | Library.broadcast_processing_progressed!(stage, video, min(1, done / total))
60 | done_total = if(stage == stage_now, do: done, else: 0)
61 | await_transcriber(video, stage_now, done_total + done_now)
62 |
63 | {:complete, video} ->
64 | Library.broadcast_processing_progressed!(stage, video, 1)
65 | Library.broadcast_processing_completed!(:transcription, video, video.url)
66 | {:ok, video.url}
67 |
68 | {:error, e, %Oban.Job{attempt: attempt, max_attempts: max_attempts}} ->
69 | Library.broadcast_processing_failed!(video, attempt, max_attempts)
70 | {:error, e}
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/algora/youtube/chat.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Youtube.Chat do
2 | @youtube_headers [
3 | {"User-Agent",
4 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36"},
5 | {"Accept-Language", "en-US"}
6 | ]
7 |
8 | def get_video_data(urls) do
9 | urls
10 | |> Enum.reduce(nil, fn url, acc ->
11 | case fetch_response(url) do
12 | {:ok, response} -> response
13 | _ -> acc
14 | end
15 | end)
16 | |> handle_response()
17 | end
18 |
19 | def fetch_response(url) do
20 | HTTPoison.get(url, @youtube_headers)
21 | end
22 |
23 | defp handle_response(nil), do: {:error, {"Stream not found", 404}}
24 |
25 | defp handle_response(%HTTPoison.Response{status_code: 404}),
26 | do: {:error, {"Stream not found", 404}}
27 |
28 | defp handle_response(%HTTPoison.Response{status_code: status}) when status != 200,
29 | do: {:error, {"Failed to fetch stream: #{status}", status}}
30 |
31 | defp handle_response(%HTTPoison.Response{body: body}) do
32 | case Regex.run(
33 | ~r/(?:window\s*\[\s*["']ytInitialData["']\s*\]|ytInitialData)\s*=\s*({.+?})\s*;/,
34 | body
35 | ) do
36 | [_, initial_data] ->
37 | case Regex.run(~r/(?:ytcfg.set)\(({[\s\S]+?})\)\s*;/, body) do
38 | [_, config_str] ->
39 | config = Jason.decode!(config_str)
40 |
41 | if Map.has_key?(config, "INNERTUBE_API_KEY") and
42 | Map.has_key?(config, "INNERTUBE_CONTEXT") do
43 | {:ok, %{initial_data: initial_data, config: Map.put(config, "hl", "US")}}
44 | else
45 | {:error, {"Failed to load YouTube context", 500}}
46 | end
47 |
48 | _ ->
49 | {:error, {"Failed to parse config", 500}}
50 | end
51 |
52 | _ ->
53 | {:error, {"Failed to parse initial data", 500}}
54 | end
55 | end
56 |
57 | def get_continuation_token(continuation) when is_map(continuation) do
58 | continuation
59 | |> Enum.find_value(nil, fn
60 | {_key, %{"continuation" => continuation_token}} -> continuation_token
61 | _ -> nil
62 | end)
63 | end
64 |
65 | def get_continuation_token(_continuation), do: nil
66 |
67 | def get_id(data) when is_map(data) do
68 | data
69 | |> Map.delete("clickTrackingParams")
70 | |> traverse_map()
71 | end
72 |
73 | defp traverse_map(map) do
74 | case Map.to_list(map) do
75 | [{_action_type, %{"item" => action}}] ->
76 | case Map.to_list(action) do
77 | [{_renderer_type, %{"id" => id}}] -> id
78 | _ -> nil
79 | end
80 |
81 | _ ->
82 | nil
83 | end
84 | end
85 |
86 | def find_key_value(json_string, key, target_value) do
87 | case Jason.decode(json_string) do
88 | {:ok, decoded_json} ->
89 | find_in_nested(decoded_json, key, target_value)
90 |
91 | {:error, error} ->
92 | IO.puts("Error decoding JSON: #{inspect(error)}")
93 | end
94 | end
95 |
96 | defp find_in_nested(nil, _key, _target_value), do: nil
97 |
98 | defp find_in_nested(map = %{}, key, target_value) do
99 | Enum.find_value(map, fn
100 | {^key, ^target_value} -> map
101 | {_k, v} -> find_in_nested(v, key, target_value)
102 | end)
103 | end
104 |
105 | defp find_in_nested([head | tail], key, target_value) do
106 | find_in_nested(head, key, target_value) || find_in_nested(tail, key, target_value)
107 | end
108 |
109 | defp find_in_nested(_value, _key, _target_value), do: nil
110 | end
111 |
--------------------------------------------------------------------------------
/lib/algora/youtube/chat/supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.Youtube.Chat.Supervisor do
2 | use DynamicSupervisor
3 |
4 | def start_link(args) do
5 | DynamicSupervisor.start_link(__MODULE__, args, name: __MODULE__)
6 | end
7 |
8 | @impl true
9 | def init(_args) do
10 | DynamicSupervisor.init(strategy: :one_for_one, max_restarts: 1_000_000, max_seconds: 3600)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/algora_web.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use AlgoraWeb, :controller
9 | use AlgoraWeb, :html
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt manifest.json)
21 |
22 | def controller do
23 | quote do
24 | use Phoenix.Controller,
25 | namespace: AlgoraWeb,
26 | formats: [:html, :json],
27 | layouts: [html: AlgoraWeb.Layouts]
28 |
29 | import Plug.Conn
30 | import AlgoraWeb.Gettext
31 | alias AlgoraWeb.Router.Helpers, as: Routes
32 | unquote(verified_routes())
33 | end
34 | end
35 |
36 | def html do
37 | quote do
38 | use Phoenix.Component
39 |
40 | # Import convenience functions from controllers
41 | import Phoenix.Controller,
42 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
43 |
44 | # Include general helpers for rendering HTML
45 | unquote(html_helpers())
46 | end
47 | end
48 |
49 | def verified_routes do
50 | quote do
51 | use Phoenix.VerifiedRoutes,
52 | endpoint: AlgoraWeb.Endpoint,
53 | router: AlgoraWeb.Router,
54 | statics: AlgoraWeb.static_paths()
55 | end
56 | end
57 |
58 | def live_view(opts \\ []) do
59 | quote do
60 | @opts Keyword.merge(
61 | [
62 | layout: {AlgoraWeb.Layouts, :live},
63 | container: {:div, class: "relative flex"}
64 | ],
65 | unquote(opts)
66 | )
67 | use Phoenix.LiveView, @opts
68 |
69 | unquote(html_helpers())
70 | end
71 | end
72 |
73 | def live_component do
74 | quote do
75 | use Phoenix.LiveComponent
76 |
77 | unquote(html_helpers())
78 | end
79 | end
80 |
81 | def router do
82 | quote do
83 | use Phoenix.Router, helpers: false
84 |
85 | import Plug.Conn
86 | import Phoenix.Controller
87 | import Phoenix.LiveView.Router
88 | end
89 | end
90 |
91 | def channel do
92 | quote do
93 | use Phoenix.Channel
94 | import AlgoraWeb.Gettext
95 | end
96 | end
97 |
98 | defp html_helpers do
99 | quote do
100 | import Phoenix.HTML
101 | import Phoenix.HTML.Form
102 | # TODO: is this needed?
103 | use PhoenixHTMLHelpers
104 |
105 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
106 | use Phoenix.Component
107 |
108 | import AlgoraWeb.CoreComponents
109 | import AlgoraWeb.Gettext
110 | alias AlgoraWeb.Router.Helpers, as: Routes
111 | alias Phoenix.LiveView.JS
112 | unquote(verified_routes())
113 | end
114 | end
115 |
116 | @doc """
117 | When used, dispatch to the appropriate controller/view/etc.
118 | """
119 | defmacro __using__({which, opts}) when is_atom(which) do
120 | apply(__MODULE__, which, [opts])
121 | end
122 |
123 | defmacro __using__(which) when is_atom(which) do
124 | apply(__MODULE__, which, [])
125 | end
126 | end
127 |
--------------------------------------------------------------------------------
/lib/algora_web/api_spec.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ApiSpec do
2 | @moduledoc false
3 | @behaviour OpenApiSpex.OpenApi
4 |
5 | alias OpenApiSpex.{Components, Info, License, Paths, Schema, SecurityScheme}
6 |
7 | # OpenAPISpex master specification
8 |
9 | @impl OpenApiSpex.OpenApi
10 | def spec() do
11 | %OpenApiSpex.OpenApi{
12 | info: %Info{
13 | title: "Algora TV",
14 | version: "0.1.0",
15 | license: %License{
16 | name: "AGPLv3",
17 | url: "https://github.com/algora-io/tv/blob/main/LICENSE"
18 | }
19 | },
20 | paths: Paths.from_router(AlgoraWeb.Router),
21 | components: %Components{
22 | securitySchemes: %{"authorization" => %SecurityScheme{type: "http", scheme: "bearer"}}
23 | }
24 | }
25 | |> OpenApiSpex.resolve_schema_modules()
26 | end
27 |
28 | @spec data(String.t(), Schema.t()) :: {String.t(), String.t(), Schema.t()}
29 | def data(description, schema) do
30 | {description, "application/json", schema}
31 | end
32 |
33 | @spec error(String.t()) :: {String.t(), String.t(), module()}
34 | def error(description) do
35 | {description, "application/json", AlgoraWeb.ApiSpec.Error}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/algora_web/api_spec/hls.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ApiSpec.HLS do
2 | require OpenApiSpex
3 |
4 | defmodule Params do
5 | @moduledoc false
6 |
7 | defmodule HlsMsn do
8 | @moduledoc false
9 |
10 | OpenApiSpex.schema(%{
11 | type: :integer,
12 | minimum: 0,
13 | example: 10,
14 | description: "Segment sequence number",
15 | nullable: true
16 | })
17 | end
18 |
19 | defmodule HlsPart do
20 | @moduledoc false
21 |
22 | OpenApiSpex.schema(%{
23 | type: :integer,
24 | minimum: 0,
25 | example: 10,
26 | description: "Partial segment sequence number",
27 | nullable: true
28 | })
29 | end
30 |
31 | defmodule HlsSkip do
32 | @moduledoc false
33 |
34 | OpenApiSpex.schema(%{
35 | type: :string,
36 | example: "YES",
37 | description: "Set to \"YES\" if delta manifest should be requested",
38 | nullable: true
39 | })
40 | end
41 | end
42 |
43 | defmodule Response do
44 | @moduledoc false
45 |
46 | OpenApiSpex.schema(%{
47 | title: "HlsResponse",
48 | description: "Requested file",
49 | type: :string
50 | })
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/algora_web/channels/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Presence do
2 | @moduledoc """
3 | Provides presence tracking to channels and processes.
4 |
5 | See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html)
6 | docs for more details.
7 | """
8 | use Phoenix.Presence,
9 | otp_app: :algora,
10 | pubsub_server: Algora.PubSub
11 |
12 | def init(_opts) do
13 | {:ok, %{}}
14 | end
15 |
16 | def fetch(_topic, presences) do
17 | for {key, %{metas: [meta | metas]}} <- presences, into: %{} do
18 | # user can be populated here from the database here we populate
19 | # the name for demonstration purposes
20 | {key, %{metas: [meta | metas], id: meta.id, user: %{name: meta.id}}}
21 | end
22 | end
23 |
24 | def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do
25 | for {user_id, presence} <- joins do
26 | user_data = %{id: user_id, user: presence.user, metas: Map.fetch!(presences, user_id)}
27 | msg = {__MODULE__, {:join, user_data}}
28 | Phoenix.PubSub.local_broadcast(Algora.PubSub, "proxy:#{topic}", msg)
29 | end
30 |
31 | for {user_id, presence} <- leaves do
32 | metas =
33 | case Map.fetch(presences, user_id) do
34 | {:ok, presence_metas} -> presence_metas
35 | :error -> []
36 | end
37 |
38 | user_data = %{id: user_id, user: presence.user, metas: metas}
39 | msg = {__MODULE__, {:leave, user_data}}
40 | Phoenix.PubSub.local_broadcast(Algora.PubSub, "proxy:#{topic}", msg)
41 | end
42 |
43 | {:ok, state}
44 | end
45 |
46 | def list_online_users(topic),
47 | do: list(topic) |> Enum.map(fn {_id, presence} -> presence end)
48 |
49 | def track_user(topic, params), do: track(self(), topic, topic, params)
50 |
51 | def subscribe(topic), do: Phoenix.PubSub.subscribe(Algora.PubSub, "proxy:#{topic}")
52 | end
53 |
--------------------------------------------------------------------------------
/lib/algora_web/components/avatar.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Components.Avatar do
2 | @moduledoc false
3 | use Phoenix.Component
4 |
5 | attr(:src, :string)
6 | attr(:alt, :string)
7 | attr(:class, :string, default: nil)
8 | attr(:rest, :global)
9 |
10 | def user_avatar(assigns) do
11 | ~H"""
12 | <.avatar class={@class} {@rest}>
13 | <.avatar_fallback class="fallback">
14 | <%= @alt
15 | |> String.first()
16 | |> String.upcase() %>
17 |
18 | <.avatar_image src={@src} alt={@alt} />
19 |
20 | """
21 | end
22 |
23 | attr(:class, :string, default: nil)
24 | attr(:rest, :global)
25 | slot(:inner_block, required: true)
26 |
27 | def avatar(assigns) do
28 | ~H"""
29 |
30 | <%= render_slot(@inner_block) %>
31 |
32 | """
33 | end
34 |
35 | attr(:src, :string)
36 | attr(:alt, :string)
37 | attr(:class, :string, default: nil)
38 | attr(:rest, :global)
39 |
40 | def avatar_image(assigns) do
41 | ~H"""
42 |

51 | """
52 | end
53 |
54 | attr(:class, :string, default: nil)
55 | attr(:rest, :global)
56 | slot(:inner_block, required: false)
57 |
58 | def avatar_fallback(assigns) do
59 | ~H"""
60 |
64 | <%= render_slot(@inner_block) %>
65 |
66 | """
67 | end
68 |
69 | # TODO
70 | defp cn(x), do: x
71 | end
72 |
--------------------------------------------------------------------------------
/lib/algora_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <%= Phoenix.Flash.get(@flash, :info) %>
3 | <%= Phoenix.Flash.get(@flash, :note) %>
4 | <%= Phoenix.Flash.get(@flash, :error) %>
5 | <%= @inner_content %>
6 |
7 |
--------------------------------------------------------------------------------
/lib/algora_web/components/layouts/live_bare.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 | <.flash flash={@flash} kind={:info} />
4 | <.flash flash={@flash} kind={:note} />
5 | <.flash flash={@flash} kind={:error} />
6 | <.connection_status>
7 | Re-establishing connection...
8 |
9 |
10 | <.live_component module={AlgoraWeb.LayoutComponent} id="layout" />
11 |
12 |
13 | <%= @inner_content %>
14 |
15 |
16 |
--------------------------------------------------------------------------------
/lib/algora_web/components/layouts/live_chat.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 | <.flash flash={@flash} kind={:info} />
4 | <.flash flash={@flash} kind={:note} />
5 | <.flash flash={@flash} kind={:error} />
6 | <.connection_status>
7 | Re-establishing connection...
8 |
9 |
10 | <.live_component module={AlgoraWeb.LayoutComponent} id="layout" />
11 |
12 |
13 | <%= @inner_content %>
14 |
15 |
16 |
--------------------------------------------------------------------------------
/lib/algora_web/components/layouts/root_embed.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | <.live_title suffix={assigns[:page_title] && " | #{Algora.config([:title])}"}>
10 | <%= assigns[:page_title] || Algora.config([:title]) %>
11 |
12 |
13 | <%= if assigns[:page_title] do %>
14 |
15 |
16 | <% else %>
17 |
18 |
19 | <% end %>
20 |
21 | <%= if assigns[:page_description] do %>
22 |
23 |
24 |
25 | <% else %>
26 |
27 |
28 |
29 | <% end %>
30 |
31 |
35 |
39 |
40 |
41 |
42 |
43 | <%= if assigns[:page_url] do %>
44 |
45 | <% end %>
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
58 |
60 |
61 |
62 |
66 |
67 |
68 | <%= @inner_content %>
69 |
70 |
71 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/ad_redirect_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.AdRedirectController do
2 | use AlgoraWeb, :controller
3 | alias Algora.Ads
4 |
5 | def go(conn, %{"slug" => slug}) do
6 | ad = Ads.get_ad_by_slug!(slug)
7 |
8 | ## TODO: log errors
9 | Ads.track_visit(%{ad_id: ad.id})
10 |
11 | conn
12 | |> put_status(:found)
13 | |> redirect(external: ad.website_url)
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/embed_popout_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.EmbedPopoutController do
2 | use AlgoraWeb, :controller
3 |
4 | alias Algora.{Accounts, Library}
5 |
6 | def get(conn, %{"channel_handle" => channel_handle}) do
7 | user = Accounts.get_user_by!(handle: channel_handle)
8 |
9 | case Library.get_latest_video(user) do
10 | nil ->
11 | redirect(conn, to: ~p"/#{user.handle}")
12 |
13 | video ->
14 | redirect(conn,
15 | external:
16 | "https://#{URI.parse(AlgoraWeb.Endpoint.url()).host}:444/#{channel_handle}/#{video.id}/embed"
17 | )
18 | end
19 | end
20 |
21 | def get_by_id(conn, %{"channel_handle" => channel_handle, "video_id" => video_id}) do
22 | redirect(conn,
23 | external:
24 | "https://#{URI.parse(AlgoraWeb.Endpoint.url()).host}:444/#{channel_handle}/#{video_id}/embed"
25 | )
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ErrorHTML do
2 | use AlgoraWeb, :html
3 |
4 | def render(template, _assigns) do
5 | Phoenix.Controller.status_message_from_template(template)
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/fallback_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.FallbackController do
2 | use AlgoraWeb, :controller
3 |
4 | def call(conn, {:error, status, reason}) do
5 | conn
6 | |> put_resp_content_type("application/json")
7 | |> put_status(status)
8 | |> json(%{errors: reason})
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/github_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.GithubController do
2 | use AlgoraWeb, :controller
3 |
4 | alias Algora.{Accounts, Library}
5 |
6 | def get_thumbnail(conn, %{"user_id" => user_id}) do
7 | case Accounts.get_user_by_provider_id(:github, user_id) do
8 | nil -> send_resp(conn, 404, "Not found")
9 | user -> redirect(conn, external: Library.get_og_image_url(user))
10 | end
11 | end
12 |
13 | def get_channel(conn, %{"user_id" => user_id}) do
14 | case Accounts.get_user_by_provider_id(:github, user_id) do
15 | nil -> send_resp(conn, 404, "Not found")
16 | user -> redirect(conn, to: ~p"/#{user.handle}")
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/oauth_callback_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.OAuthCallbackController do
2 | use AlgoraWeb, :controller
3 | require Logger
4 |
5 | alias Algora.Accounts
6 |
7 | def new(conn, %{"provider" => "github", "code" => code, "state" => state} = params) do
8 | client = github_client(conn)
9 |
10 | with {:ok, info} <- client.exchange_access_token(code: code, state: state),
11 | %{info: info, primary_email: primary, emails: emails, token: token} = info,
12 | {:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do
13 | conn =
14 | if params["return_to"] do
15 | conn |> put_session(:user_return_to, params["return_to"])
16 | else
17 | conn
18 | end
19 |
20 | conn
21 | |> put_flash(:info, "Welcome, #{user.handle}!")
22 | |> AlgoraWeb.UserAuth.log_in_user(user)
23 | else
24 | {:error, %Ecto.Changeset{} = changeset} ->
25 | Logger.debug("failed GitHub insert #{inspect(changeset.errors)}")
26 |
27 | conn
28 | |> put_flash(
29 | :error,
30 | "We were unable to fetch the necessary information from your GitHub account"
31 | )
32 | |> redirect(to: "/")
33 |
34 | {:error, reason} ->
35 | Logger.debug("failed GitHub exchange #{inspect(reason)}")
36 |
37 | conn
38 | |> put_flash(:error, "We were unable to contact GitHub. Please try again later")
39 | |> redirect(to: "/")
40 | end
41 | end
42 |
43 | def new(conn, %{"provider" => "github", "error" => "access_denied"}) do
44 | redirect(conn, to: "/")
45 | end
46 |
47 | def new(conn, %{"provider" => "restream", "code" => code, "state" => state}) do
48 | client = restream_client(conn)
49 |
50 | user_id = get_session(conn, :user_id)
51 |
52 | with {:ok, state} <- verify_session(conn, :restream_state, state),
53 | {:ok, info} <- client.exchange_access_token(code: code, state: state),
54 | %{info: info, tokens: tokens} = info,
55 | {:ok, user} <- Accounts.link_restream_account(user_id, info, tokens) do
56 | conn
57 | |> put_flash(:info, "Restream account has been linked!")
58 | |> AlgoraWeb.UserAuth.log_in_user(user)
59 | else
60 | {:error, %Ecto.Changeset{} = changeset} ->
61 | Logger.debug("failed Restream insert #{inspect(changeset.errors)}")
62 |
63 | conn
64 | |> put_flash(
65 | :error,
66 | "We were unable to fetch the necessary information from your Restream account"
67 | )
68 | |> redirect(to: "/")
69 |
70 | {:error, reason} ->
71 | Logger.debug("failed Restream exchange #{inspect(reason)}")
72 |
73 | conn
74 | |> put_flash(:error, "We were unable to contact Restream. Please try again later")
75 | |> redirect(to: "/")
76 | end
77 | end
78 |
79 | defp verify_session(conn, key, token) do
80 | if Plug.Crypto.secure_compare(token, get_session(conn, key)) do
81 | {:ok, token}
82 | else
83 | {:error, "#{key} is invalid"}
84 | end
85 | end
86 |
87 | def sign_out(conn, _) do
88 | AlgoraWeb.UserAuth.log_out_user(conn)
89 | end
90 |
91 | defp github_client(conn) do
92 | conn.assigns[:github_client] || Algora.Github
93 | end
94 |
95 | defp restream_client(conn) do
96 | conn.assigns[:restream_client] || Algora.Restream
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/oauth_login_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.OAuthLoginController do
2 | use AlgoraWeb, :controller
3 | require Logger
4 |
5 | def new(conn, %{"provider" => "restream"} = params) do
6 | if conn.assigns.current_user do
7 | state = Algora.Util.random_string()
8 |
9 | conn
10 | |> put_session(:user_return_to, params["return_to"])
11 | |> put_session(:restream_state, state)
12 | |> redirect(external: Algora.Restream.authorize_url(state))
13 | else
14 | conn |> redirect(to: ~p"/auth/login")
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/page_html.ex:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/lib/algora_web/controllers/page_html.ex
--------------------------------------------------------------------------------
/lib/algora_web/controllers/redirect_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.RedirectController do
2 | use AlgoraWeb, :controller
3 |
4 | import AlgoraWeb.UserAuth, only: [fetch_current_user: 2]
5 |
6 | plug :fetch_current_user
7 |
8 | def redirect_authenticated(conn, _) do
9 | if conn.assigns.current_user do
10 | AlgoraWeb.UserAuth.redirect_if_user_is_authenticated(conn, [])
11 | else
12 | redirect(conn, to: ~p"/auth/login")
13 | end
14 | end
15 |
16 | @guests %{
17 | "tembo" => 10745,
18 | "percona" => 10777,
19 | "keygen" => 10799,
20 | "electric" => 10826,
21 | "midday" => 10851,
22 | "trigger" => 10867,
23 | "briefer" => 10896,
24 | "typesense" => 10967
25 | }
26 |
27 | def guests, do: @guests
28 |
29 | def redirect_guest(conn, _params) do
30 | redirect(conn, to: "/algora/#{conn.assigns[:video_id]}")
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/show_calendar_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ShowCalendarController do
2 | use AlgoraWeb, :controller
3 |
4 | alias Algora.{Shows, Accounts, Library}
5 |
6 | def export(conn, %{"slug" => slug} = params) do
7 | case Shows.get_show_by_fields!(slug: slug) do
8 | nil ->
9 | send_resp(conn, 404, "Not found")
10 |
11 | show when show.scheduled_for == nil ->
12 | send_resp(conn, 404, "Not found")
13 |
14 | show ->
15 | channel = Accounts.get_user!(show.user_id) |> Library.get_channel!()
16 | url = show.url || "#{AlgoraWeb.Endpoint.url()}/#{channel.handle}/latest"
17 |
18 | start_date =
19 | show.scheduled_for
20 | |> Timex.to_datetime("Etc/UTC")
21 | |> Timex.Timezone.convert(params["tz"] || "Etc/UTC")
22 |
23 | end_date = DateTime.add(start_date, 3600)
24 |
25 | events = [
26 | %ICalendar.Event{
27 | summary: show.title,
28 | dtstart: start_date,
29 | dtend: end_date,
30 | description: show.description,
31 | location: url,
32 | url: url,
33 | organizer: channel.name
34 | }
35 | ]
36 |
37 | ics = %ICalendar{events: events} |> ICalendar.to_ics()
38 |
39 | conn
40 | |> put_resp_content_type("text/calendar")
41 | |> put_resp_header("content-disposition", "attachment; filename=#{show.slug}.ics")
42 | |> Plug.Conn.send_resp(:ok, ics)
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/video_popout_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.VideoPopoutController do
2 | use AlgoraWeb, :controller
3 |
4 | alias Algora.{Accounts, Library}
5 |
6 | def get(conn, %{"channel_handle" => channel_handle}) do
7 | user = Accounts.get_user_by!(handle: channel_handle)
8 |
9 | case Library.get_latest_video(user) do
10 | nil ->
11 | redirect(conn, to: ~p"/#{user.handle}")
12 |
13 | video ->
14 | redirect(conn, to: ~p"/#{user.handle}/#{video.id}")
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/algora_web/controllers/youtube_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.YoutubeAuthController do
2 | use AlgoraWeb, :controller
3 | plug Ueberauth
4 | plug :ensure_authenticated
5 |
6 | alias Algora.Accounts.User
7 |
8 | def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
9 | user = conn.assigns.current_user
10 |
11 | case User.create_or_update_youtube_identity(user, auth) do
12 | {:ok, _identity} ->
13 | conn
14 | |> put_flash(:info, "Successfully connected your YouTube account.")
15 | |> redirect(to: "/channel/settings")
16 |
17 | {:error, _changeset} ->
18 | conn
19 | |> put_flash(:error, "Error connecting your YouTube account.")
20 | |> redirect(to: "/channel/settings")
21 | end
22 | end
23 |
24 | def callback(%{assigns: %{ueberauth_failure: _failure}} = conn, _params) do
25 | conn
26 | |> put_flash(:error, "Failed to connect YouTube account.")
27 | |> redirect(to: "/channel/settings")
28 | end
29 |
30 | defp ensure_authenticated(conn, _opts) do
31 | if conn.assigns[:current_user] do
32 | conn
33 | else
34 | conn
35 | |> put_flash(:error, "You must be logged in to connect your YouTube account.")
36 | |> redirect(to: "/auth/login")
37 | |> halt()
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/algora_web/embed/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Embed.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :algora
3 |
4 | socket "/live", Phoenix.LiveView.Socket
5 |
6 | # Serve at "/" the static files from "priv/static" directory.
7 | #
8 | # You should set gzip to true if you are running phx.digest
9 | # when deploying your static files in production.
10 | plug Plug.Static,
11 | at: "/",
12 | from: :algora,
13 | gzip: false,
14 | only: AlgoraWeb.static_paths()
15 |
16 | # Code reloading can be explicitly enabled under the
17 | # :code_reloader configuration of your endpoint.
18 | if code_reloading? do
19 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
20 | plug Phoenix.LiveReloader
21 | plug Phoenix.CodeReloader
22 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :algora
23 | end
24 |
25 | plug Phoenix.LiveDashboard.RequestLogger,
26 | param_key: "request_logger",
27 | cookie_key: "request_logger"
28 |
29 | plug Plug.RequestId
30 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
31 |
32 | plug Plug.Parsers,
33 | parsers: [:urlencoded, :multipart, :json],
34 | pass: ["*/*"],
35 | json_decoder: Phoenix.json_library()
36 |
37 | plug Plug.MethodOverride
38 | plug Plug.Head
39 | plug AlgoraWeb.Embed.Router
40 | end
41 |
--------------------------------------------------------------------------------
/lib/algora_web/embed/router.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Embed.Router do
2 | use AlgoraWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :put_root_layout, {AlgoraWeb.Layouts, :root_embed}
7 | plug :put_secure_browser_headers
8 | end
9 |
10 | pipeline :embed do
11 | plug AlgoraWeb.Plugs.AllowIframe
12 | end
13 |
14 | scope "/", AlgoraWeb do
15 | pipe_through [:browser, :embed]
16 |
17 | live_session :embed,
18 | layout: {AlgoraWeb.Layouts, :live_bare} do
19 | live "/:channel_handle/:video_id/embed", EmbedLive, :show
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/algora_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :algora
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_algora_key_v1",
10 | signing_salt: "WCC/F/SKdA2YFt9qhULZeyo2ITCmFzYt"
11 | ]
12 |
13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
14 |
15 | socket "/chat/:channel_handle", AlgoraWeb.Websockets.ChatSocket
16 |
17 | # Serve at "/" the static files from "priv/static" directory.
18 | #
19 | # You should set gzip to true if you are running phx.digest
20 | # when deploying your static files in production.
21 | plug Plug.Static,
22 | at: "/",
23 | from: :algora,
24 | gzip: false,
25 | only: AlgoraWeb.static_paths()
26 |
27 | # Code reloading can be explicitly enabled under the
28 | # :code_reloader configuration of your endpoint.
29 | if code_reloading? do
30 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
31 | plug Phoenix.LiveReloader
32 | plug Phoenix.CodeReloader
33 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :algora
34 | end
35 |
36 | plug Phoenix.LiveDashboard.RequestLogger,
37 | param_key: "request_logger",
38 | cookie_key: "request_logger"
39 |
40 | plug Plug.RequestId
41 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
42 |
43 | plug Plug.Parsers,
44 | parsers: [:urlencoded, :multipart, :json],
45 | pass: ["*/*"],
46 | json_decoder: Phoenix.json_library()
47 |
48 | plug Plug.MethodOverride
49 | plug Plug.Head
50 | plug Plug.Session, @session_options
51 | plug AlgoraWeb.Router
52 | end
53 |
--------------------------------------------------------------------------------
/lib/algora_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.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 AlgoraWeb.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: :algora
24 | end
25 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.AdLive.FormComponent do
2 | use AlgoraWeb, :live_component
3 |
4 | alias Algora.Ads
5 |
6 | @impl true
7 | def render(assigns) do
8 | ~H"""
9 |
10 | <.header class="mb-8">
11 | <%= @title %>
12 | <:subtitle>Use this form to manage ad records in your database.
13 |
14 |
15 | <.simple_form
16 | for={@form}
17 | id="ad-form"
18 | phx-target={@myself}
19 | phx-change="validate"
20 | phx-submit="save"
21 | >
22 |
23 |
24 | tv.algora.io/go/
25 |
26 | <.input field={@form[:slug]} type="text" label="QR Code URL" class="ps-[6.75rem]" />
27 |
28 | <.input field={@form[:website_url]} type="text" label="Website URL" />
29 | <.input
30 | field={@form[:border_color]}
31 | type="text"
32 | label="Border color"
33 | style={"color: #{valid_color(@form[:border_color].value)}"}
34 | />
35 | <:actions>
36 | <.button phx-disable-with="Saving...">Save Ad
37 |
38 |
39 |
40 | """
41 | end
42 |
43 | @impl true
44 | def update(%{ad: ad} = assigns, socket) do
45 | changeset = Ads.change_ad(ad)
46 |
47 | {:ok,
48 | socket
49 | |> assign(assigns)
50 | |> assign_form(changeset)}
51 | end
52 |
53 | @impl true
54 | def handle_event("validate", %{"ad" => ad_params}, socket) do
55 | changeset =
56 | socket.assigns.ad
57 | |> Ads.change_ad(ad_params)
58 | |> Map.put(:action, :validate)
59 |
60 | {:noreply, assign_form(socket, changeset)}
61 | end
62 |
63 | def handle_event("save", %{"ad" => ad_params}, socket) do
64 | save_ad(socket, socket.assigns.action, ad_params)
65 | end
66 |
67 | defp save_ad(socket, :edit, ad_params) do
68 | case Ads.update_ad(socket.assigns.ad, ad_params) do
69 | {:ok, ad} ->
70 | notify_parent({:saved, ad})
71 | Ads.broadcast_ad_updated!(ad)
72 |
73 | {:noreply,
74 | socket
75 | |> put_flash(:info, "Ad updated successfully")
76 | |> push_patch(to: socket.assigns.patch)}
77 |
78 | {:error, %Ecto.Changeset{} = changeset} ->
79 | {:noreply, assign_form(socket, changeset)}
80 | end
81 | end
82 |
83 | defp save_ad(socket, :new, ad_params) do
84 | case Ads.create_ad(ad_params) do
85 | {:ok, ad} ->
86 | notify_parent({:saved, ad})
87 | Ads.broadcast_ad_created!(ad)
88 |
89 | {:noreply,
90 | socket
91 | |> put_flash(:info, "Ad created successfully")
92 | |> push_patch(to: socket.assigns.patch)}
93 |
94 | {:error, %Ecto.Changeset{} = changeset} ->
95 | {:noreply, assign_form(socket, changeset)}
96 | end
97 | end
98 |
99 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do
100 | assign(socket, :form, to_form(changeset))
101 | end
102 |
103 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
104 |
105 | defp valid_color(color) do
106 | if is_valid_hex_color?(color), do: color, else: "inherit"
107 | end
108 |
109 | defp is_valid_hex_color?(nil), do: false
110 |
111 | defp is_valid_hex_color?(color), do: color =~ ~r/^#([0-9A-F]{3}){1,2}$/i
112 | end
113 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.AdLive.Index do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Ads
5 | alias Algora.Ads.Ad
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | if connected?(socket) do
10 | schedule_next_rotation()
11 | end
12 |
13 | next_slot = Ads.next_slot()
14 |
15 | ads =
16 | Ads.list_active_ads()
17 | |> Ads.rotate_ads()
18 | |> Enum.with_index(-1)
19 | |> Enum.map(fn {ad, index} ->
20 | %{
21 | ad
22 | | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
23 | }
24 | end)
25 |
26 | {:ok,
27 | socket
28 | |> stream(:ads, ads)
29 | |> assign(:next_slot, Ads.next_slot())}
30 | end
31 |
32 | @impl true
33 | def handle_params(params, _url, socket) do
34 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
35 | end
36 |
37 | defp apply_action(socket, :edit, %{"id" => id}) do
38 | socket
39 | |> assign(:page_title, "Edit Ad")
40 | |> assign(:ad, Ads.get_ad!(id))
41 | end
42 |
43 | defp apply_action(socket, :new, _params) do
44 | socket
45 | |> assign(:page_title, "New Ad")
46 | |> assign(:ad, %Ad{})
47 | end
48 |
49 | defp apply_action(socket, :index, _params) do
50 | socket
51 | |> assign(:page_title, "Listing Ads")
52 | |> assign(:ad, nil)
53 | end
54 |
55 | @impl true
56 | def handle_info({AlgoraWeb.AdLive.FormComponent, {:saved, ad}}, socket) do
57 | {:noreply, stream_insert(socket, :ads, ad)}
58 | end
59 |
60 | @impl true
61 | def handle_info(:rotate_ads, socket) do
62 | schedule_next_rotation()
63 |
64 | next_slot = Ads.next_slot()
65 |
66 | rotated_ads =
67 | socket.assigns.ads
68 | |> Ads.rotate_ads(1)
69 | |> Enum.with_index(-1)
70 | |> Enum.map(fn {ad, index} ->
71 | %{
72 | ad
73 | | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
74 | }
75 | end)
76 |
77 | {:noreply,
78 | socket
79 | |> stream(:ads, rotated_ads)
80 | |> assign(:next_slot, Ads.next_slot())}
81 | end
82 |
83 | @impl true
84 | def handle_event("delete", %{"id" => id}, socket) do
85 | ad = Ads.get_ad!(id)
86 | {:ok, _} = Ads.delete_ad(ad)
87 | Ads.broadcast_ad_deleted!(ad)
88 |
89 | {:noreply, stream_delete(socket, :ads, ad)}
90 | end
91 |
92 | defp schedule_next_rotation do
93 | Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot())
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.header class="pl-4 pr-6">
2 | Active Ads
3 | <:actions>
4 | <.link patch={~p"/ads/new"}>
5 | <.button>New Ad
6 |
7 |
8 |
9 |
10 | <.table id="ads" rows={@streams.ads} row_click={fn {_id, ad} -> JS.navigate(~p"/ads/#{ad}") end}>
11 | <:col :let={{_id, ad}} label="">
12 |
13 |
14 |
15 | <%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %>
16 |
17 |
18 | <%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %>
19 |
20 |
21 |
35 | algora.tv/go/<%= ad.slug %>
36 |
37 |
38 |
39 | <.live_billboard ad={ad} id={"ad-banner-#{ad.id}"} />
40 |
41 | <:action :let={{_id, ad}}>
42 |
43 | <.link navigate={~p"/ads/#{ad}"}>Show
44 |
45 | <.link patch={~p"/ads/#{ad}/edit"}>Edit
46 |
47 | <:action :let={{id, ad}}>
48 | <.link
49 | phx-click={JS.push("delete", value: %{id: ad.id}) |> hide("##{id}")}
50 | data-confirm="Are you sure?"
51 | >
52 | Delete
53 |
54 |
55 |
56 |
57 | <.modal :if={@live_action in [:new, :edit]} id="ad-modal" show on_cancel={JS.patch(~p"/ads")}>
58 | <.live_component
59 | module={AlgoraWeb.AdLive.FormComponent}
60 | id={@ad.id || :new}
61 | title={@page_title}
62 | action={@live_action}
63 | ad={@ad}
64 | patch={~p"/ads"}
65 | />
66 |
67 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/schedule.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.AdLive.Schedule do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Ads
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | if connected?(socket) do
9 | schedule_next_rotation()
10 | end
11 |
12 | next_slot = Ads.next_slot()
13 |
14 | ads =
15 | Ads.list_active_ads()
16 | |> Ads.rotate_ads()
17 | |> Enum.with_index(-1)
18 | |> Enum.map(fn {ad, index} ->
19 | %{
20 | ad
21 | | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
22 | }
23 | end)
24 |
25 | {:ok,
26 | socket
27 | |> stream(:ads, ads)
28 | |> assign(:next_slot, Ads.next_slot())}
29 | end
30 |
31 | @impl true
32 | def handle_params(params, _url, socket) do
33 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
34 | end
35 |
36 | defp apply_action(socket, :schedule, _params) do
37 | socket
38 | |> assign(:page_title, "Ads schedule")
39 | |> assign(:ad, nil)
40 | end
41 |
42 | @impl true
43 | def handle_info(:rotate_ads, socket) do
44 | schedule_next_rotation()
45 |
46 | next_slot = Ads.next_slot()
47 |
48 | rotated_ads =
49 | socket.assigns.ads
50 | |> Ads.rotate_ads(1)
51 | |> Enum.with_index(-1)
52 | |> Enum.map(fn {ad, index} ->
53 | %{
54 | ad
55 | | scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
56 | }
57 | end)
58 |
59 | {:noreply,
60 | socket
61 | |> stream(:ads, rotated_ads)
62 | |> assign(:next_slot, Ads.next_slot())}
63 | end
64 |
65 | defp schedule_next_rotation do
66 | Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot())
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/schedule.html.heex:
--------------------------------------------------------------------------------
1 | <.header class="pl-4 pr-6">
2 | Ads schedule
3 |
4 |
5 | <.table id="ads" rows={@streams.ads}>
6 | <:col :let={{_id, ad}} label="">
7 |
8 |
9 |
10 | <%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %>
11 |
12 |
13 | <%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %>
14 |
15 |
16 |
30 | algora.tv/go/<%= ad.slug %>
31 |
32 |
33 |
34 | <.live_billboard ad={ad} id={"ad-banner-#{ad.id}"} />
35 |
36 |
37 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.AdLive.Show do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Ads
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(:ad, Ads.get_ad!(id))}
17 | end
18 |
19 | defp page_title(:show), do: "Show Ad"
20 | defp page_title(:edit), do: "Edit Ad"
21 | end
22 |
--------------------------------------------------------------------------------
/lib/algora_web/live/ad_live/show.html.heex:
--------------------------------------------------------------------------------
1 | <.header>
2 | Ad <%= @ad.id %>
3 | <:subtitle>This is a ad record from your database.
4 | <:actions>
5 | <.link patch={~p"/ads/#{@ad}/show/edit"} phx-click={JS.push_focus()}>
6 | <.button>Edit ad
7 |
8 |
9 |
10 |
11 | <.list>
12 | <:item title="Verified"><%= @ad.verified %>
13 | <:item title="Website url"><%= @ad.website_url %>
14 | <:item title="Composite asset url"><%= @ad.composite_asset_urls |> Enum.join(", ") %>
15 | <:item title="Asset url"><%= @ad.asset_url %>
16 | <:item title="Logo url"><%= @ad.logo_url %>
17 | <:item title="Qrcode url"><%= @ad.qrcode_url %>
18 | <:item title="Start date"><%= @ad.start_date %>
19 | <:item title="End date"><%= @ad.end_date %>
20 | <:item title="Total budget"><%= @ad.total_budget %>
21 | <:item title="Daily budget"><%= @ad.daily_budget %>
22 | <:item title="Tech stack"><%= @ad.tech_stack %>
23 | <:item title="Status"><%= @ad.status %>
24 |
25 |
26 | <.back navigate={~p"/ads"}>Back to ads
27 |
28 | <.modal :if={@live_action == :edit} id="ad-modal" show on_cancel={JS.patch(~p"/ads/#{@ad}")}>
29 | <.live_component
30 | module={AlgoraWeb.AdLive.FormComponent}
31 | id={@ad.id}
32 | title={@page_title}
33 | action={@live_action}
34 | ad={@ad}
35 | patch={~p"/ads/#{@ad}"}
36 | />
37 |
38 |
--------------------------------------------------------------------------------
/lib/algora_web/live/channel_live/stream_form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ChannelLive.StreamFormComponent do
2 | use AlgoraWeb, :live_component
3 |
4 | alias Algora.Accounts
5 |
6 | def handle_event("validate", %{"user" => params}, socket) do
7 | changeset = Accounts.change_settings(socket.assigns.current_user, params)
8 | {:noreply, assign(socket, changeset: Map.put(changeset, :action, :validate))}
9 | end
10 |
11 | def handle_event("save", %{"user" => params}, socket) do
12 | case Accounts.update_settings(socket.assigns.current_user, params) do
13 | {:ok, user} ->
14 | {:noreply,
15 | socket
16 | |> assign(current_user: user)
17 | |> put_flash(:info, "settings updated!")}
18 |
19 | {:error, changeset} ->
20 | {:noreply, assign(socket, changeset: changeset)}
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/algora_web/live/channel_live/stream_form_component.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <.form
3 | :let={f}
4 | id="stream-form"
5 | for={@changeset}
6 | class="space-y-8"
7 | phx-target={@myself}
8 | phx-change="validate"
9 | phx-submit="save"
10 | >
11 |
12 |
13 |
14 |
17 |
18 | <%= text_input(f, :channel_tagline,
19 | class:
20 | "bg-gray-950 text-white flex-1 focus:ring-purple-400 focus:border-purple-400 block w-full min-w-0 rounded-md sm:text-sm border-gray-600"
21 | ) %>
22 | <.error field={:channel_tagline} input_name="user[channel_tagline]" errors={f.errors} />
23 |
24 |
25 |
26 |
27 |
30 |
31 |
32 | <%= "rtmp://#{URI.parse(AlgoraWeb.Endpoint.url()).host}:#{Algora.config([:rtmp_port])}/#{@current_user.stream_key}" %>
33 |
34 |
35 |
36 | <%= "Paste into OBS Studio > File > Settings > Stream > Server" %>
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/lib/algora_web/live/hero_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.HeroComponent do
2 | use AlgoraWeb, :live_component
3 |
4 | alias Algora.{Library, Events}
5 | alias AlgoraWeb.Presence
6 |
7 | @impl true
8 | def render(assigns) do
9 | ~H"""
10 |
15 | """
16 | end
17 |
18 | @impl true
19 | def update(assigns, socket) do
20 | # TODO: log at regular intervals
21 | # if socket.current_user && socket.assigns.video.is_live do
22 | # schedule_watch_event(:timer.seconds(2))
23 | # end
24 |
25 | socket =
26 | case assigns[:video] do
27 | nil ->
28 | socket
29 |
30 | video ->
31 | %{current_user: current_user} = assigns
32 |
33 | Events.log_watched(current_user, video)
34 |
35 | Presence.track_user(video.channel_handle, %{
36 | id: if(current_user, do: current_user.handle, else: "")
37 | })
38 |
39 | socket
40 | |> push_event("play_video", %{
41 | player_id: assigns.id,
42 | id: video.id,
43 | url: video.url,
44 | title: video.title,
45 | player_type: Library.player_type(video),
46 | channel_name: video.channel_name,
47 | current_time: assigns[:current_time]
48 | })
49 | end
50 |
51 | {:ok,
52 | socket
53 | |> assign(:id, assigns[:id])}
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/algora_web/live/layout_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.LayoutComponent do
2 | @moduledoc """
3 | Component for rendering content inside layout without full DOM patch.
4 | """
5 | use AlgoraWeb, :live_component
6 |
7 | def show_modal(module, attrs) do
8 | send_update(__MODULE__, id: "layout", show: Enum.into(attrs, %{module: module}))
9 | end
10 |
11 | def hide_modal do
12 | send_update(__MODULE__, id: "layout", show: nil)
13 | end
14 |
15 | def update(%{id: id} = assigns, socket) do
16 | show =
17 | case assigns[:show] do
18 | %{module: _module, confirm: {text, attrs}} = show ->
19 | show
20 | |> Map.put_new(:title, show[:title])
21 | |> Map.put_new(:on_cancel, show[:on_cancel] || %JS{})
22 | |> Map.put_new(:on_confirm, show[:on_confirm] || %JS{})
23 | |> Map.put_new(:patch, nil)
24 | |> Map.put_new(:navigate, nil)
25 | |> Map.merge(%{confirm_text: text, confirm_attrs: attrs})
26 |
27 | nil ->
28 | nil
29 | end
30 |
31 | {:ok, assign(socket, id: id, show: show)}
32 | end
33 |
34 | def render(assigns) do
35 | ~H"""
36 |
37 | <%= if @show do %>
38 | <.modal show id={@id} on_cancel={@show.on_cancel} on_confirm={@show.on_confirm}>
39 | <:title><%= @show.title %>
40 | <.live_component module={@show.module} {@show} />
41 | <:cancel>Cancel
42 | <:confirm {@show.confirm_attrs}><%= @show.confirm_text %>
43 |
44 | <% end %>
45 |
46 | """
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/algora_web/live/nav.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Nav do
2 | import Phoenix.LiveView
3 | use Phoenix.Component
4 |
5 | alias Algora.{Library}
6 | alias AlgoraWeb.{ChannelLive, HomeLive, SettingsLive}
7 |
8 | def on_mount(:default, _params, _session, socket) do
9 | {:cont,
10 | socket
11 | |> assign(active_users: Library.list_live_channels(limit: 20))
12 | |> assign(:region, System.get_env("FLY_REGION") || "iad")
13 | |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)}
14 | end
15 |
16 | defp handle_active_tab_params(params, _url, socket) do
17 | active_tab =
18 | case {socket.view, socket.assigns.live_action} do
19 | {ChannelLive, _} ->
20 | if params["channel_handle"] == current_user_channel_handle(socket) do
21 | :channel
22 | end
23 |
24 | {HomeLive, _} ->
25 | :home
26 |
27 | {SettingsLive, _} ->
28 | :settings
29 |
30 | {_, _} ->
31 | nil
32 | end
33 |
34 | {:cont, assign(socket, active_tab: active_tab)}
35 | end
36 |
37 | defp current_user_channel_handle(socket) do
38 | if user = socket.assigns.current_user do
39 | user.handle
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/algora_web/live/player_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.PlayerComponent do
2 | use AlgoraWeb, :live_component
3 |
4 | alias Algora.{Library, Events}
5 | alias AlgoraWeb.Presence
6 |
7 | @impl true
8 | def render(assigns) do
9 | ~H"""
10 |
17 | """
18 | end
19 |
20 | @impl true
21 | def update(assigns, socket) do
22 | # TODO: log at regular intervals
23 | # if socket.current_user && socket.assigns.video.is_live do
24 | # schedule_watch_event(:timer.seconds(2))
25 | # end
26 |
27 | socket =
28 | case assigns[:video] do
29 | nil ->
30 | socket
31 |
32 | video ->
33 | %{current_user: current_user} = assigns
34 |
35 | Events.log_watched(current_user, video)
36 |
37 | Presence.track_user(video.channel_handle, %{
38 | id: if(current_user, do: current_user.handle, else: "")
39 | })
40 |
41 | socket
42 | |> push_event("play_video", %{
43 | is_live: video.is_live,
44 | player_id: assigns.id,
45 | id: video.id,
46 | url: video.url,
47 | title: assigns[:title] || video.title,
48 | poster: video.thumbnail_url,
49 | player_type: Library.player_type(video),
50 | channel_name: video.channel_name,
51 | current_time: assigns[:current_time] || 0,
52 | clip_start_time: assigns[:clip_start_time] || assigns[:current_time] || 0,
53 | clip_end_time: assigns[:end_time]
54 | })
55 | end
56 |
57 | {:ok,
58 | socket
59 | |> assign(:id, assigns[:id])}
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/algora_web/live/show_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ShowLive.Index do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Shows
5 | alias Algora.Shows.Show
6 |
7 | @impl true
8 | def mount(_params, _session, socket) do
9 | {:ok, stream(socket, :shows, Shows.list_shows())}
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 | @impl true
18 | def handle_info({AlgoraWeb.ShowLive.FormComponent, {:saved, show}}, socket) do
19 | {:noreply, socket |> assign(:show, show)}
20 | end
21 |
22 | defp apply_action(socket, :new, _params) do
23 | socket
24 | |> assign(:page_title, "New Show")
25 | |> assign(:show, %Show{})
26 | end
27 |
28 | defp apply_action(socket, :index, _params) do
29 | socket
30 | |> assign(:page_title, "Listing Shows")
31 | |> assign(:show, nil)
32 | end
33 |
34 | @impl true
35 | def handle_event("delete", %{"id" => id}, socket) do
36 | show = Shows.get_show!(id)
37 | {:ok, _} = Shows.delete_show(show)
38 |
39 | {:noreply, stream_delete(socket, :shows, show)}
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/algora_web/live/show_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.header>
2 | Listing Shows
3 | <:actions>
4 | <.link patch={~p"/shows/new"}>
5 | <.button>New Show
6 |
7 |
8 |
9 |
10 | <.table
11 | id="shows"
12 | rows={@streams.shows}
13 | row_click={fn {_id, show} -> JS.navigate(~p"/shows/#{show.slug}") end}
14 | >
15 | <:col :let={{_id, show}} label="Title"><%= show.title %>
16 | <:col :let={{_id, show}} label="Slug"><%= show.slug %>
17 | <:col :let={{_id, show}} label="Scheduled for"><%= show.scheduled_for %>
18 | <:col :let={{_id, show}} label="Image url"><%= show.image_url %>
19 | <:action :let={{_id, show}}>
20 |
21 | <.link navigate={~p"/shows/#{show.slug}"}>Show
22 |
23 | <.link patch={~p"/shows/#{show.slug}/edit"}>Edit
24 |
25 | <:action :let={{id, show}}>
26 | <.link
27 | phx-click={JS.push("delete", value: %{id: show.id}) |> hide("##{id}")}
28 | data-confirm="Are you sure?"
29 | >
30 | Delete
31 |
32 |
33 |
34 |
35 | <.modal :if={@live_action in [:new, :edit]} id="show-modal" show on_cancel={JS.patch(~p"/shows")}>
36 | <.live_component
37 | module={AlgoraWeb.ShowLive.FormComponent}
38 | id={@show.id || :new}
39 | title={@page_title}
40 | action={@live_action}
41 | show={@show}
42 | patch={~p"/shows"}
43 | />
44 |
45 |
--------------------------------------------------------------------------------
/lib/algora_web/live/sign_in_live.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.SignInLive do
2 | use AlgoraWeb, :live_view
3 |
4 | def render(assigns) do
5 | ~H"""
6 |
19 | """
20 | end
21 |
22 | def mount(_params, _session, socket) do
23 | {:ok, socket}
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/algora_web/live/subscriptions_live.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.SubscriptionsLive do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Events
5 |
6 | def render(assigns) do
7 | ~H"""
8 |
9 | <.header>
10 |
Subscriptions
11 |
View & manage your subscriptions
12 |
13 |
14 |
15 | -
19 |
24 |
25 |
26 |
27 |
28 |
29 | <%= subscription.user_display_name %>
30 |
31 |
32 | - Bio
33 | -
34 | <%= subscription.user_meta["user"]["bio"] ||
35 | subscription.user_meta["user"]["company"] || "@#{subscription.user_handle}" %>
36 |
37 |
38 |
39 | <.button class="opacity-0 rounded-none h-0 group-hover:opacity-100 group-hover:h-10 transition-all text-sm bg-gray-950/50 hover:bg-gray-950/75 text-white">
40 | <.link navigate={~p"/#{subscription.user_handle}"} class="relative">
41 | Watch
42 |
43 |
44 | <.button
45 | phx-click="unsubscribe"
46 | phx-value-id={subscription.channel_id}
47 | class="opacity-0 rounded-none h-0 group-hover:opacity-100 group-hover:h-10 transition-all text-sm bg-gray-950/50 hover:bg-gray-950/75 text-white"
48 | >
49 | Unsubscribe
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | """
58 | end
59 |
60 | def mount(_params, _session, socket) do
61 | user = socket.assigns.current_user
62 | subscriptions = Events.fetch_subscriptions(user)
63 |
64 | {:ok,
65 | socket
66 | |> assign(:subscriptions, subscriptions)}
67 | end
68 |
69 | def handle_event("unsubscribe", %{"id" => id}, socket) do
70 | Events.unsubscribe(socket.assigns.current_user, String.to_integer(id))
71 | subscriptions = Events.fetch_subscriptions(socket.assigns.current_user)
72 |
73 | {:noreply,
74 | socket
75 | |> assign(:subscriptions, subscriptions)}
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/algora_web/live/subtitle_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.SubtitleLive.FormComponent do
2 | use AlgoraWeb, :live_component
3 |
4 | alias Algora.Library
5 |
6 | @impl true
7 | def render(assigns) do
8 | ~H"""
9 |
10 | <.header class="pb-6">
11 | <%= @title %>
12 | <:subtitle>Use this form to manage subtitle records in your database.
13 |
14 |
15 | <.simple_form
16 | for={@form}
17 | id="subtitle-form"
18 | phx-target={@myself}
19 | phx-change="validate"
20 | phx-submit="save"
21 | >
22 | <.input field={@form[:body]} type="text" label="Body" />
23 | <.input field={@form[:start]} type="number" label="Start" step="any" />
24 | <.input field={@form[:end]} type="number" label="End" step="any" />
25 | <:actions>
26 | <.button phx-disable-with="Saving...">Save Subtitle
27 |
28 |
29 |
30 | """
31 | end
32 |
33 | @impl true
34 | def update(%{subtitle: subtitle} = assigns, socket) do
35 | changeset = Library.change_subtitle(subtitle)
36 |
37 | {:ok,
38 | socket
39 | |> assign(assigns)
40 | |> assign_form(changeset)}
41 | end
42 |
43 | @impl true
44 | def handle_event("validate", %{"subtitle" => subtitle_params}, socket) do
45 | changeset =
46 | socket.assigns.subtitle
47 | |> Library.change_subtitle(subtitle_params)
48 | |> Map.put(:action, :validate)
49 |
50 | {:noreply, assign_form(socket, changeset)}
51 | end
52 |
53 | def handle_event("save", %{"subtitle" => subtitle_params}, socket) do
54 | save_subtitle(socket, socket.assigns.action, subtitle_params)
55 | end
56 |
57 | defp save_subtitle(socket, :edit, subtitle_params) do
58 | case Library.update_subtitle(socket.assigns.subtitle, subtitle_params) do
59 | {:ok, subtitle} ->
60 | notify_parent({:saved, subtitle})
61 |
62 | {:noreply,
63 | socket
64 | |> put_flash(:info, "Subtitle updated successfully")
65 | |> push_patch(to: socket.assigns.patch)}
66 |
67 | {:error, %Ecto.Changeset{} = changeset} ->
68 | {:noreply, assign_form(socket, changeset)}
69 | end
70 | end
71 |
72 | defp save_subtitle(socket, :new, subtitle_params) do
73 | case Library.create_subtitle(socket.assigns.video, subtitle_params) do
74 | {:ok, subtitle} ->
75 | notify_parent({:saved, subtitle})
76 |
77 | {:noreply,
78 | socket
79 | |> put_flash(:info, "Subtitle created successfully")
80 | |> push_patch(to: socket.assigns.patch)}
81 |
82 | {:error, %Ecto.Changeset{} = changeset} ->
83 | {:noreply, assign_form(socket, changeset)}
84 | end
85 | end
86 |
87 | defp assign_form(socket, %Ecto.Changeset{} = changeset) do
88 | assign(socket, :form, to_form(changeset))
89 | end
90 |
91 | defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
92 | end
93 |
--------------------------------------------------------------------------------
/lib/algora_web/live/subtitle_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.SubtitleLive.Index do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Library
5 | alias Algora.Library.Subtitle
6 |
7 | @impl true
8 | def mount(%{"video_id" => video_id}, _session, socket) do
9 | video = Library.get_video!(video_id)
10 |
11 | {:ok,
12 | socket
13 | |> assign(:video, video)
14 | |> stream(:subtitles, Library.list_subtitles(video))}
15 | end
16 |
17 | @impl true
18 | def handle_params(params, _url, socket) do
19 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
20 | end
21 |
22 | defp apply_action(socket, :edit, %{"id" => id}) do
23 | socket
24 | |> assign(:page_title, "Edit Subtitle")
25 | |> assign(:subtitle, Library.get_subtitle!(id))
26 | end
27 |
28 | defp apply_action(socket, :new, _params) do
29 | socket
30 | |> assign(:page_title, "New Subtitle")
31 | |> assign(:subtitle, %Subtitle{})
32 | end
33 |
34 | defp apply_action(socket, :index, _params) do
35 | socket
36 | |> assign(:page_title, "Listing Subtitles")
37 | |> assign(:subtitle, nil)
38 | end
39 |
40 | @impl true
41 | def handle_info({AlgoraWeb.SubtitleLive.FormComponent, {:saved, subtitle}}, socket) do
42 | {:noreply, stream_insert(socket, :subtitles, subtitle)}
43 | end
44 |
45 | @impl true
46 | def handle_event("delete", %{"id" => id}, socket) do
47 | subtitle = Library.get_subtitle!(id)
48 | {:ok, _} = Library.delete_subtitle(subtitle)
49 |
50 | {:noreply, stream_delete(socket, :subtitles, subtitle)}
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/algora_web/live/subtitle_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.header class="p-4">
2 | Subtitles
3 | <:actions>
4 | <.link patch={~p"/videos/#{@video.id}/subtitles/new"}>
5 | <.button>New Subtitle
6 |
7 |
8 |
9 |
10 | <.table
11 | id="subtitles"
12 | rows={@streams.subtitles}
13 | row_click={
14 | fn {_id, subtitle} -> JS.navigate(~p"/videos/#{@video.id}/subtitles/#{subtitle}") end
15 | }
16 | >
17 | <:col :let={{_id, subtitle}} label="Start"><%= subtitle.start %>
18 | <:col :let={{_id, subtitle}} label="End"><%= subtitle.end %>
19 | <:col :let={{_id, subtitle}} label="Body"><%= subtitle.body %>
20 | <:action :let={{_id, subtitle}}>
21 |
22 | <.link navigate={~p"/videos/#{@video.id}/subtitles/#{subtitle}"}>Show
23 |
24 | <.link patch={~p"/videos/#{@video.id}/subtitles/#{subtitle}/edit"}>Edit
25 |
26 | <:action :let={{id, subtitle}}>
27 | <.link
28 | phx-click={JS.push("delete", value: %{id: subtitle.id}) |> hide("##{id}")}
29 | data-confirm="Are you sure?"
30 | >
31 | Delete
32 |
33 |
34 |
35 |
36 | <.modal
37 | :if={@live_action in [:new, :edit]}
38 | id="subtitle-modal"
39 | show
40 | on_cancel={JS.navigate(~p"/videos/#{@video.id}/subtitles")}
41 | >
42 | <.live_component
43 | module={AlgoraWeb.SubtitleLive.FormComponent}
44 | id={@subtitle.id || :new}
45 | title={@page_title}
46 | action={@live_action}
47 | subtitle={@subtitle}
48 | video={@video}
49 | patch={~p"/videos/#{@video.id}/subtitles"}
50 | />
51 |
52 |
--------------------------------------------------------------------------------
/lib/algora_web/live/subtitle_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.SubtitleLive.Show do
2 | use AlgoraWeb, :live_view
3 |
4 | alias Algora.Library
5 |
6 | @impl true
7 | def mount(%{"video_id" => video_id}, _session, socket) do
8 | video = Library.get_video!(video_id)
9 |
10 | {:ok, socket |> assign(:video, video)}
11 | end
12 |
13 | @impl true
14 | def handle_params(%{"id" => id}, _, socket) do
15 | {:noreply,
16 | socket
17 | |> assign(:page_title, page_title(socket.assigns.live_action))
18 | |> assign(:subtitle, Library.get_subtitle!(id))}
19 | end
20 |
21 | defp page_title(:show), do: "Show Subtitle"
22 | defp page_title(:edit), do: "Edit Subtitle"
23 | end
24 |
--------------------------------------------------------------------------------
/lib/algora_web/live/subtitle_live/show.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <.header class="py-4 sm:py-6 lg:py-8">
3 | Subtitle <%= @subtitle.id %>
4 | <:subtitle>This is a subtitle record from your database.
5 | <:actions>
6 | <.link
7 | patch={~p"/videos/#{@video.id}/subtitles/#{@subtitle}/show/edit"}
8 | phx-click={JS.push_focus()}
9 | >
10 | <.button>Edit subtitle
11 |
12 |
13 |
14 |
15 | <.list>
16 | <:item title="Body"><%= @subtitle.body %>
17 | <:item title="Start"><%= @subtitle.start %>
18 | <:item title="End"><%= @subtitle.end %>
19 |
20 |
21 | <.back navigate={~p"/videos/#{@video.id}/subtitles"}>Back to subtitles
22 |
23 | <.modal
24 | :if={@live_action == :edit}
25 | id="subtitle-modal"
26 | show
27 | on_cancel={JS.patch(~p"/videos/#{@video.id}/subtitles/#{@subtitle}")}
28 | >
29 | <.live_component
30 | module={AlgoraWeb.SubtitleLive.FormComponent}
31 | id={@subtitle.id}
32 | title={@page_title}
33 | action={@live_action}
34 | subtitle={@subtitle}
35 | patch={~p"/videos/#{@video.id}/subtitles/#{@subtitle}"}
36 | />
37 |
38 |
39 |
--------------------------------------------------------------------------------
/lib/algora_web/live/tags_component.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.TagsComponent do
2 | use AlgoraWeb, :live_component
3 |
4 | def update(assigns, socket) do
5 | {:ok, assign(socket, id: assigns.id, name: assigns.name, tags: assigns.tags)}
6 | end
7 |
8 | def render(assigns) do
9 | ~H"""
10 |
33 | """
34 | end
35 |
36 | def handle_event("key_pressed", %{"key" => " ", "value" => value}, socket) do
37 | add_tag(socket, value)
38 | end
39 |
40 | def handle_event("key_pressed", _params, socket) do
41 | {:noreply, socket}
42 | end
43 |
44 | def handle_event("remove_tag", %{"tag" => tag}, socket) do
45 | updated_tags = List.delete(socket.assigns.tags, tag)
46 | send(self(), {:update_tags, updated_tags})
47 | {:noreply, assign(socket, tags: updated_tags)}
48 | end
49 |
50 | defp add_tag(socket, value) do
51 | tag = String.trim(value)
52 | current_tags = socket.assigns.tags
53 |
54 | if tag !== "" and tag not in current_tags do
55 | updated_tags = current_tags ++ [tag]
56 | send(self(), {:update_tags, updated_tags})
57 | {:noreply,
58 | socket
59 | |> assign(tags: updated_tags)
60 | |> push_event("tag_added", %{})}
61 | else
62 | {:noreply, socket}
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/lib/algora_web/plugs/allow_iframe.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Plugs.AllowIframe do
2 | import Plug.Conn
3 | def init(_), do: %{}
4 |
5 | def call(conn, _opts) do
6 | put_resp_header(
7 | conn,
8 | "content-security-policy",
9 | "frame-ancestors *"
10 | )
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/algora_web/plugs/transform_docs.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Plugs.TransformDocs do
2 | def init(options), do: options
3 |
4 | def call(%Plug.Conn{} = conn, _ \\ []) do
5 | conn
6 | |> Plug.Conn.register_before_send(&transform_body/1)
7 | |> Plug.Conn.update_req_header("accept-encoding", "identity", fn _ -> "identity" end)
8 | end
9 |
10 | def transform_body(%Plug.Conn{} = conn) do
11 | proxy_url = Algora.config([:docs, :url])
12 |
13 | body =
14 | if conn.resp_body do
15 | conn.resp_body
16 | |> String.replace("src=\"/", "src=\"#{proxy_url}/")
17 | |> String.replace("href=\"/", "href=\"#{proxy_url}/")
18 | |> String.replace("#{proxy_url}", AlgoraWeb.Endpoint.url())
19 | end
20 |
21 | %Plug.Conn{conn | resp_body: body}
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/algora_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.stop.duration",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.router_dispatch.stop.duration",
29 | tags: [:route],
30 | unit: {:native, :millisecond}
31 | ),
32 |
33 | # Database Metrics
34 | summary("algora.repo.query.total_time",
35 | unit: {:native, :millisecond},
36 | description: "The sum of the other measurements"
37 | ),
38 | summary("algora.repo.query.decode_time",
39 | unit: {:native, :millisecond},
40 | description: "The time spent decoding the data received from the database"
41 | ),
42 | summary("algora.repo.query.query_time",
43 | unit: {:native, :millisecond},
44 | description: "The time spent executing the query"
45 | ),
46 | summary("algora.repo.query.queue_time",
47 | unit: {:native, :millisecond},
48 | description: "The time spent waiting for a database connection"
49 | ),
50 | summary("algora.repo.query.idle_time",
51 | unit: {:native, :millisecond},
52 | description:
53 | "The time the connection spent waiting before being checked out for the query"
54 | ),
55 |
56 | # VM Metrics
57 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
58 | summary("vm.total_run_queue_lengths.total"),
59 | summary("vm.total_run_queue_lengths.cpu"),
60 | summary("vm.total_run_queue_lengths.io")
61 | ]
62 | end
63 |
64 | defp periodic_measurements do
65 | [
66 | # A module, function and arguments to be invoked periodically.
67 | # This function must call :telemetry.execute/3 and a metric must be added above.
68 | # {AlgoraWeb, :count_users, []}
69 | ]
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/algora_web/websockets/chat_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.Websockets.ChatSocket do
2 | @moduledoc """
3 | `Phoenix.Socket.Transport` implementation for sending chat messages
4 | to the client connection.
5 | """
6 |
7 | @behaviour Phoenix.Socket.Transport
8 |
9 | require Logger
10 |
11 | alias Algora.{Accounts, Library, Chat}
12 |
13 | @base_mount_path "/chat"
14 |
15 | @spec base_mount_path :: String.t()
16 | def base_mount_path, do: @base_mount_path
17 |
18 | @impl true
19 | def child_spec(_opts) do
20 | %{id: Task, start: {Task, :start_link, [fn -> :ok end]}, restart: :transient}
21 | end
22 |
23 | @impl true
24 | def connect(%{params: %{"channel_handle" => channel_handle}}) do
25 | {:ok, %{channel_handle: channel_handle}}
26 | end
27 |
28 | @impl true
29 | def init(%{channel_handle: channel_handle}) do
30 | user = Accounts.get_user_by!(handle: channel_handle)
31 | channel = Library.get_channel!(user)
32 | video = Library.get_latest_video(user)
33 |
34 | Library.subscribe_to_channel(channel)
35 | if video, do: Chat.subscribe_to_room(video)
36 |
37 | {:ok, %{video: video}}
38 | end
39 |
40 | @impl true
41 | def handle_in({_message, _opts}, state) do
42 | {:ok, state}
43 | end
44 |
45 | @impl true
46 | def handle_info({Chat, %Chat.Events.MessageSent{message: message}}, state) do
47 | {:push, {:text, Jason.encode!(message)}, state}
48 | end
49 |
50 | def handle_info(
51 | {Library, %Library.Events.LivestreamStarted{video: video}},
52 | state
53 | ) do
54 | if state.video, do: Chat.unsubscribe_to_room(state.video)
55 | Chat.subscribe_to_room(video)
56 | {:ok, %{video: video}}
57 | end
58 |
59 | def handle_info(_message, state) do
60 | {:ok, state}
61 | end
62 |
63 | @impl true
64 | def terminate(_reason, _state) do
65 | :ok
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/observer:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # After opening a Wireguard connection to your Fly network, run this script to
4 | # open a BEAM Observer from your local machine to the remote server. This creates
5 | # a local node that is clustered to a machine running on Fly.
6 |
7 | # In order for it to work:
8 | # - Your wireguard connection must be up.
9 | # - The RELEASE_COOKIE value must be the same as the cookie value used for your project.
10 | # - Observer needs to be working in your local environment. That requires WxWidget support in your Erlang install.
11 |
12 | # When done, close Observer. It leaves you with an open IEx shell that is connected to the remote server. You can safely CTRL+C, CTRL+C to exit it.
13 |
14 | set -e
15 |
16 | if [ -z "$RELEASE_COOKIE" ]; then
17 | echo "Set the RELEASE_COOKIE your project uses in the RELEASE_COOKIE ENV value before running this script."
18 | exit 1
19 | fi
20 |
21 | if ! command -v jq &> /dev/null; then
22 | echo "jq is not installed. Please install it before running this script. It is a command-line JSON processor."
23 | exit 1
24 | fi
25 |
26 | # Get the data we need in JSON format
27 | json_data=$(fly status --json)
28 |
29 | # Extract app name
30 | app_name=$(echo "$json_data" | jq -r '.Name')
31 |
32 | # Extract private_ip for the first started machine
33 | private_ip=$(echo "$json_data" | jq -r '.Machines[] | select(.state == "started") | .private_ip' | head -n 1)
34 |
35 | if [ -z "$private_ip" ]; then
36 | echo "No instances appear to be running at this time."
37 | exit 1
38 | fi
39 |
40 | # Assemble the full node name
41 | FULL_NODE_NAME="${app_name}@${private_ip}"
42 | echo Attempting to connect to $FULL_NODE_NAME
43 |
44 | # IMPORTANT:
45 | # ==========
46 | # Fly.io uses an IPv6 network internally for private IPs. The BEAM needs IPv6
47 | # support to be enabled explicitly.
48 | #
49 | # The issue is, if it's enabled globally like in a `.bashrc` file, then setting
50 | # it here essentially flips it OFF. If not set globally, then it should be set
51 | # here. Choose the version that fits your situation.
52 | #
53 | # It's the `--erl "-proto_dist inet6_tcp"` portion.
54 |
55 | # Toggles on IPv6 support for the local node being started.
56 | iex --erl "-proto_dist inet6_tcp" --sname my_remote --cookie ${RELEASE_COOKIE} -e "IO.inspect(Node.connect(:'${FULL_NODE_NAME}'), label: \"Node Connected?\"); IO.inspect(Node.list(), label: \"Connected Nodes\"); :observer.start"
57 |
58 | # Does NOT toggle on IPv6 support, assuming it is enabled some other way.
59 | # iex --sname my_remote --cookie ${RELEASE_COOKIE} -e "IO.inspect(Node.connect(:'${FULL_NODE_NAME}'), label: \"Node Connected?\"); IO.inspect(Node.list(), label: \"Connected Nodes\"); :observer.start"
60 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240229191000_init_oban.exs:
--------------------------------------------------------------------------------
1 | defmodule MyApp.Repo.Migrations.InitOban do
2 | use Ecto.Migration
3 |
4 | def up do
5 | Oban.Migration.up(version: 11)
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.Migration.down(version: 1)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240229191100_init_core.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.InitCore do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute "CREATE EXTENSION IF NOT EXISTS citext", ""
6 |
7 | create table(:users) do
8 | add :email, :citext, null: false
9 | add :name, :string
10 | add :handle, :citext, null: false
11 | add :channel_tagline, :string
12 | add :avatar_url, :string
13 | add :external_homepage_url, :string
14 | add :videos_count, :integer, null: false, default: 0
15 | add :is_live, :boolean, null: false, default: false
16 | add :stream_key, :string
17 | add :visibility, :integer, null: false, default: 1
18 | add :bounties_count, :integer
19 | add :tech, :map
20 | add :orgs_contributed, :map
21 |
22 | timestamps()
23 | end
24 |
25 | create unique_index(:users, [:email])
26 | create unique_index(:users, [:handle])
27 |
28 | create table(:identities) do
29 | add :user_id, references(:users, on_delete: :delete_all), null: false
30 | add :provider, :string, null: false
31 | add :provider_token, :string, null: false
32 | add :provider_email, :string, null: false
33 | add :provider_login, :string, null: false
34 | add :provider_id, :string, null: false
35 | add :provider_meta, :map, default: "{}", null: false
36 |
37 | timestamps()
38 | end
39 |
40 | create index(:identities, [:user_id])
41 | create index(:identities, [:provider])
42 | create unique_index(:identities, [:user_id, :provider])
43 |
44 | create table(:videos) do
45 | add :duration, :integer, default: 0, null: false
46 | add :title, :string, null: false
47 | add :type, :integer, null: false
48 | add :is_live, :boolean, null: false, default: false
49 | add :thumbnails_ready, :boolean, null: false, default: false
50 | add :url, :string, null: false
51 | add :url_root, :string
52 | add :uuid, :string
53 | add :visibility, :integer, null: false, default: 1
54 | add :user_id, references(:users, on_delete: :nothing)
55 |
56 | timestamps()
57 | end
58 |
59 | create index(:videos, [:user_id])
60 |
61 | create table(:messages) do
62 | add :body, :text
63 | add :user_id, references(:users, on_delete: :nothing)
64 | add :video_id, references(:videos, on_delete: :nothing)
65 |
66 | timestamps()
67 | end
68 |
69 | create index(:messages, [:user_id])
70 | create index(:messages, [:video_id])
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240304213317_create_subtitles.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateSubtitles do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:subtitles) do
6 | add :body, :text
7 | add :start, :float
8 | add :end, :float
9 | add :video_id, references(:videos, on_delete: :nothing), null: false
10 |
11 | timestamps()
12 | end
13 |
14 | create index(:subtitles, [:video_id])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240306163651_add_thumbnail_url_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.AddThumbnailUrlToVideo do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table("videos") do
6 | add :thumbnail_url, :string
7 | end
8 |
9 | execute "update videos set thumbnail_url = format('%s/index.jpeg', url_root) where url_root is not null and thumbnails_ready = 't'"
10 |
11 | execute "update videos set thumbnail_url = format('https://i.ytimg.com/vi/%s/hqdefault.jpg', substr(url,29)) where url like 'https://youtube.com/watch?v=%'"
12 | end
13 |
14 | def down do
15 | alter table("videos") do
16 | remove :thumbnail_url
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240306165832_remove_thumbnails_ready_from_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.RemoveThumbnailsReadyFromVideo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("videos") do
6 | remove :thumbnails_ready
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240306230124_add_vertical_thumbnail_url_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.AddVerticalThumbnailUrlToVideo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("videos") do
6 | add :vertical_thumbnail_url, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240310170243_add_fly_postgres_proc.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.AddFlyPostgresProc do
2 | use Ecto.Migration
3 |
4 | def up do
5 | Fly.Postgres.Migrations.V01.up()
6 | end
7 |
8 | def down do
9 | Fly.Postgres.Migrations.V01.down()
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240330185918_add_format_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddFormatToVideo do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table("videos") do
6 | add :format, :integer
7 | end
8 |
9 | execute "update videos set format = 1 where type = 1 and url not like 'https://youtube.com/watch?v=%'"
10 |
11 | execute "update videos set format = 2 where type = 2"
12 |
13 | execute "update videos set format = 3 where type = 1 and url like 'https://youtube.com/watch?v=%'"
14 |
15 | alter table("videos") do
16 | modify :format, :integer, null: false
17 | end
18 | end
19 |
20 | def down do
21 | alter table("videos") do
22 | remove :format
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240331001729_add_transmuxed_from_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.AddTransmuxedFromToVideo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:videos) do
6 | add :transmuxed_from_id, references(:videos, on_delete: :nothing)
7 | end
8 |
9 | create index(:videos, [:transmuxed_from_id])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240331022336_add_filename_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddFilenameToVideo do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table("videos") do
6 | add :filename, :string
7 | end
8 |
9 | execute "update videos set filename = replace(url, format('%s/', url_root), '') where url_root is not null"
10 | end
11 |
12 | def down do
13 | alter table("videos") do
14 | remove :filename
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240331181838_add_paths_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddPathsToVideo do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table("videos") do
6 | add :remote_path, :string
7 | add :local_path, :string
8 | end
9 |
10 | execute "update videos set remote_path = format('%s/%s', uuid, filename) where filename is not null"
11 | end
12 |
13 | def down do
14 | alter table("videos") do
15 | remove :remote_path
16 | remove :local_path
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240401001340_make_video_url_nullable.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.MakeVideoUrlNullable do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("videos") do
6 | modify :url, :string, null: true
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240401075738_add_description_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddDescriptionToVideo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("videos") do
6 | add :description, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240408000415_create_segments.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.CreateSegments do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:segments) do
6 | add :body, :text
7 | add :start, :float
8 | add :end, :float
9 | add :embedding, :map
10 | add :starting_subtitle_id, references(:subtitles, on_delete: :nothing), null: false
11 | add :ending_subtitle_id, references(:subtitles, on_delete: :nothing), null: false
12 | add :video_id, references(:videos, on_delete: :nothing), null: false
13 |
14 | timestamps()
15 | end
16 |
17 | create index(:segments, [:video_id])
18 | create index(:segments, [:starting_subtitle_id])
19 | create index(:segments, [:ending_subtitle_id])
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240509132227_add_og_image_url_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddOgImageUrlToVideo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("videos") do
6 | add :og_image_url, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240510083721_add_solving_challenge_to_user.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddSolvingChallengeToUser do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("users") do
6 | add :solving_challenge, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240519130701_create_destinations.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.CreateDestinations do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:destinations) do
6 | add :rtmp_url, :string, null: false
7 | add :stream_key, :string, null: false
8 | add :active, :boolean, default: true, null: false
9 | add :user_id, references(:users, on_delete: :delete_all), null: false
10 |
11 | timestamps()
12 | end
13 |
14 | create index(:destinations, [:user_id])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240522125826_add_provider_refresh_token_to_identities.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddProviderRefreshTokenToIdentities do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("identities") do
6 | add :provider_refresh_token, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240522183524_create_entities.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.CreateEntities do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:entities) do
6 | add :user_id, references(:users)
7 | add :name, :string
8 | add :handle, :citext, null: false
9 | add :avatar_url, :string
10 | add :platform, :string, null: false
11 | add :platform_id, :string, null: false
12 | add :platform_meta, :map, default: "{}", null: false
13 |
14 | timestamps()
15 | end
16 |
17 | create unique_index(:entities, [:platform, :platform_id])
18 | create unique_index(:entities, [:platform, :handle])
19 | create index(:entities, [:platform])
20 | create index(:entities, [:platform_id])
21 | create index(:entities, [:handle])
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240522183934_modify_messages_for_entities.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.ModifyMessagesForEntities do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:messages) do
6 | add :entity_id, references(:entities)
7 | end
8 |
9 | execute """
10 | INSERT INTO entities (user_id, name, handle, avatar_url, platform, platform_id, platform_meta, inserted_at, updated_at)
11 | SELECT id, name, handle, avatar_url, 'algora', id::text, '{}', NOW(), NOW()
12 | FROM users
13 | """
14 |
15 | execute """
16 | UPDATE messages
17 | SET entity_id = entities.id
18 | FROM entities
19 | WHERE messages.user_id = entities.user_id
20 | """
21 |
22 | alter table("messages") do
23 | modify :entity_id, :integer, null: false
24 | end
25 |
26 | create index(:messages, [:entity_id])
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240523135647_create_events.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.CreateEvents do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:events) do
6 | add :actor_id, :string, null: false
7 | add :user_id, references(:users)
8 | add :video_id, references(:videos)
9 | add :channel_id, references(:users)
10 | add :name, :string, null: false
11 |
12 | timestamps()
13 | end
14 |
15 | create index(:events, [:actor_id])
16 | create index(:events, [:user_id])
17 | create index(:events, [:video_id])
18 | create index(:events, [:channel_id])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240524154930_create_shows.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateShows do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:shows) do
6 | add :title, :string, null: false
7 | add :description, :string
8 | add :slug, :citext, null: false
9 | add :scheduled_for, :naive_datetime
10 | add :image_url, :string
11 | add :og_image_url, :string
12 | add :url, :string
13 | add :user_id, references(:users, on_delete: :nothing)
14 |
15 | timestamps()
16 | end
17 |
18 | alter table(:events) do
19 | add :show_id, references(:shows)
20 | end
21 |
22 | alter table(:videos) do
23 | add :show_id, references(:shows)
24 | end
25 |
26 | alter table(:users) do
27 | add :twitter_url, :string
28 | end
29 |
30 | create unique_index(:shows, [:slug])
31 | create index(:shows, [:user_id])
32 | create index(:events, [:show_id])
33 | create index(:videos, [:show_id])
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240530121830_add_ordering_to_show.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddOrderingToShow do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("shows") do
6 | add :ordering, :integer
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240602220449_update_show_description_type.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.UpdateShowDescriptionType do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("shows") do
6 | modify :description, :text
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240603112913_cascade_video_deletion.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.CascadeVideoDeletion do
2 | use Ecto.Migration
3 |
4 | def change do
5 | drop constraint(:messages, :messages_video_id_fkey)
6 | drop constraint(:events, :events_video_id_fkey)
7 |
8 | alter table(:messages) do
9 | modify :video_id, references(:videos, on_delete: :delete_all)
10 | end
11 |
12 | alter table(:events) do
13 | modify :video_id, references(:videos, on_delete: :delete_all)
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240719060446_add_platform_id_to_message.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddPlatformIdToMessage do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("messages") do
6 | add :platform_id, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240729183655_create_ads.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateAds do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:ads) do
6 | add :verified, :boolean, default: false, null: false
7 | add :website_url, :string
8 | add :composite_asset_url, :string
9 | add :asset_url, :string
10 | add :logo_url, :string
11 | add :qrcode_url, :string
12 | add :start_date, :naive_datetime
13 | add :end_date, :naive_datetime
14 | add :total_budget, :integer
15 | add :daily_budget, :integer
16 | add :tech_stack, {:array, :string}
17 | add :click_count, :integer
18 | add :status, :string
19 | add :user_id, references(:users, on_delete: :nothing)
20 |
21 | timestamps()
22 | end
23 |
24 | create index(:ads, [:user_id])
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240729183917_create_ad_impressions.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateAdImpressions do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:ad_impressions) do
6 | add :duration, :integer
7 | add :viewers_count, :integer
8 | add :ad_id, references(:ads, on_delete: :nothing)
9 | add :video_id, references(:videos, on_delete: :nothing)
10 |
11 | timestamps()
12 | end
13 |
14 | create index(:ad_impressions, [:ad_id])
15 | create index(:ad_impressions, [:video_id])
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240730000856_create_ad_visits.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateAdVisits do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:ad_visits) do
6 | add :ad_id, references(:ads, on_delete: :nothing)
7 | add :video_id, references(:videos, on_delete: :nothing)
8 |
9 | timestamps()
10 | end
11 |
12 | alter table(:ads) do
13 | remove :click_count
14 | end
15 |
16 | create index(:ad_visits, [:ad_id])
17 | create index(:ad_visits, [:video_id])
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240730160846_create_contact_info.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateContactInfo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:contact_info) do
6 | add :email, :string
7 | add :website_url, :string
8 | add :revenue, :string
9 | add :company_location, :string
10 |
11 | timestamps()
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240802162318_add_slug_to_ads.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddSlugToAds do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:ads) do
6 | add :slug, :string
7 | end
8 |
9 | create unique_index(:ads, [:slug])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240805035256_add_border_color_to_ads.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddBorderColorToAds do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:ads) do
6 | add :border_color, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240809163713_create_ad_related_tables.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Migrations.CreateAdRelatedTables do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:product_reviews) do
6 | add :clip_from, :integer, null: false
7 | add :clip_to, :integer, null: false
8 | add :thumbnail_url, :string
9 | add :ad_id, references(:ads, on_delete: :nothing), null: false
10 | add :video_id, references(:videos, on_delete: :nothing), null: false
11 |
12 | timestamps()
13 | end
14 |
15 | create index(:product_reviews, [:ad_id])
16 | create index(:product_reviews, [:video_id])
17 |
18 | create table(:ad_appearances) do
19 | add :airtime, :integer, null: false
20 | add :ad_id, references(:ads, on_delete: :nothing), null: false
21 | add :video_id, references(:videos, on_delete: :nothing), null: false
22 |
23 | timestamps()
24 | end
25 |
26 | create index(:ad_appearances, [:ad_id])
27 | create index(:ad_appearances, [:video_id])
28 |
29 | create table(:content_metrics) do
30 | add :algora_stream_url, :string
31 | add :twitch_stream_url, :string
32 | add :youtube_video_url, :string
33 | add :twitter_video_url, :string
34 | add :twitch_avg_concurrent_viewers, :integer
35 | add :twitch_views, :integer
36 | add :youtube_views, :integer
37 | add :twitter_views, :integer
38 | add :video_id, references(:videos, on_delete: :nothing), null: false
39 |
40 | timestamps()
41 | end
42 |
43 | create index(:content_metrics, [:video_id])
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240814021502_add_name_to_ads.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddNameToAds do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:ads) do
6 | add :name, :string
7 | end
8 |
9 | execute "UPDATE ads SET name = INITCAP(slug)"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240814024335_update_content_metrics_fields.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.UpdateContentMetricsFields do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute "UPDATE content_metrics SET twitch_avg_concurrent_viewers = COALESCE(twitch_avg_concurrent_viewers, 0)"
6 | execute "UPDATE content_metrics SET twitch_views = COALESCE(twitch_views, 0)"
7 | execute "UPDATE content_metrics SET youtube_views = COALESCE(youtube_views, 0)"
8 | execute "UPDATE content_metrics SET twitter_views = COALESCE(twitter_views, 0)"
9 |
10 | alter table(:content_metrics) do
11 | modify :twitch_avg_concurrent_viewers, :integer, null: false, default: 0, from: :integer
12 | modify :twitch_views, :integer, null: false, default: 0, from: :integer
13 | modify :youtube_views, :integer, null: false, default: 0, from: :integer
14 | modify :twitter_views, :integer, null: false, default: 0, from: :integer
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240814033358_add_og_image_url_to_ads.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddOgImageUrlToAds do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:ads) do
6 | add :og_image_url, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240831185906_add_corrupted_to_video.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddCorruptedToVideo do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:videos) do
6 | add :corrupted, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240903140540_add_deleted_at_to_videos.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddDeletedAtToVideos do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:videos) do
6 | add :deleted_at, :naive_datetime
7 | end
8 |
9 | create index(:videos, [:deleted_at])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240910030054_add_featured_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddFeaturedToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :featured, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240910150315_add_tags_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddTagsToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :tags, {:array, :string}
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240910150515_add_tags_to_videos.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddTagsToVideos do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:videos) do
6 | add :tags, {:array, :string}
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240926172045_rename_composite_asset_url_to_composite_asset_urls.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.RenameCompositeAssetUrlToCompositeAssetUrls do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table(:ads) do
6 | add :composite_asset_urls, {:array, :string}, default: []
7 | end
8 |
9 | execute """
10 | UPDATE ads
11 | SET composite_asset_urls = ARRAY[composite_asset_url]
12 | WHERE composite_asset_url IS NOT NULL
13 | """
14 |
15 | alter table(:ads) do
16 | remove :composite_asset_url
17 | end
18 | end
19 |
20 | def down do
21 | alter table(:ads) do
22 | add :composite_asset_url, :string
23 | end
24 |
25 | execute """
26 | UPDATE ads
27 | SET composite_asset_url = composite_asset_urls[1]
28 | WHERE composite_asset_urls IS NOT NULL
29 | """
30 |
31 | alter table(:ads) do
32 | remove :composite_asset_urls
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240926215228_add_video_thumbnails_table.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddVideoThumbnailsTable do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:video_thumbnails) do
6 | add :minutes, :integer, null: false
7 | add :thumbnail_url, :string, null: false
8 | add :video_id, references(:videos, on_delete: :nothing), null: false
9 |
10 | timestamps()
11 | end
12 |
13 | create index(:video_thumbnails, [:video_id])
14 | create index(:video_thumbnails, [:video_id, :minutes], unique: true)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20241211205543_add_gin_index_to_tags.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.AddGinIndexToTags do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute("create index users_tags_index on users using gin (tags);")
6 | execute("create index videos_tags_index on videos using gin (tags);")
7 | end
8 |
9 | def down do
10 | execute("drop index users_tags_index;")
11 | execute("drop index videos_tags_index;")
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20241217005445_convert_token_fields_to_text.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.Repo.Local.Migrations.ConvertTokenFieldsToText do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:identities) do
6 | modify :provider_token, :text, from: :string
7 | modify :provider_refresh_token, :text, from: :string
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/favicon.ico
--------------------------------------------------------------------------------
/priv/static/images/analytics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/analytics.png
--------------------------------------------------------------------------------
/priv/static/images/elixir.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/elixir.png
--------------------------------------------------------------------------------
/priv/static/images/in-video-ad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/in-video-ad.png
--------------------------------------------------------------------------------
/priv/static/images/live-billboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/live-billboard.png
--------------------------------------------------------------------------------
/priv/static/images/logo-1200px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/logo-1200px.png
--------------------------------------------------------------------------------
/priv/static/images/logo-192px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/logo-192px.png
--------------------------------------------------------------------------------
/priv/static/images/logo-512px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/logo-512px.png
--------------------------------------------------------------------------------
/priv/static/images/logo-gradient.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/logo-gradient.png
--------------------------------------------------------------------------------
/priv/static/images/og/cossgpt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/og/cossgpt.png
--------------------------------------------------------------------------------
/priv/static/images/og/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/og/default.png
--------------------------------------------------------------------------------
/priv/static/images/og/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/og/home.png
--------------------------------------------------------------------------------
/priv/static/images/og/partner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/og/partner.png
--------------------------------------------------------------------------------
/priv/static/images/partner-demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/partner-demo.png
--------------------------------------------------------------------------------
/priv/static/images/shows/build-in-public.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/build-in-public.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/coding-challenges.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/coding-challenges.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/coss-founder-podcast.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/coss-founder-podcast.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/coss-office-hours.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/coss-office-hours.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/eu-acc.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/eu-acc.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/live-bounty-hunting.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/live-bounty-hunting.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/request-for-comments.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/request-for-comments.jpg
--------------------------------------------------------------------------------
/priv/static/images/shows/the-save-file.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/shows/the-save-file.jpg
--------------------------------------------------------------------------------
/priv/static/images/sponsored-stream.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/algora-io/tv/9e26f2753ccc32b4c6b468e6fe21304739677f37/priv/static/images/sponsored-stream.png
--------------------------------------------------------------------------------
/priv/static/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Algora TV",
3 | "name": "Algora TV",
4 | "icons": [
5 | {
6 | "src": "/images/logo-192px.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/images/logo-512px.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "start_url": "/",
17 | "display": "fullscreen",
18 | "theme_color": "#5533bd",
19 | "background_color": "#5533bd"
20 | }
21 |
--------------------------------------------------------------------------------
/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/rel/env.sh.eex:
--------------------------------------------------------------------------------
1 | ip=$(grep fly-local-6pn /etc/hosts | cut -f 1)
2 | export RELEASE_DISTRIBUTION=name
3 | export RELEASE_NODE=$FLY_APP_NAME@$ip
4 | export ERL_CRASH_DUMP=/dev/stderr
5 | export ERL_CRASH_DUMP_BYTES=8192
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | exec ./algora eval Algora.Release.migrate
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate.bat:
--------------------------------------------------------------------------------
1 | call "%~dp0\algora" eval Algora.Release.migrate
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | PHX_SERVER=true exec ./algora start
4 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\algora" start
--------------------------------------------------------------------------------
/scripts/backfill.livemd:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Notebook
4 |
5 | ```elixir
6 | Mix.install([
7 | {:kino, "~> 0.12.0"},
8 | {:jason, "~> 1.4"}
9 | ])
10 | ```
11 |
12 | ## Section
13 |
14 | ```elixir
15 | data =
16 | [1, 2, 3]
17 | |> Enum.map(&"by-ids-#{&1}.json")
18 | |> Enum.flat_map(fn path ->
19 | Kino.FS.file_path(path)
20 | |> File.read!()
21 | |> Jason.decode!()
22 | |> then(& &1["items"])
23 | end)
24 |
25 | data |> Enum.take(6) |> Kino.Tree.new()
26 | ```
27 |
28 | ```elixir
29 | defmodule Video do
30 | def parse_duration(""), do: 0
31 |
32 | def parse_duration(x) do
33 | {duration, ""} = Integer.parse(x)
34 | duration
35 | end
36 |
37 | def backfill(video) do
38 | %{"hours" => hours, "minutes" => minutes, "seconds" => seconds} =
39 | Regex.named_captures(
40 | ~r/^PT((?
\d+)H)?((?\d+)M)?((?\d+)S)?$/,
41 | video["contentDetails"]["duration"]
42 | )
43 |
44 | duration =
45 | 3600 * parse_duration(hours) +
46 | 60 * parse_duration(minutes) +
47 | 1 * parse_duration(seconds)
48 |
49 | thumbnail_url =
50 | video["snippet"]["thumbnails"]
51 | |> Map.values()
52 | |> Enum.sort_by(& &1["height"], :desc)
53 | |> Enum.at(0)
54 | |> then(& &1["url"])
55 |
56 | url = "https://youtube.com/watch?v=#{video["id"]}"
57 |
58 | title = video["snippet"]["title"]
59 |
60 | "{1, nil} = Repo.update_all(from(v in Library.Video, where: v.url == \"#{url}\"), set: [title: \"#{title}\", duration: #{duration}, thumbnail_url: \"#{thumbnail_url}\"])"
61 | end
62 | end
63 | ```
64 |
65 | ```elixir
66 | data |> Enum.map(&Video.backfill/1) |> Enum.join("\n") |> IO.puts()
67 | ```
68 |
--------------------------------------------------------------------------------
/test/algora/ads_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Algora.AdsTest do
2 | use Algora.DataCase
3 |
4 | alias Algora.Ads
5 |
6 | describe "next_slot/1" do
7 | test "returns the next 30-minute slot" do
8 | # Test case 1: Exactly at the start of a slot
9 | time = ~U[2024-08-03 10:00:00.000Z]
10 | assert Ads.next_slot(time) == ~U[2024-08-03 10:30:00.000Z]
11 |
12 | # Test case 2: In the middle of a slot
13 | time = ~U[2024-08-03 10:15:30.123Z]
14 | assert Ads.next_slot(time) == ~U[2024-08-03 10:30:00.000Z]
15 |
16 | # Test case 3: Just before the next slot
17 | time = ~U[2024-08-03 10:29:59.999Z]
18 | assert Ads.next_slot(time) == ~U[2024-08-03 10:30:00.000Z]
19 |
20 | # Test case 4: Crossing an hour boundary
21 | time = ~U[2024-08-03 10:55:00.123Z]
22 | assert Ads.next_slot(time) == ~U[2024-08-03 11:00:00.000Z]
23 |
24 | # Test case 5: Crossing a day boundary
25 | time = ~U[2024-08-03 23:55:00.123Z]
26 | assert Ads.next_slot(time) == ~U[2024-08-04 00:00:00.000Z]
27 | end
28 |
29 | test "uses current time when no argument is provided" do
30 | assert Ads.next_slot() == Ads.next_slot(DateTime.utc_now())
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/test/algora/pipeline/README.md:
--------------------------------------------------------------------------------
1 | # Algora Media Processing Pipeline
2 |
3 | The code in this directory is built upon the work from the [Membrane Framework](https://github.com/membraneframework). Their efforts provided a robust starting point, and we have made modifications and additions to better suit our project’s objectives.
4 |
5 | We would like to explicitly acknowledge and thank the authors and maintainers of the Membrane Framework for making their work available to the community under an open source license. They have laid the groundwork that enabled us to build and innovate further.
6 |
7 | ## License
8 |
9 | This subdirectory, as with the rest of the codebase, is licensed under the terms of the [AGPLv3 License](https://github.com/algora-io/tv/blob/main/LICENSE). The original codebase and plugins can be found [here](https://github.com/membraneframework/membrane_core), which are licensed under the [Apache License 2.0](https://github.com/membraneframework/membrane_core/blob/master/LICENSE).
10 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule AlgoraWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` 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 AlgoraWeb.ConnCase, 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 | # The default endpoint for testing
23 | @endpoint AlgoraWeb.Endpoint
24 |
25 | use AlgoraWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import AlgoraWeb.ConnCase
31 | end
32 | end
33 |
34 | setup tags do
35 | Algora.DataCase.setup_sandbox(tags)
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Algora.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 Algora.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 Algora.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import Algora.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | Algora.DataCase.setup_sandbox(tags)
32 | :ok
33 | end
34 |
35 | @doc """
36 | Sets up the sandbox based on the test tags.
37 | """
38 | def setup_sandbox(tags) do
39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Algora.Repo, shared: not tags[:async])
40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
41 | end
42 |
43 | @doc """
44 | A helper that transforms changeset errors into a map of messages.
45 |
46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
47 | assert "password is too short" in errors_on(changeset).password
48 | assert %{password: ["password is too short"]} = errors_on(changeset)
49 |
50 | """
51 | def errors_on(changeset) do
52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
53 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
55 | end)
56 | end)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 |
2 | ExUnit.start(capture_log: true)
3 |
4 | Ecto.Adapters.SQL.Sandbox.mode(Algora.Repo, :manual)
5 |
--------------------------------------------------------------------------------