From 2d94f5e9b007740e837d2bcd8541a565a8e644b9 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Tue, 18 Jun 2024 22:00:46 +0200 Subject: [PATCH] feat: Add job to download captions (#417) --- apps/cf/config/config.exs | 19 --- apps/cf/config/test.exs | 3 - apps/cf/lib/videos/captions_fetcher.ex | 3 +- apps/cf/lib/videos/captions_fetcher_test.ex | 13 +- .../cf/lib/videos/captions_fetcher_youtube.ex | 130 +++++++++++++++--- apps/cf/lib/videos/videos.ex | 41 +++++- apps/cf/test/videos/videos_test.exs | 10 +- apps/cf_jobs/config/config.exs | 11 ++ apps/cf_jobs/lib/application.ex | 4 +- apps/cf_jobs/lib/jobs/download_captions.ex | 66 +++++++++ apps/db/lib/db_schema/users_actions_report.ex | 1 + apps/db/lib/db_schema/video_caption.ex | 5 +- .../20240618055503_update_video_captions.exs | 24 ++++ mix.lock | 13 +- 14 files changed, 288 insertions(+), 55 deletions(-) create mode 100644 apps/cf_jobs/lib/jobs/download_captions.ex create mode 100644 apps/db/priv/repo/migrations/20240618055503_update_video_captions.exs diff --git a/apps/cf/config/config.exs b/apps/cf/config/config.exs index a6a864dd..05b44a01 100644 --- a/apps/cf/config/config.exs +++ b/apps/cf/config/config.exs @@ -14,25 +14,6 @@ config :cf, soft_limitations_period: 15 * 60, hard_limitations_period: 3 * 60 * 60 -# Configure scheduler -config :cf, CF.Scheduler, - # Run only one instance across cluster - global: true, - debug_logging: false, - jobs: [ - # credo:disable-for-lines:10 - # Actions analysers - # Every minute - {"*/1 * * * *", {CF.Jobs.Reputation, :update, []}}, - # Every day - {"@daily", {CF.Jobs.Reputation, :reset_daily_limits, []}}, - # Every minute - {"*/1 * * * *", {CF.Jobs.Flags, :update, []}}, - # Various updaters - # Every 5 minutes - {"*/5 * * * *", {CF.Jobs.Moderation, :update, []}} - ] - # Configure mailer config :cf, CF.Mailer, adapter: Bamboo.MailgunAdapter diff --git a/apps/cf/config/test.exs b/apps/cf/config/test.exs index 306ecf23..1a6cb8b6 100644 --- a/apps/cf/config/test.exs +++ b/apps/cf/config/test.exs @@ -14,9 +14,6 @@ config :cf, # Print only warnings and errors during test config :logger, level: :warn -# Disable CRON tasks on test -config :cf, CF.Scheduler, jobs: [] - # Mails config :cf, CF.Mailer, adapter: Bamboo.TestAdapter diff --git a/apps/cf/lib/videos/captions_fetcher.ex b/apps/cf/lib/videos/captions_fetcher.ex index 649d294f..597176db 100644 --- a/apps/cf/lib/videos/captions_fetcher.ex +++ b/apps/cf/lib/videos/captions_fetcher.ex @@ -3,5 +3,6 @@ defmodule CF.Videos.CaptionsFetcher do Fetch captions for videos. """ - @callback fetch(DB.Schema.Video.t()) :: {:ok, DB.Schema.VideoCaption.t()} | {:error, binary()} + @callback fetch(DB.Schema.Video.t()) :: + {:ok, %{raw: String.t(), parsed: String.t(), format: String.t()}} | {:error, term()} end diff --git a/apps/cf/lib/videos/captions_fetcher_test.ex b/apps/cf/lib/videos/captions_fetcher_test.ex index 178e08ca..66038462 100644 --- a/apps/cf/lib/videos/captions_fetcher_test.ex +++ b/apps/cf/lib/videos/captions_fetcher_test.ex @@ -7,9 +7,16 @@ defmodule CF.Videos.CaptionsFetcherTest do @impl true def fetch(_video) do - captions = %DB.Schema.VideoCaption{ - content: "__TEST-CONTENT__", - format: "xml" + captions = %{ + raw: "__TEST-CONTENT__", + format: "custom", + parsed: [ + %{ + "text" => "__TEST-CONTENT__", + "start" => 0.0, + "duration" => 1.0 + } + ] } {:ok, captions} diff --git a/apps/cf/lib/videos/captions_fetcher_youtube.ex b/apps/cf/lib/videos/captions_fetcher_youtube.ex index 69666aa6..699e6ca4 100644 --- a/apps/cf/lib/videos/captions_fetcher_youtube.ex +++ b/apps/cf/lib/videos/captions_fetcher_youtube.ex @@ -1,38 +1,134 @@ defmodule CF.Videos.CaptionsFetcherYoutube do @moduledoc """ A captions fetcher for YouTube. + Based upon https://github.com/Valian/youtube-captions, but adapted with Httpoison. """ @behaviour CF.Videos.CaptionsFetcher + require Logger + @impl true def fetch(%{youtube_id: youtube_id, language: language}) do - with {:ok, content} <- fetch_captions_content(youtube_id, language) do - captions = %DB.Schema.VideoCaption{ - content: content, - format: "xml" - } - - {:ok, captions} + with {:ok, data} <- fetch_youtube_data(youtube_id), + {:ok, caption_tracks} <- parse_caption_tracks(data), + {:ok, transcript_url} <- find_transcript_url(caption_tracks, language), + {:ok, transcript_data} <- fetch_transcript(transcript_url) do + {:ok, + %{ + raw: transcript_data, + parsed: process_transcript(transcript_data), + format: "xml" + }} end end - defp fetch_captions_content(video_id, locale) do - case HTTPoison.get("http://video.google.com/timedtext?lang=#{locale}&v=#{video_id}") do - {:ok, %HTTPoison.Response{status_code: 200, body: ""}} -> - {:error, :not_found} + defp fetch_youtube_data(video_id) do + url = "https://www.youtube.com/watch?v=#{video_id}" - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case HTTPoison.get(url, []) do + {:ok, %HTTPoison.Response{body: body}} -> {:ok, body} - {:ok, %HTTPoison.Response{status_code: 404}} -> - {:error, :not_found} + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "Failed to fetch YouTube video #{url}: #{inspect(reason)}"} + end + end + + defp parse_caption_tracks(data) do + captions_regex = ~r/"captionTracks":(?\[.*?\])/ + + case Regex.named_captures(captions_regex, data) do + %{"data" => data} -> {:ok, Jason.decode!(data)} + _ -> {:error, :not_found} + end + end + + defp find_transcript_url(caption_tracks, lang) do + case Enum.find(caption_tracks, &Regex.match?(~r".#{lang}", &1["vssId"])) do + nil -> + {:error, :language_not_found} + + %{"baseUrl" => base_url} -> + {:ok, base_url} + + _data -> + {:error, :language_url_not_found} + end + end - {:ok, %HTTPoison.Response{status_code: _}} -> - {:error, :unknown} + defp fetch_transcript(base_url) do + case HTTPoison.get(base_url, []) do + {:ok, %HTTPoison.Response{body: body}} -> + {:ok, body} {:error, %HTTPoison.Error{reason: reason}} -> - {:error, reason} + {:error, "Failed to fetch transcript: #{inspect(reason)}"} end end + + defp process_transcript(transcript) do + transcript + |> String.replace(~r/^<\?xml version="1.0" encoding="utf-8"\?>/, "") + |> String.replace("", "") + |> String.split("") + |> Enum.filter(&(String.trim(&1) != "")) + |> Enum.map(&process_line/1) + end + + defp process_line(line) do + %{"start" => start} = Regex.named_captures(~r/start="(?[\d.]+)"/, line) + %{"dur" => dur} = Regex.named_captures(~r/dur="(?[\d.]+)"/, line) + + text = + line + |> String.replace("&", "&") + |> String.replace(~r//, "") + |> String.replace(~r"]+(>|$)", "") + |> HtmlEntities.decode() + |> String.trim() + + %{start: parse_float(start), duration: parse_float(dur), text: text} + end + + defp parse_float(val) do + {num, _} = Float.parse(val) + num + end + + # Below is an implementation using the official YouTube API, but it requires OAuth2 authentication. + # It is left here for reference, in case we loose access to the unofficial API. + # defp fetch_captions_content_with_official_api(video_id, locale) do + # # TODO: Continue dev here. See https://www.perplexity.ai/search/Can-you-show-jioyCtw.S4yrL8mlIBdqGg + # {:ok, token} = Goth.Token.for_scope("https://www.googleapis.com/auth/youtube.force-ssl") + # conn = YouTubeConnection.new(token.token) + # {:ok, captions} = GoogleApi.YouTube.V3.Api.Captions.youtube_captions_list(conn, ["snippet"], video_id, []) + # { + # "kind": "youtube#captionListResponse", + # "etag": "kMTAKpyU_VGu7GxgEnxXHqcuEXM", + # "items": [ + # { + # "kind": "youtube#caption", + # "etag": "tWo68CIcRRFZA0oXPt8HGxCYia4", + # "id": "AUieDaZJxYug0L5YNAw_31GbXz73b0CPXCDFlsPNSNe7KQvuv1g", + # "snippet": { + # "videoId": "v2IoEhuho2k", + # "lastUpdated": "2024-06-16T18:45:12.56697Z", + # "trackKind": "asr", + # "language": "fr", + # "name": "", + # "audioTrackType": "unknown", + # "isCC": false, + # "isLarge": false, + # "isEasyReader": false, + # "isDraft": false, + # "isAutoSynced": false, + # "status": "serving" + # } + # } + # ] + # } + # caption_id = List.first(captions.items).id # TODO inspect to pick the right caption + # {:ok, caption} = GoogleApi.YouTube.V3.Api.Captions.youtube_captions_download(conn, caption_id, []) + # end end diff --git a/apps/cf/lib/videos/videos.ex b/apps/cf/lib/videos/videos.ex index 81ec17e2..8016e075 100644 --- a/apps/cf/lib/videos/videos.ex +++ b/apps/cf/lib/videos/videos.ex @@ -3,6 +3,8 @@ defmodule CF.Videos do The boundary for the Videos system. """ + require Logger + import Ecto.Query, warn: false import CF.Videos.MetadataFetcher import CF.Videos.CaptionsFetcher @@ -167,15 +169,42 @@ defmodule CF.Videos do iex> download_captions(video) """ def download_captions(video = %Video{}) do - with {:ok, captions} <- @captions_fetcher.fetch(video) do - captions - |> VideoCaption.changeset(%{video_id: video.id}) - |> Repo.insert() - - {:ok, captions} + # Try to fetch new captions + existing_captions = get_existing_captions(video) + captions_base = if existing_captions, do: existing_captions, else: %VideoCaption{} + + case @captions_fetcher.fetch(video) do + {:ok, captions} -> + captions_base + |> VideoCaption.changeset(Map.merge(captions, %{video_id: video.id})) + |> Repo.insert_or_update() + + # If no Youtube caption found, insert a dummy entry in DB to prevent retrying for 30 days + {:error, :not_found} -> + unless existing_captions do + Repo.insert(%DB.Schema.VideoCaption{ + video_id: video.id, + raw: "", + parsed: "", + format: "xml" + }) + end + + {:error, :not_found} + + result -> + result end end + defp get_existing_captions(video) do + VideoCaption + |> where([vc], vc.video_id == ^video.id) + |> order_by(desc: :inserted_at) + |> limit(1) + |> Repo.one() + end + defp get_metadata_fetcher(video_url) do if Application.get_env(:cf, :use_test_video_metadata_fetcher) do &MetadataFetcher.Test.fetch_video_metadata/1 diff --git a/apps/cf/test/videos/videos_test.exs b/apps/cf/test/videos/videos_test.exs index 111196bf..18bfa8d6 100644 --- a/apps/cf/test/videos/videos_test.exs +++ b/apps/cf/test/videos/videos_test.exs @@ -68,7 +68,15 @@ defmodule CF.VideosTest do {:ok, captions} = Videos.download_captions(video) - assert captions.content == "__TEST-CONTENT__" + assert captions.raw == "__TEST-CONTENT__" + + assert captions.parsed == [ + %{ + "text" => "__TEST-CONTENT__", + "start" => 0.0, + "duration" => 1.0 + } + ] end end end diff --git a/apps/cf_jobs/config/config.exs b/apps/cf_jobs/config/config.exs index 310ab01c..76aada30 100644 --- a/apps/cf_jobs/config/config.exs +++ b/apps/cf_jobs/config/config.exs @@ -8,6 +8,7 @@ config :cf_jobs, CF.Jobs.Scheduler, jobs: [ # Reputation update_reputations: [ + # every 20 minutes schedule: {:extended, "*/20"}, task: {CF.Jobs.Reputation, :update, []}, overlap: false @@ -19,21 +20,31 @@ config :cf_jobs, CF.Jobs.Scheduler, ], # Moderation update_moderation: [ + # every 5 minutes schedule: "*/5 * * * *", task: {CF.Jobs.Moderation, :update, []}, overlap: false ], # Flags update_flags: [ + # every minute schedule: "*/1 * * * *", task: {CF.Jobs.Flags, :update, []}, overlap: false ], # Notifications create_notifications: [ + # every 5 seconds schedule: {:extended, "*/5"}, task: {CF.Jobs.CreateNotifications, :update, []}, overlap: false + ], + # Captions + download_captions: [ + # every 10 minutes + schedule: "*/10 * * * *", + task: {CF.Jobs.DownloadCaptions, :update, []}, + overlap: false ] ] diff --git a/apps/cf_jobs/lib/application.ex b/apps/cf_jobs/lib/application.ex index 277aae33..edd49824 100644 --- a/apps/cf_jobs/lib/application.ex +++ b/apps/cf_jobs/lib/application.ex @@ -10,13 +10,15 @@ defmodule CF.Jobs.Application do :timer.sleep(1000) env = Application.get_env(:cf, :env) + # Define workers and child supervisors to be supervised children = [ # Jobs worker(CF.Jobs.Reputation, []), worker(CF.Jobs.Flags, []), worker(CF.Jobs.Moderation, []), - worker(CF.Jobs.CreateNotifications, []) + worker(CF.Jobs.CreateNotifications, []), + worker(CF.Jobs.DownloadCaptions, []) ] # Do not start scheduler in tests diff --git a/apps/cf_jobs/lib/jobs/download_captions.ex b/apps/cf_jobs/lib/jobs/download_captions.ex new file mode 100644 index 00000000..feeace97 --- /dev/null +++ b/apps/cf_jobs/lib/jobs/download_captions.ex @@ -0,0 +1,66 @@ +defmodule CF.Jobs.DownloadCaptions do + @behaviour CF.Jobs.Job + + require Logger + import Ecto.Query + import ScoutApm.Tracing + + alias DB.Repo + alias DB.Schema.UserAction + alias DB.Schema.Video + alias DB.Schema.VideoCaption + alias DB.Schema.UsersActionsReport + + alias CF.Jobs.ReportManager + + @name :download_captions + @analyser_id UsersActionsReport.analyser_id(@name) + + # --- Client API --- + + def name, do: @name + + def start_link() do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(args) do + {:ok, args} + end + + # 2 minutes + @timeout 120_000 + def update() do + GenServer.call(__MODULE__, :download_captions, @timeout) + end + + # --- Server callbacks --- + @transaction_opts [type: "background", name: "download_captions"] + def handle_call(:download_captions, _from, _state) do + get_videos() + |> Enum.map(fn video -> + Logger.info("Downloading captions for video #{video.id}") + CF.Videos.download_captions(video) + end) + + {:reply, :ok, :ok} + end + + # Get all videos that need new captions. We fetch new captions: + # - For any videos that doesn't have any captions yet + # - For videos whose captions haven't been updated in the last 30 days + defp get_videos() do + Repo.all( + from(v in Video, + limit: 15, + left_join: captions in VideoCaption, + on: captions.video_id == v.id, + where: + is_nil(captions.id) or + captions.inserted_at < ^DateTime.add(DateTime.utc_now(), -30 * 24 * 60 * 60, :second), + group_by: v.id, + order_by: [desc: v.inserted_at] + ) + ) + end +end diff --git a/apps/db/lib/db_schema/users_actions_report.ex b/apps/db/lib/db_schema/users_actions_report.ex index 2abef0d0..6cd958b1 100644 --- a/apps/db/lib/db_schema/users_actions_report.ex +++ b/apps/db/lib/db_schema/users_actions_report.ex @@ -34,6 +34,7 @@ defmodule DB.Schema.UsersActionsReport do def analyser_id(:achievements), do: 3 def analyser_id(:votes), do: 4 def analyser_id(:create_notifications), do: 5 + def analyser_id(:download_captions), do: 6 def status(:pending), do: 1 def status(:running), do: 2 diff --git a/apps/db/lib/db_schema/video_caption.ex b/apps/db/lib/db_schema/video_caption.ex index 414d993c..70280bd4 100644 --- a/apps/db/lib/db_schema/video_caption.ex +++ b/apps/db/lib/db_schema/video_caption.ex @@ -5,13 +5,14 @@ defmodule DB.Schema.VideoCaption do @primary_key false schema "videos_captions" do belongs_to(:video, DB.Schema.Video, primary_key: true) - field(:content, :string) + field(:raw, :string) + field(:parsed, {:array, :map}) field(:format, :string) timestamps() end - @required_fields ~w(video_id content format)a + @required_fields ~w(video_id raw parsed format)a @doc """ Builds a changeset based on the `struct` and `params`. diff --git a/apps/db/priv/repo/migrations/20240618055503_update_video_captions.exs b/apps/db/priv/repo/migrations/20240618055503_update_video_captions.exs new file mode 100644 index 00000000..ee048d9d --- /dev/null +++ b/apps/db/priv/repo/migrations/20240618055503_update_video_captions.exs @@ -0,0 +1,24 @@ +defmodule DB.Repo.Migrations.UpdateVideoCaptions do + use Ecto.Migration + + def up do + # Delete all values (there are none in prod) + execute("DELETE FROM videos_captions") + + # Drop column :content in favor of raw + parsed + alter table(:videos_captions) do + remove(:content) + add(:raw, :text, null: false) + add(:parsed, {:array, :map}, null: false) + end + end + + def down do + # Drop raw + parsed in favor of :content + alter table(:videos_captions) do + remove(:raw) + remove(:parsed) + add(:content, :text, null: false) + end + end +end diff --git a/mix.lock b/mix.lock index 53976d34..3d46c25d 100644 --- a/mix.lock +++ b/mix.lock @@ -15,6 +15,7 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burnex": {:hex, :burnex, "3.1.0", "1c1ffaab0dccd4efe80f3c3d0de61e9bb4e622fd0c52b0fccea693095e7c30b2", [:mix], [{:dns, "~> 2.2.0", [hex: :dns, repo: "hexpm", optional: false]}], "hexpm", "611af3dd131c1a5e75b367c75641c9104b0a942dfdd9767e69fbe8be883d536d"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, + "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "4.1.2", "3eb5620fd8e35508991664b4c2b04dd41e52f1620b36957be837c1d7784b7592", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm", "d8700a0ca4dbb616c22c9b3f6dd539d88deaafec3efe66869d6370c9a559b3e9"}, @@ -42,17 +43,20 @@ "exsync": {:hex, :exsync, "0.2.3", "a1ac11b4bd3808706003dbe587902101fcc1387d9fc55e8b10972f13a563dd15", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "23b6b186a2caa1cf5c0c4dfea9bd181d21d80a4032594d2f7c27d7ca78caa51d"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "floki": {:hex, :floki, "0.34.2", "5fad07ef153b3b8ec110b6b155ec3780c4b2c4906297d0b4be1a7162d04a7e02", [:mix], [], "hexpm", "26b9d50f0f01796bc6be611ca815c5e0de034d2128e39cc9702eee6b66a4d1c8"}, "gen_stage": {:hex, :gen_stage, "0.13.1", "edff5bca9cab22c5d03a834062515e6a1aeeb7665fb44eddae086252e39c4378", [:mix], [], "hexpm", "307544601361efdc80620f8762c8e9afc7e65187640ed260b17a035d58db865c"}, "gen_state_machine": {:hex, :gen_state_machine, "2.1.0", "a38b0e53fad812d29ec149f0d354da5d1bc0d7222c3711f3a0bd5aa608b42992", [:mix], [], "hexpm", "ae367038808db25cee2f2c4b8d0531522ea587c4995eb6f96ee73410a60fa06b"}, "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [:mix], [], "hexpm", "b233b4ab0d349a359b52592d2d591fc6e4b20fdbe0b15a624cc15a3ca509a1cc"}, "google_api_you_tube": {:hex, :google_api_you_tube, "0.42.0", "b420b117df66debe1e24c0253e395e936e497eb8d68f9f60129417e8b9d80a84", [:mix], [{:google_gax, "~> 0.4", [hex: :google_gax, repo: "hexpm", optional: false]}], "hexpm", "96fdd9b80694d1ebd3ec3ade7db2a9c05506a30fd897a06d810417b666151cd5"}, "google_gax": {:hex, :google_gax, "0.4.1", "310105070626013712c56f8007b6ff7b4ead02ecad1efe7888350c6eaba52783", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:poison, ">= 3.0.0 and < 5.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "aef7dce7e04840c0e611f962475e3223d27d50ebd5e7d8e9e963c5e9e3b1ca79"}, + "goth": {:hex, :goth, "1.4.3", "80e86225ae174844e6a77b61982fafadfc715277db465e0956348d8bdd56b231", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "34e2012ed1af2fe2446083da60a988fd9829943d30e4447832646c3a6863a7e6"}, "guardian": {:hex, :guardian, "2.0.0", "5d3e537832b7cf35c8674da92457b7be671666a2eff4bf0f2ccfcfb3a8c67a0b", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6804b9eea4a30cab82bf51f1ae7ae333980b3bdcc6535b018242c4737e41e042"}, "guardian_db": {:hex, :guardian_db, "2.1.0", "ec95a9d99cdd1e550555d09a7bb4a340d8887aad0697f594590c2fd74be02426", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f8e7d543ac92c395f3a7fd5acbe6829faeade57d688f7562e2f0fca8f94a0d70"}, "guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "21f439246715192b231f228680465d1ed5fbdf01555a4a3b17165532f5f9a08c"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, "hashids": {:hex, :hashids, "2.1.0", "aabbcc4f9fa0b460cc6ef629f5bcbc35e7e87b382fee79f9c50be40b86574288", [:mix], [], "hexpm", "172163b1642d415881ef1c6e1f1be12d4e92b0711d5bbbd8854f82a1ce32d60b"}, + "hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "4846958172d6401c4f34ecc5c2c4607b5b0d90b8eec8f6df137ca4907942ed0f"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, @@ -65,10 +69,13 @@ "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.1", "eee6fc570d77ad6851c7bc08de420a47fd1e449ef5ccfa6a77ef68b72e7e51ad", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f82262b54dee533467021723892e15c3267349849f1f737526523ecba4e6baae"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm", "b93e2b1e564bdbadfecc297277f9e6d0902da645b417d6c9210f6038ac63489a"}, "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "not_qwerty123": {:hex, :not_qwerty123, "2.2.1", "656e940159517f2d2f07ea0bb14e4ad376d176b5f4de07115e7a64902b5e13e3", [:mix], [{:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: false]}], "hexpm", "7637173b09eb7b26b29925039d5b92f7107c94a27cbe4d2ba8efb8b84d060c4b"}, "oauth2": {:hex, :oauth2, "0.9.4", "632e8e8826a45e33ac2ea5ac66dcc019ba6bb5a0d2ba77e342d33e3b7b252c6e", [:mix], [{:hackney, "~> 1.7", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "407c6b9f60aa0d01b915e2347dc6be78adca706a37f0c530808942da3b62e7af"}, "parallel_stream": {:hex, :parallel_stream, "1.0.6", "b967be2b23f0f6787fab7ed681b4c45a215a81481fb62b01a5b750fa8f30f76c", [:mix], [], "hexpm", "639b2e8749e11b87b9eb42f2ad325d161c170b39b288ac8d04c4f31f8f0823eb"}, @@ -84,6 +91,7 @@ "postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"}, "quantum": {:hex, :quantum, "2.3.3", "83f565de81ac43b8fda4dd4266b209eaed29545d1c41e17aa6b75b08736c80f6", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm", "01a63089a17f00f360ddad6c2f068c26d4e280999c2a6c2bce170d0bd6b2bd2e"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"}, "scout_apm": {:hex, :scout_apm, "1.0.7", "0ca260f2c7f3c29bf6a5b361e90339bdce0a5f3ae0cf7b0ce166bfb22eefb89c", [:mix], [{:approximate_histogram, "~>0.1.1", [hex: :approximate_histogram, repo: "hexpm", optional: false]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~>1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "a4a3c8318fb84e4586e68fcd7e889c5bfe17c1caa218cc6f333fb0e4c0ff4ec1"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"}, @@ -94,7 +102,7 @@ "swarm": {:hex, :swarm, "3.3.1", "b4d29c49310b92b4a84bd3be6a51d9616eaeda1899b7619201d0908d8d789bd8", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "228dcaa2bbff0bf5a5e5bb4541cb0e2afcfc38afdd01f44e81912d2ad7a2e33b"}, "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, - "tesla": {:hex, :tesla, "1.6.0", "14f3d3f0b0628d2747f210da09cc3213dc627a96792e82811150f6353a438579", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "3593ac332caebb2850876116047a2051867e319d7cf3bf1c71be68dc099a6f21"}, + "tesla": {:hex, :tesla, "1.10.0", "31c73de1e6b8c063e32bf4115922ed50eb1f56b3530907a49e39dd685bae95be", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "eb1cab91a367667ba7391d3d2c7c46c0ac016a80718b32fb179b54880962e0a8"}, "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, "ueberauth": {:hex, :ueberauth, "0.4.0", "bc72d5e5a7bdcbfcf28a756e34630816edabc926303bdce7e171f7ac7ffa4f91", [:mix], [{:plug, "~> 1.2", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, @@ -106,4 +114,5 @@ "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, + "youtube_captions": {:hex, :youtube_captions, "0.1.0", "1491f9c751924a03ec2b87e43be4d7ec42d18823d758c3871711d3832fe93165", [:mix], [{:html_entities, "~> 0.5", [hex: :html_entities, repo: "hexpm", optional: false]}, {:req, "~> 0.3", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "46c2f0a27192f2b2fe88c65260be3e44e079ce72f6e4d86007fb282d0899b54b"}, }