├── .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 | 3 | 4 | 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 | 33 | 34 | 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 | 28 | 29 | 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 |