diff --git a/.env b/.env new file mode 100644 index 00000000..37b3c42a --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +AWS_ACCESS_KEY_ID=secrettunnel +AWS_SECRET_ACCESS_KEY=secrettunnel +AWS_DEFAULT_REGION=ap-southeast-1 diff --git a/assets/js/app.js b/assets/js/app.js index ccdb8816..0ef6eb47 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,7 +27,12 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute( "content", ); let liveSocket = new LiveSocket("/live", Socket, { - params: { _csrf_token: csrfToken }, + params: { _csrf_token: csrfToken, + locale: Intl.NumberFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone_offset: -new Date().getTimezoneOffset(), + session: JSON.parse(localStorage.getItem("session")) || {active: true} + }, hooks: Hooks, }); diff --git a/assets/js/hooks/audio_player.js b/assets/js/hooks/audio_player.js new file mode 100644 index 00000000..c6ca4b56 --- /dev/null +++ b/assets/js/hooks/audio_player.js @@ -0,0 +1,237 @@ +/** + * Hooks for audio player. + * */ + +let nowSeconds = () => Math.round(Date.now() / 1000) +let rand = (min, max) => Math.floor(Math.random() * (max - min) + min) +let isVisible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0) + +let execJS = (selector, attr) => { + document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(attr))) +} + + + +AudioPlayer = { + mounted() { + this.playbackBeganAt = null + this.player = this.el.querySelector("audio") + this.currentTime = this.el.querySelector("#player-time") + this.duration = this.el.querySelector("#player-duration") + this.progress = this.el.querySelector("#player-progress") + + let enableAudio = () => { + if(this.player.src){ + document.removeEventListener("click", enableAudio) + const hasNothingToPlay = this.player.readyState === 0; + if(hasNothingToPlay){ + this.player.play().catch(error => null) + this.player.pause() + } + } + } + + document.addEventListener("click", enableAudio) + + this.player.addEventListener("loadedmetadata", e => { + console.log("Loaded metadata!", { + duration: this.player.duration, + event: e, + }) + }) + + this.el.addEventListener("js:listen_now", () => this.play({sync: true})) + this.el.addEventListener("js:play_pause", () => { + console.log("{play_pause event triggerred} player:", this.player) + // toggle play-pause + if(this.player.paused){ + this.play() + } + }) + + this.handleEvent("initSession", (sess) => { + localStorage.setItem("session", JSON.stringify(sess)) + }) + + this.handleEvent("registerEventsTimeline", params => { + console.log("Register Events Timeline", params); + this.player.eventsTimeline = params.voice_events; + }) + + + /// events handled by audio player:: + this.handleEvent("play", (params) => { + const {filePath, isPlaying, elapsed, artist, title} = params; + const beginTime = nowSeconds() - elapsed + this.playbackBeganAt = beginTime + let currentSrc = this.player.src.split("?")[0] + const isLoadedAndPaused = currentSrc === filePath && !isPlaying && this.player.paused; + if(isLoadedAndPaused){ + this.play({sync: true}) + } else if(currentSrc !== filePath) { + currentSrc = filePath + this.player.src = currentSrc + this.play({sync: true}) + } + + const isMediaSessionApiSupported = "mediaSession" in navigator; + if(isMediaSessionApiSupported){ + navigator.mediaSession.metadata = new MediaMetadata({artist, title}) + } + }) + this.handleEvent("pause", () => this.pause()) + this.handleEvent("stop", () => this.stop()) + this.handleEvent("seekTo", params => this.seekTo(params)) + }, + clearNextTimer(){ + clearTimeout(this.nextTimer) + this.nextTimer = null + }, + clearProgressTimer() { + console.log("[clearProgressTimer]", { + timer: this.progressTimer, + }) + clearInterval(this.progressTimer) + }, + play(opts = {}){ + console.log("Triggered playback, check params", { + player: this.player, + opts, + }) + + let {sync} = opts + this.clearNextTimer() + + // + this.player.play().then(() => { + if(sync) { + const currentTime = nowSeconds() - this.playbackBeganAt + this.player.currentTime = currentTime; + this.currentTime.innerText = this.formatTime(currentTime); + } + const progressUpdateInterval = 100 // 10fps, comfortable for human eye + + if (!this.progressTimer) { // single instance of progress timer + this.progressTimer = setInterval(() => this.updateProgress(), progressUpdateInterval) + } + console.log("[play: ProgressTimer]: ", this.progressTimer) + + }, error => { + if(error.name === "NotAllowedError"){ + execJS("#enable-audio", "data-js-show") + } + }) + }, + + pause(){ + this.clearProgressTimer() + this.player.pause() + }, + + stop(){ + this.clearProgressTimer() + this.player.pause() + this.player.currentTime = 0 + this.updateProgress() + this.duration.innerText = "" + this.currentTime.innerText = "" + }, + seekTo(params) { + const { + positionS + } = params; + + const beginTime = nowSeconds() - positionS + this.playbackBeganAt = beginTime; + this.currentTime.innerText = this.formatTime(positionS); + this.player.currentTime = positionS; + this.updateProgress() + }, + /** + * Updates playback progress information. + * */ + updateProgress() { + // console.log("[updateProgress]", { + // eventsTimeline: this.player.eventsTimeline + // }) + + this.emphasizeActiveEvent(this.player.currentTime, this.player.eventsTimeline) + + if(isNaN(this.player.duration)) { + console.log("player duration is nan") + return false + } + + const shouldStopUpdating = this.player.currentTime > 0 && this.player.paused + if (shouldStopUpdating) { + this.clearProgressTimer() + } + + const shouldAutoPlayNextSong = !this.nextTimer && this.player.currentTime >= this.player.duration; + if(shouldAutoPlayNextSong) { + this.clearProgressTimer() // stops progress update + const autoPlayMaxDelay = 1500 + this.nextTimer = setTimeout( + // pushes next autoplay song to server: + // FIXME: this shall be added in in the following PRs + () => this.pushEvent("next_song_auto"), + rand(0, autoPlayMaxDelay) + ) + return + } + this.progress.style.width = `${(this.player.currentTime / (this.player.duration) * 100)}%` + const durationVal = this.formatTime(this.player.duration); + const currentTimeVal = this.formatTime(this.player.currentTime); + console.log("update progress:", { + player: this.player, + durationVal, + currentTimeVal, + }) + this.duration.innerText = durationVal; + this.currentTime.innerText = currentTimeVal; + }, + + formatTime(seconds) { + return new Date(1000 * seconds).toISOString().substring(11, 19) + }, + emphasizeActiveEvent(currentTime, events) { + + if (!events) { + console.log("No events found") + return; + } + + const currentTimeMs = currentTime * 1000 + const activeEvent = events.find(event => currentTimeMs >= event.origin && currentTimeMs < (event.origin + event.duration)) + console.log("activeEvent:", {currentTimeMs, activeEvent}) + + if (!activeEvent) { + console.log("No active event found @ time = ", currentTime) + return; + } + + const { + verse_id: verseId + } = activeEvent; + + if (!verseId) { + return + } + + + const classVals = ["bg-orange-500", "border-l-8", "border-black"] + + // TODO: this is a pedestrian approach that can be improved significantly: + for (const otherDomNode of document.querySelectorAll('[id*="verse-"]')) { + classVals.forEach(classVal => otherDomNode.classList.remove(classVal)) + otherDomNode.classList.remove("bg-orange-500") + } + + const targetDomId = `verse-${verseId}` + const node = document.getElementById(targetDomId) + classVals.forEach(classVal => node.classList.add(classVal)) + } +} + + +export default AudioPlayer; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index 7367c4aa..33f3d0ad 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -4,11 +4,16 @@ import { TriggerYouTubeFunction, } from "./youtube_player.js"; import MiniPlayer from "./mini_player.js"; +import AudioPlayer from "./audio_player.js"; +import ProgressBar from "./progress_bar.js"; + let Hooks = {}; Hooks.ShareQuoteButton = ShareQuoteButton; Hooks.RenderYouTubePlayer = RenderYouTubePlayer; Hooks.TriggerYouTubeFunction = TriggerYouTubeFunction; Hooks.MiniPlayer = MiniPlayer; +Hooks.AudioPlayer = AudioPlayer; +Hooks.ProgressBar = ProgressBar; export default Hooks; diff --git a/assets/js/hooks/progress_bar.js b/assets/js/hooks/progress_bar.js new file mode 100644 index 00000000..f39dc06a --- /dev/null +++ b/assets/js/hooks/progress_bar.js @@ -0,0 +1,64 @@ +/** + * Progress Bar hooks intended to sync playback related actions + * + * */ +ProgressBar = { + mounted() { + this.el.addEventListener("click", (e) => { + // The progress bar is measure in milliseconds, + // with `min` set at 0 and `max` at the duration of the track. + // + // [--------------------------X-----------] + // 0 | offsetWidth + // | + // [--------------------------] e.target.offsetX + // + // When the user clicks on the progress bar (represented by X), we get + // the position of the click in pixels (e.target.offsetX). + // + // We know that we can express the relationship between pixels + // and milliseconds as: + // + // e.target.offsetX : e.target.offsetWidth = X : max + // + // To find X, we do: + // console.log("check possible positions info:", { + // e. + + // }) + + const { + max: maxTime, + } = this.el.dataset + + if (!maxTime) { + console.log("unable to seek position, payload is incorrect") + return + } + + const containerNode = document.getElementById("player-progress-container") + const maxPlaybackMs = Number(maxTime) + const maxOffset = containerNode.offsetWidth + const currXOffset = e.offsetX; + const playbackPercentage = (currXOffset / maxOffset) + const positionMs = maxPlaybackMs * playbackPercentage + + // Optimistic update + this.el.value = positionMs; + + console.log("seek attempt @ positionMs:", { + event: e, + maxOffset, + currXOffset, + playbackPercentage, + positionMs + }) + this.pushEventTo("#audio-player-container", "seekToMs", { position_ms: positionMs }); + + return; + }); + }, +}; + + +export default ProgressBar; diff --git a/config/dev.exs b/config/dev.exs index 53808a19..a6ce6f9e 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -10,6 +10,23 @@ config :vyasa, Vyasa.Repo, show_sensitive_data_on_connection_error: true, pool_size: 10 +# MINIO Object Store API Domain +config :ex_aws, + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], + secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role], + region: "ap-southeast-1", + http_client: Vyasa.Medium.Ext.S3Client + +config :ex_aws, :s3, + scheme: "http://", + host: "localhost", + port: 9000 + +config :ex_aws, :retries, + max_attempts: 2, + base_backoff_in_ms: 10, + max_backoff_in_ms: 10_000 + # For development, we disable any cache and enable # debugging and code reloading. # diff --git a/config/runtime.exs b/config/runtime.exs index 0b1c15f9..c0a06fa9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -114,4 +114,7 @@ if config_env() == :prod do # config :swoosh, :api_client, Swoosh.ApiClient.Hackney # # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. + # + else + Vyasa.Parser.Env.load_file('.env') end diff --git a/docker-compose.yml b/docker-compose.yml index 1699f8ef..5c3a6f7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,7 @@ version: '3.8' services: postgres: container_name: postgres_container - build: - context: ./GanapatiSQL - dockerfile: Localfile - #image: postgres + image: postgres environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-root} diff --git a/docs/initial_db_helpers.livemd b/docs/initial_db_helpers.livemd index b80ef35a..ba1d5c04 100644 --- a/docs/initial_db_helpers.livemd +++ b/docs/initial_db_helpers.livemd @@ -25,11 +25,11 @@ Convert json --> struct --> using changeset --> insert into repo ```elixir alias Vyasa.Written.{Chapter, Source, Translation} +alias Vyasa.Medium alias Vyasa.Repo defmodule G do - @root "/Users/ritesh/Projects/vyasa" - @gita_sub_dir "#{@root}/priv/static/corpus/gita" + @gita_sub_dir Path.expand("./priv/static/corpus/gita") @verses "#{@gita_sub_dir}/verse.json" @chapters "#{@gita_sub_dir}/chapters.json" @@ -56,7 +56,9 @@ defmodule G do |> Map.put("body", verse["text"]) |> Map.put("no", verse["verse_number"]) - dbg(updated) + updated + + # dbg(updated) end def get_verses_for_chapter(chap_no, src_id) do @@ -84,7 +86,8 @@ defmodule G do body: chapter["chapter_summary"], source_id: source_id } - |> dbg() + + # |> dbg() end def create_chapter(%{} = chapter, [%{} = _head | _rest] = _verses, source_id) do @@ -198,21 +201,36 @@ end * purging of db * seeding of db +```elixir +alias Vyasa.Medium.Voice + +v = %Voice{ + lang: "sa", + meta: %{ + artist: ["myArtist1", "myArtist2"] + } +} +``` + ```elixir defmodule DBHelper do alias Vyasa.Repo alias G alias Vyasa.Written.{Source, Chapter, Verse, Translation} + alias Vyasa.Medium.{Event} + alias Vyasa.Medium.{Voice} + + # alias Vyasa.Medium.{Voice, Event, Store, Track, Video, Writer} def purge_db() do - [Translation, Verse, Chapter, Source] + [Event, Voice, Translation, Verse, Chapter, Source] |> Enum.map(fn mod -> Repo.delete_all(mod) end) end def seed_db() do uuid = Ecto.UUID.generate() _source = DBHelper.insert_source(uuid, "Gita") - DBHelper.insert_chapters(uuid) + _chap = DBHelper.insert_chapters(uuid) end def insert_source(uuid, source_title \\ "Gita") do @@ -241,6 +259,8 @@ defmodule DBHelper do verses_translation_changesets |> Enum.map(fn c_set -> Repo.insert(c_set) end) |> Enum.map(fn {:ok, inserted_verse_translation} -> inserted_verse_translation end) + + chap end def insert_chapters(source_id) do @@ -258,17 +278,272 @@ DBHelper.purge_db() DBHelper.seed_db() ``` +#### Events are allivvee + +```elixir +R.recompile() +gita = Vyasa.Written.get_source_by_title("Gita") +verses = Vyasa.Written.get_verses_in_chapter(1, gita.id) + +# creats a map using verse information +verse_lookup = Enum.into(for(%{id: id, no: verse_no} <- verses, do: {verse_no, id}), %{}) + +c1_path = Path.expand("./1.mp3", "media/gita") + +{:ok, + %Vyasa.Parser.MP3{ + duration: tot_d, + path: p + }} = Vyasa.Parser.MP3.parse(c1_path) + +{:ok, voice} = + Vyasa.Medium.create_voice(%{ + lang: "sa", + duration: tot_d, + file_path: c1_path, + source_id: gita.id, + chapter_no: 1 + }) + +# Vyasa.Medium.get_voice!("4c73fb6d-4163-4b64-90d0-5d49680c1ee4") +# |> Vyasa.Medium.delete_voice() +``` + +```elixir +R.recompile() + +events = + """ + start :- 00:00 + Shloka 1:- 00:33 + Shloka 2 :- 00:49 + Shloka 3:- 01:06 + Shloka 4:- 01:19 + Shloka 5:- 01:32 + Shloka 6:- 01:46 + Shloka 7:- 02:00 + Shloka 8:- 02:15 + Shloka 9:- 02:28 + Shloka 10:- 02:42 + Shloka 11:- 02:56 + Shloka 12:- 03:09 + Shloka 13:- 03:22 + Shloka 14:- 03:36 + Shloka 15:- 03:49 + Shloka 16:- 04:02 + Shloka 17:- 04:14 + Shloka 18:- 04:27 + Shloka 19:- 04:40 + Shloka 20:- 04:54 + Shloka 21:- 05:07 + Shloka 22:- 05:23 + Shloka 23:- 05:36 + Shloka 24:- 05:50 + Shloka 25:- 06:05 + Shloka 26:- 06:18 + Shloka 27:- 06:32 + Shloka 28:- 06:46 + Shloka 29:- 07:01 + Shloka 30:- 07:13 + Shloka 31:- 07:26 + Shloka 32 :- 07:38 + Shloka 33:- 07:52 + Shloka 34 :- 08:05 + Shloka 35 :- 08:18 + Shloka 36 :- 08:31 + Shloka 37:- 08:44 + Shloka 38 :- 08:57 + Shloka 39:- 09:09 + Shloka 40:- 09:22 + Shloka 41:- 09:35 + Shloka 42:- 09:48 + Shloka 43:- 10:02 + Shloka 44:- 10:16 + Shloka 45:- 10:29 + Shloka 46:- 10:40 + Shloka 47:- 10:53 + end:- 11:08 + """ + # each line + |> String.split("\n") + |> Enum.map(fn x -> + x + |> String.split(":-") + |> Enum.map(&String.trim/1) + |> Enum.reduce([], fn + <<"Shloka"::utf8, sep::utf8, verse_no::binary>>, acc -> + [verse_lookup[String.to_integer(verse_no)] | acc] + + bin, acc -> + [bin | acc] + end) + end) + |> IO.inspect(limit: :infinity) + |> Enum.reduce( + [], + fn + [time, "start"], acc -> + [ + %Vyasa.Medium.Event{origin: 0, phase: "start", voice_id: voice.id, source_id: gita.id} + | acc + ] + + [time, "end"], [%{origin: o} = prev | acc] -> + [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) + d = (min * 60 + sec) * 1000 + + [ + %Vyasa.Medium.Event{ + origin: d, + duration: tot_d - d, + phase: "end", + voice_id: voice.id, + source_id: gita.id + } + | [%{prev | duration: d - o} | acc] + ] + + [time, id], [%{origin: o} = prev | acc] -> + [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) + d = (min * 60 + sec) * 1000 + + [ + %Vyasa.Medium.Event{origin: d, verse_id: id, voice_id: voice.id, source_id: gita.id} + | [%{prev | duration: d - o} | acc] + ] + + _, acc -> + acc + end + ) + |> Enum.map(&Vyasa.Medium.create_event(&1)) +``` + +### Testing Assocs. + ```elixir -### checking the query functions +R.recompile() alias Vyasa.Written + +# getting a voice from a chapter: +chap_1 = Written.get_chapter(1, "Gita") + +voice = + chap_1.voices + # sanskrit voice for chapter 1 + |> Enum.find(fn v -> v.lang == "sa" end) + +# <== this remains empty because the assoc is not done +events = voice.events + +# IO.inspect(events, limit: :infinity) +# hd(events) + +# 736fbb85-1751-4419-a572-c4283a321252 +# Vyasa.Repo.all(Vyasa.Medium.Voice) +# Vyasa.Repo.get(Vyasa.Medium.Voice, "736fbb85-1751-4419-a572-c4283a321252") +# |> Vyasa.Repo.preload([:chapter, :source, :track]) +``` + +```elixir +R.recompile() alias Vyasa.Repo +alias Vyasa.Medium.{Voice, Event} +alias Vyasa.Written.{Chapter} + +Repo.all(Event) + +# Repo.get(Voice, "d1303578-b08a-4811-b219-d63bff7ece6e") +Repo.get(Voice, "d040c39a-a25d-45b2-b73d-a7d3db70cbee") + +# Written.get_chapter(1, "Gita") +``` + +### Demo of how to write an example voice using the writer, and test it using curl... + +```elixir +R.recompile() +alias Vyasa.Medium.{Voice} +alias Vyasa.Repo + +example_url = "/Users/ritesh/Desktop/sample-15s.mp3" +# example_v = %Voice{lang: "en", file_path: "/Users/ritesh/Desktop/sample-15s.mp3"} + +# {:ok, inserted} = Repo.insert(example_v) +# {:ok, stored_url} = Vyasa.Medium.Store.put(inserted) |> dbg() +# output = System.cmd("curl", [stored_url]) +src = Vyasa.Written.get_source_by_title("Gita") +{:ok, voice} = Vyasa.Medium.create_voice(%{lang: "sa", source_id: src.id}) + +stored_url = + %{voice | file_path: example_url} + |> Vyasa.Medium.Writer.run() + |> then(&elem(&1, 1).key) + |> Vyasa.Medium.Store.get!() + +output = System.cmd("curl", [stored_url]) +``` + +```elixir +R.recompile() + +alias Vyasa.MediaLibrary +alias Vyasa.Medium.{Voice} + +curr_playing = + MediaLibrary.Playback.new(%{ + medium: %Voice{}, + playing: false + }) + +# play: + +curr_playing = %{curr_playing | playing: true, played_at: DateTime.utc_now()} +``` + +```elixir +existing_v = hd(Repo.all(Voice)) + +{:ok, v} = + Vyasa.Medium.update_voice(existing_v, %{file_path: "/Users/ritesh/Desktop/sample-15s.mp3"}) + +{:ok, url} = Vyasa.Medium.Store.put(v) + +{:ok, again_v} = Vyasa.Medium.update_voice(v, %{file_path: url}) +again_v.file_path +``` + +```elixir R.recompile() -src = - Written.list_sources() - |> Enum.find(fn src -> src.title == "Gita" end) +alias Vyasa.Medium +alias Vyasa.Repo +alias Vyasa.Medium.{Voice, Store} +example_url = "/Users/ritesh/Desktop/example.mp3" + +{:ok, inserted_v} = + %Voice{ + lang: "sa", + file_path: example_url + } + |> Repo.insert() + +{:ok, stored_url} = Store.put(inserted_v) + +# {:ok, _output} = System.cmd("curl", ["-k", "-X", "PUT", "-T", stored_url, example_url]) + +System.cmd("curl", ["-k", "-X", "PUT", "-T", stored_url, example_url]) |> dbg() + +# curl -k -X PUT -T ./mpv-shot0002.mp3 "url" + +inserted_v +|> Medium.update_voice(%{file_path: stored_url}) +``` + +```elixir +Vyasa.Repo.all(Vyasa.Medium.Voice) ``` ##### Bala's working example of using the polymorphic translations @@ -314,17 +589,12 @@ Vyasa.Written.Translation.gen_changeset( |> Vyasa.Repo.insert() ``` -```elixir -id = Ecto.UUID.generate() -verses = G.get_verses(id) -chaps = G.get_chapters(verses, id) |> G.insert_verses_into_chapters(verses) -hd(chaps) -``` + + +## Playback ```elixir -R.recompile() -DBHelper.purge_db() -DBHelper.seed_db() +DateTime.diff(DateTime.utc_now(), DateT, :second) ``` @@ -348,15 +618,23 @@ sources ``` ```elixir -R.recompile() +# R.recompile() -alias Vyasa.Written +# alias Vyasa.Written -Written.list_sources() +# Written.list_sources() -[%Source{id: id} = _hd | tail] = Written.list_sources() -Written.get_source!(id) -Written.get_chapter!(2, id) +# [%Source{id: id} = _hd | tail] = Written.list_sources() +# Written.get_source!(id) +# Written.get_chapter!(2, id) + +# Vyasa.Repo.all(Vyasa.Medium.Voice) +``` + +### Exploration of the medium schemas + +```elixir +stored_url ``` diff --git a/docs/schema/vyasa_6feb_schema.png b/docs/schema/vyasa_6feb_schema.png new file mode 100644 index 00000000..94fbd065 Binary files /dev/null and b/docs/schema/vyasa_6feb_schema.png differ diff --git a/docs/scraper.livemd b/docs/scraper.livemd index a1732fa6..51df2d92 100644 --- a/docs/scraper.livemd +++ b/docs/scraper.livemd @@ -1,13 +1,195 @@ # Scraper Lite ```elixir -Mix.install([ - {:poison, "~> 5.0"}, - {:floki, "~> 0.35.2"}, - {:req, "~> 0.4.8"} -]) +# Mix.install([ +# {:poison, "~> 5.0"}, +# {:floki, "~> 0.35.2"}, +# {:req, "~> 0.4.8"}, +# {:youtube_captions, "~> 0.1.0"} +# ]) ``` +## Root + +```elixir +# defmodule R do +# def recompile() do +# Mix.Task.reenable("app.start") +# Mix.Task.reenable("compile") +# Mix.Task.reenable("compile.all") +# compilers = Mix.compilers() +# Enum.each(compilers, &Mix.Task.reenable("compile.#{&1}")) +# Mix.Task.run("compile.all") +# end +# end + +R.recompile() +``` + +```elixir +{:ok, src} = Vyasa.Written.create_source(%{title: "rama"}) +# Vyasa.Medium.Writer.init(%) +``` + +```elixir + +``` + + + +## Valmiki Ramayana + +```elixir +url = "https://www.valmikiramayan.net/utf8/" +# fetch from root frame +path = "baala/sarga1/" +doc = "balasans1.htm" + +col = + Finch.build(:get, url <> path <> doc) + |> Finch.request!(Vyasa.Finch) + |> Map.get(:body) + |> Floki.parse_document!() + |> Floki.find("body") + |> List.first() + |> elem(2) +``` + +```elixir +defmodule Rama do + # audio n+1 + def parse(data, acc) do + case {data, acc} do + {{"p", [{"class", "SanSloka"}], [{"audio", _, _} | _] = audio}, + [%{"count" => c} = curr | _] = acc} -> + # IO.inspect(audio, label: "audio") + [src] = + audio + |> Floki.find("source") + |> Floki.attribute("src") + + [ + curr + |> Map.put("count", c + 1) + |> Map.put("audio", src) + | acc + ] + + {{"p", [{"class", "SanSloka"}], [{"audio", _, _} | _] = audio}, []} -> + [src] = + audio + |> Floki.find("source") + |> Floki.attribute("src") + + [%{"count" => 1, "audio" => src}] + + # verse n + 1 + {{"p", [{"class", "SanSloka"}], verses}, [curr | acc]} -> + [Map.put(curr, "verse", verses |> Floki.text() |> String.trim()) | acc] + + # nesting in verloc + + {{"p", [{"class", "verloc"}], [{"p", [{"class", "SanSloka"}], sloka} | _] = ns_tree}, + [curr | _] = acc} + when is_map(curr) -> + # IO.inspect(ns_tree) + # IO.inspect(ns_tree) + Enum.reduce(ns_tree, acc, &parse/2) + + {{"p", [{"class", "verloc"}], rem} = c_tree, [curr | acc]} when is_map(curr) -> + [curr | acc] + + # # n case before verse break + {{"p", [{"class", class}], _} = c_tree, [curr | acc]} when is_map(curr) -> + [Map.put(curr, class, c_tree |> Floki.text() |> String.trim()) | acc] + + {para, acc} -> + # IO.inspect(para, label: "div") + acc + end + end +end + +output = + col + |> Enum.reduce([], &Rama.parse/2) + +# # formatting & tying loose ends +# clean_verses = +# [Map.put(curr, "count", count + 1) | verses] +# |> Enum.reverse() +``` + +```elixir +File.mkdir_p!(Path.expand(path, "media")) + +output +|> Enum.reduce([], fn + %{"audio" => aud, "count" => 12 = count, "verse" => verse}, acc -> + aud_bin = + Finch.build( + :get, + Path.join(url <> path, aud) + |> String.replace(~r/\.\//, "") + ) + |> Finch.request!(Vyasa.Finch) + |> Map.get(:body) + + m_path = Path.expand(path <> "/#{count}.mp3", "media") + File.write!(m_path, aud_bin) + + {:ok, + %Vyasa.Parser.MP3{ + duration: d, + path: p, + title: title + }} = Vyasa.Parser.MP3.parse(m_path) + + [ + %Vyasa.Medium.Event{ + origin: 0, + duration: d, + fragments: [%{status: "firstpass", quote: verse}] + } + | acc + ] + + _, acc -> + acc +end) +``` + +```elixir +aud = + output + |> Enum.find(&(Map.get(&1, "count") == 1)) + |> Map.get("audio") +``` + +```elixir +aud_bin = + Finch.build( + :get, + Path.join(url <> path, aud) + |> String.replace(~r/\.\//, "") + ) + |> Finch.request!(Vyasa.Finch) + |> Map.get(:body) +``` + +```elixir +# # IO.binwrite(path, aud) +# # |> :file.read_file_info() +# File.open(Path.expand([path,aud], "media"), [:write, :binary]) +# # |> IO.binwrite(aud_bin) +# Path.expand([path,aud], "media") +# # |> File.touch!() +File.mkdir_p!(Path.expand(path, "media")) +{:ok, file} = File.write!(Path.expand(path, "media"), aud_bin) +``` + + + ## Shlokam ```elixir @@ -15,7 +197,9 @@ url = "https://shlokam.org/" path = "hanumanchalisa" col = - Req.get!(url <> path).body + Finch.build(:get, url <> path) + |> Finch.request!(Vyasa.Finch) + |> Map.get(:body) |> Floki.parse_document!() |> Floki.find(".uncode_text_column") ``` @@ -110,3 +294,124 @@ map = %{ en_translation: en_translation } ``` + + + +## Gita Events + +```elixir +gita = Vyasa.Written.get_source_by_title("Gita") +verses = Vyasa.Written.get_verses_in_chapter(1, gita.id) +verse_lookup = Enum.into(for(%{id: id, no: verse_no} <- verses, do: {verse_no, id}), %{}) + +c1_path = Path.expand("./1.mp3", "media/gita") + +{:ok, + %Vyasa.Parser.MP3{ + duration: tot_d, + path: p + }} = Vyasa.Parser.MP3.parse(c1_path) + +{:ok, voice} = + Vyasa.Medium.create_voice(%{lang: "sa", duration: tot_d, file_path: c1_path, source_id: gita.id}) + +# Vyasa.Medium.get_voice!("4c73fb6d-4163-4b64-90d0-5d49680c1ee4") +# |> Vyasa.Medium.delete_voice() +``` + +```elixir +""" +start :- 00:00 +Shloka 1:- 00:33 +Shloka 2 :- 00:49 +Shloka 3:- 01:06 +Shloka 4:- 01:19 +Shloka 5:- 01:32 +Shloka 6:- 01:46 +Shloka 7:- 02:00 +Shloka 8:- 02:15 +Shloka 9:- 02:28 +Shloka 10:- 02:42 +Shloka 11:- 02:56 +Shloka 12:- 03:09 +Shloka 13:- 03:22 +Shloka 14:- 03:36 +Shloka 15:- 03:49 +Shloka 16:- 04:02 +Shloka 17:- 04:14 +Shloka 18:- 04:27 +Shloka 19:- 04:40 +Shloka 20:- 04:54 +Shloka 21:- 05:07 +Shloka 22:- 05:23 +Shloka 23:- 05:36 +Shloka 24:- 05:50 +Shloka 25:- 06:05 +Shloka 26:- 06:18 +Shloka 27:- 06:32 +Shloka 28:- 06:46 +Shloka 29:- 07:01 +Shloka 30:- 07:13 +Shloka 31:- 07:26 +Shloka 32 :- 07:38 +Shloka 33:- 07:52 +Shloka 34 :- 08:05 +Shloka 35 :- 08:18 +Shloka 36 :- 08:31 +Shloka 37:- 08:44 +Shloka 38 :- 08:57 +Shloka 39:- 09:09 +Shloka 40:- 09:22 +Shloka 41:- 09:35 +Shloka 42:- 09:48 +Shloka 43:- 10:02 +Shloka 44:- 10:16 +Shloka 45:- 10:29 +Shloka 46:- 10:40 +Shloka 47:- 10:53 +end:- 11:08 +""" +|> String.split("\n") +|> Enum.map(fn x -> + x + |> String.split(":-") + |> Enum.map(&String.trim/1) + |> Enum.reduce([], fn + <<"Shloka"::utf8, sep::utf8, verse_no::binary>>, acc -> + [verse_lookup[String.to_integer(verse_no)] | acc] + + bin, acc -> + [bin | acc] + end) +end) +|> IO.inspect(limit: :infinity) +|> Enum.reduce( + [], + fn + [time, "start"], acc -> + [%Vyasa.Medium.Event{origin: 0, phase: "start", voice_id: voice.id} | acc] + + [time, "end"], [%{origin: o} = prev | acc] -> + [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) + d = (min * 60 + sec) * 1000 + + [ + %Vyasa.Medium.Event{origin: d, duration: tot_d - d, phase: "end", voice_id: voice.id} + | [%{prev | duration: d - o} | acc] + ] + + [time, id], [%{origin: o} = prev | acc] -> + [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) + d = (min * 60 + sec) * 1000 + + [ + %Vyasa.Medium.Event{origin: d, verse_id: id, voice_id: voice.id} + | [%{prev | duration: d - o} | acc] + ] + + _, acc -> + acc + end +) +|> Enum.map(&Vyasa.Medium.create_event(&1)) +``` diff --git a/lib/vyasa/medium.ex b/lib/vyasa/medium.ex new file mode 100644 index 00000000..d3c0f373 --- /dev/null +++ b/lib/vyasa/medium.ex @@ -0,0 +1,193 @@ +defmodule Vyasa.Medium do + + import Ecto.Query, warn: false + alias Vyasa.Medium.{Voice, Event} + alias Vyasa.Medium + alias Vyasa.Repo + + + @doc """ + Gets a single voice. + + Raises `Ecto.NoResultsError` if the Voice does not exist. + + ## Examples + + iex> get_voice!(123) + %Voice{} + + iex> get_voice!(456) + ** (Ecto.NoResultsError) + + """ + def get_voice!(id) do + Repo.get!(Voice, id) + |> Repo.preload([:events]) + end + + + def get_voices!(%Voice{source_id: src_id, chapter_no: c_no, lang: l}) do + from(v in Voice, + where: v.source_id == ^src_id and v.chapter_no == ^c_no and v.lang == ^l, + preload: [:events]) + |> Repo.all() + end + + + @doc """ + Creates a voice. + + ## Examples + + iex> create_voice(%{field: value}) + {:ok, %Voice{}} + + iex> create_voice(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_voice(attrs \\ %{}) do + %Voice{} + |> Voice.gen_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a voice. + + ## Examples + + iex> update_voice(voice, %{field: new_value}) + {:ok, %Voice{}} + + iex> update_voice(voice, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_voice(%Voice{} = voice, attrs) do + voice + |> Voice.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a voice. + + ## Examples + + iex> delete_voice(voice) + {:ok, %Voice{}} + + iex> delete_voice(voice) + {:error, %Ecto.Changeset{}} + + """ + def delete_voice(%Voice{} = voice) do + Repo.delete(voice) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking voice changes. + + ## Examples + iex> change_voice(voice) + %Ecto.Changeset{data: %Voice{}} + """ + def change_voice(%Voice{} = voice, attrs \\ %{}) do + Voice.changeset(voice, attrs) + end + + @doc """ + Gets a single event. + + Raises `Ecto.NoResultsError` if the Event does not exist. + + ## Examples + + iex> get_event!(123) + %Event{} + + iex> get_event!(456) + ** (Ecto.NoResultsError) + + """ + def get_event!(id), do: Repo.get!(Event, id) + + + @doc """ + Creates a event. + + ## Examples + + iex> create_event(%{field: value}) + {:ok, %Event{}} + + iex> create_event(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + + def create_event(attrs \\ %{}) + def create_event(%Event{} = event) do + event + |> Repo.insert() + end + def create_event(attrs) do + %Event{} + |> Event.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a event. + + ## Examples + + iex> update_event(event, %{field: new_value}) + {:ok, %Event{}} + + iex> update_event(event, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_event(%Event{} = event, attrs) do + event + |> Event.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a event. + + ## Examples + + iex> delete_event(event) + {:ok, %Event{}} + + iex> delete_event(event) + {:error, %Ecto.Changeset{}} + + """ + def delete_event(%Event{} = event) do + Repo.delete(event) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking event changes. + + ## Examples + iex> change_event(event) + %Ecto.Changeset{data: %Event{}} + """ + def change_event(%Event{} = event, attrs \\ %{}) do + Event.changeset(event, attrs) + end + + def load_events(%Voice{} = voice) do + voice + |> Medium.get_voices!() + |> List.first() + |> Medium.Store.hydrate() + end + +end diff --git a/lib/vyasa/medium/event.ex b/lib/vyasa/medium/event.ex new file mode 100644 index 00000000..8e9822b2 --- /dev/null +++ b/lib/vyasa/medium/event.ex @@ -0,0 +1,40 @@ +defmodule Vyasa.Medium.Event do + use Ecto.Schema + + import Ecto.Changeset + + alias Vyasa.Written.{Verse, Source} + alias Vyasa.Medium.Voice + + @primary_key {:id, Ecto.UUID, autogenerate: true} + schema "events" do + field :origin, :integer + field :duration, :integer + field :phase, :string + + embeds_many :fragments, EventFrag do + field :offset, :integer + field :duration, :integer + field :quote, :string + field :status, :string + end + + belongs_to :verse, Verse, foreign_key: :verse_id, type: :binary_id + belongs_to :voice, Voice, foreign_key: :voice_id, type: :binary_id + belongs_to :source, Source, foreign_key: :source_id, type: :binary_id + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [:origin, :duration, :phase, :verse_id, :voice_id, :source_id]) + |> cast_embed(:fragments, with: &frag_changeset/2) + |> validate_required([:origin, :duration, :fragments, :source_id]) + |> validate_inclusion(:phase, ["start", "end"]) + end + + def frag_changeset(frag, attrs) do + frag + |> cast(attrs, [:offset, :duration,:quote]) + end +end diff --git a/lib/vyasa/medium/ext/s3client.ex b/lib/vyasa/medium/ext/s3client.ex new file mode 100644 index 00000000..77975fb1 --- /dev/null +++ b/lib/vyasa/medium/ext/s3client.ex @@ -0,0 +1,22 @@ +defmodule Vyasa.Medium.Ext.S3Client do + @behaviour ExAws.Request.HttpClient + + require Logger + + def request(method, url, body, headers, http_opts) do + case http_opts do + [] -> :noop + opts -> Logger.debug(inspect({:http_opts, opts})) + end + + with {:ok, resp} <- + Finch.build(method, url, headers, body) + |> Finch.request(Vyasa.Finch) do + IO + {:ok, %{status_code: resp.status, body: resp.body, headers: resp.headers}} + else + {:error, reason} -> + {:error, %{reason: reason}} + end + end +end diff --git a/lib/vyasa/medium/playback.ex b/lib/vyasa/medium/playback.ex new file mode 100644 index 00000000..abd08fd6 --- /dev/null +++ b/lib/vyasa/medium/playback.ex @@ -0,0 +1,27 @@ + defmodule Vyasa.Medium.Playback do + @moduledoc """ + The Playback struct is the bridge between written and media player contexts. + """ + + alias Vyasa.Medium + alias Vyasa.Medium.{Voice, Playback} + + defstruct [:medium, playing?: false, played_at: nil, paused_at: nil, elapsed: 0, current_time: 0] + + def new(%{} = attrs) do + %Vyasa.Medium.Playback{ + medium: attrs.medium, + playing?: attrs.playing?, + played_at: nil, # timestamps + paused_at: nil, # timestamps + elapsed: 0, # seconds TODO: convert to ms to standardise w HTML players? + } + end + + def create_playback(%Voice{} = voice) do + Playback.new(%{ + medium: voice |> Medium.load_events(), + playing?: false, + }) + end +end diff --git a/lib/vyasa/medium/store.ex b/lib/vyasa/medium/store.ex new file mode 100644 index 00000000..5af31aee --- /dev/null +++ b/lib/vyasa/medium/store.ex @@ -0,0 +1,97 @@ +defmodule Vyasa.Medium.Store do + @moduledoc """ + S3 Object Storage Service Communicator using HTTP POST sigv4 + https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html + """ + + alias Vyasa.Medium + + @bucket "vyasa" + + def path(st) when is_struct(st) do + #input and output paths + {local_path(st), path_constructor(st)} + end + + def get(st) when is_struct(st) do + signer(:get, path_constructor(st)) + end + + def get(path) do + signer(:get, path) + end + + def get!(st) when is_struct(st) do + signer!(:get, path_constructor(st)) + end + + def get!(path) do + signer!(:get, path) + end + + def put(struct) do + signer(:put, path_constructor(struct)) + end + + def put!(struct) do + signer!(:put, path_constructor(struct)) + end + + def hydrate(%Medium.Voice{} = voice) do + %{voice | file_path: get!(voice)} + end + + def hydrate(rt), do: rt + + + def s3_config do + %{ + region: System.fetch_env!("AWS_DEFAULT_REGION"), + bucket: @bucket, + access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), + secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") + } + end + + defp signer!(action, path) do + {:ok, url} = signer(action, path) + url + end + + defp signer(:headandget, path) do + ExAws.S3.head_object("orbistertius", path) + |> ExAws.request!() + ## with 200 + signer(:get, path) + ## else (pass signer link of fallback image function or nil) + end + + + defp signer(action, path) do + ExAws.Config.new(:s3, s3_config()) |> + ExAws.S3.presigned_url(action, @bucket, path, + [expires_in: 88888, virtual_host: false, query_params: [{"ContentType", "application/octet-stream"}]]) + end + + defp local_path(%Medium.Voice{file_path: local_path}) do + local_path + end + + defp path_constructor(%Medium.Voice{__meta__: %{source: type}, id: id}) do + "#{type}/#{id}.mp3" #default to mp3 ext for now + end + + defp path_constructor(%Medium.Voice{__meta__: %{source: type}, source: %{title: st}, meta: %{artists: [ artist | _]}}) do + "#{type}#{unless is_nil(st), + do: "/#{st}"}#{unless is_nil(artist), + do: "/#{artist}"}" + end + + + # defp path_suffix(full, prefix) do + # base = byte_size(prefix) + # <<_::binary-size(base), rest::binary>> = full + # rest + # end + +end diff --git a/lib/vyasa/medium/track.ex b/lib/vyasa/medium/track.ex new file mode 100644 index 00000000..5b55a727 --- /dev/null +++ b/lib/vyasa/medium/track.ex @@ -0,0 +1,28 @@ +defmodule Vyasa.Medium.Track do + use Ecto.Schema + import Ecto.Changeset + + alias Vyasa.Medium.Voice + + @primary_key {:id, Ecto.UUID, autogenerate: true} + schema "tracks" do + field :title, :string + has_many :voices, Voice + + timestamps(type: :utc_datetime) + end + + @doc false + def gen_changeset(track, attrs) do + track + |> cast(attrs, [:id, :title]) + |> cast_assoc(:verses) + |> validate_required([:title]) + end + + def mutate_changeset(track, attrs) do + track + |> cast(attrs, [:id, :title]) + |> cast_assoc(:verses) + end +end diff --git a/lib/vyasa/medium/video.ex b/lib/vyasa/medium/video.ex new file mode 100644 index 00000000..ed366494 --- /dev/null +++ b/lib/vyasa/medium/video.ex @@ -0,0 +1,23 @@ +defmodule Vyasa.Medium.Video do + use Ecto.Schema + import Ecto.Changeset + + alias Vyasa.Medium.Voice + + @primary_key {:id, Ecto.UUID, autogenerate: true} + schema "videos" do + field :type, :string + field :ext_uri, :string + + belongs_to :voice, Voice, type: Ecto.UUID + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(text, attrs) do + text + |> cast(attrs, [:title]) + |> validate_required([:title]) + end +end diff --git a/lib/vyasa/medium/voice.ex b/lib/vyasa/medium/voice.ex new file mode 100644 index 00000000..c9979866 --- /dev/null +++ b/lib/vyasa/medium/voice.ex @@ -0,0 +1,58 @@ +defmodule Vyasa.Medium.Voice do + use Ecto.Schema + import Ecto.Changeset + + alias Vyasa.Written.{Source, Chapter} + alias Vyasa.Medium.{Event, Video, Track} + + @primary_key {:id, Ecto.UUID, autogenerate: false} + schema "voices" do + field :lang, :string + field :title, :string + field :duration, :integer + field :file_path, :string, virtual: true + + embeds_one :meta, VoiceMetadata do + field(:artist, {:array, :string}) + end + + has_many :events, Event, references: :id, foreign_key: :voice_id, preload_order: [asc: :origin] + belongs_to :track, Track, references: :id, foreign_key: :track_id + belongs_to :chapter, Chapter, type: :integer, references: :no, foreign_key: :chapter_no + has_one :video, Video, references: :id, foreign_key: :voice_id + belongs_to :source, Source, references: :id, foreign_key: :source_id, type: :binary_id + + timestamps(type: :utc_datetime) + end + + @doc false + + def gen_changeset(voice, attrs) do + %{voice | id: Ecto.UUID.generate()} + |> cast(attrs, [:id, :title, :duration, :lang, :file_path, :chapter_no, :source_id]) + |> cast_embed(:meta, with: &meta_changeset/2) + |> file_upload() + end + + def changeset(voice, attrs) do + voice + |> cast(attrs, [:title, :duration, :lang, :file_path, :chapter_no, :source_id]) + |> cast_embed(:meta, with: &meta_changeset/2) + end + + def meta_changeset(voice, attrs) do + voice + |> cast(attrs, [:artist]) + end + + def file_upload(%Ecto.Changeset{changes: %{file_path: _} = changes} = ec) do + ext_path = apply_changes(ec) + |> Vyasa.Medium.Writer.run() + |> then(&elem(&1, 1).key) + |> Vyasa.Medium.Store.get!() + + %{ec | changes: %{changes | file_path: ext_path}} + end + + def file_upload(ec), do: ec +end diff --git a/lib/vyasa/medium/writer.ex b/lib/vyasa/medium/writer.ex new file mode 100644 index 00000000..bddbccd5 --- /dev/null +++ b/lib/vyasa/medium/writer.ex @@ -0,0 +1,52 @@ +defmodule Vyasa.Medium.Writer do + @behaviour Phoenix.LiveView.UploadWriter + + alias Vyasa.Medium.Store + + @impl true + def init(struct) do + {local_path, ext_path} = Store.path(struct) + with {:ok, file} <- File.open(local_path, [:binary, :write]), + %{bucket: bucket} = config <- Store.s3_config(), + s3_op <- ExAws.S3.initiate_multipart_upload(bucket, ext_path) do + {:ok, %{file: file, path: local_path, key: ext_path, chunk: 1, s3_op: s3_op, s3_config: ExAws.Config.new(:s3, config)}} + end + end + + def run(struct) do + {local_path, ext_path} = Store.path(struct) + with fs <- ExAws.S3.Upload.stream_file(local_path), + %{bucket: bucket} = cfg <- Store.s3_config(), + req <- ExAws.S3.upload(fs, bucket, ext_path), + {:ok, %{status_code: 200, body: body}} <- ExAws.request(req, config: cfg) do + {:ok, body} + else + {:err, err} -> {:err, err} + end + end + + @impl true + def meta(state) do + %{local_path: state.path, key: state.key} + end + + @impl true + def write_chunk(data, state) do + case IO.binwrite(state.file, data) do + :ok -> + part = ExAws.S3.Upload.upload_chunk!({data, state.chunk}, state.s3_op, state.s3_config) + {:ok, %{state | chunk: state.chunk+1, parts: [part | state.parts]}} + {:error, reason} -> {:error, reason, state} + end + end + + @impl true + def close(state, _reason) do + case {File.close(state.file), ExAws.S3.Upload.complete(state.parts, state.s3_op, state.s3_config)} do + {:ok, {:ok, _}} -> + {:ok, state} + {{:error, reason}, _} -> {:error, reason} + {_,{:error, reason}} -> {:error, reason} + end + end +end diff --git a/lib/vyasa/parser/env.ex b/lib/vyasa/parser/env.ex new file mode 100644 index 00000000..90923f52 --- /dev/null +++ b/lib/vyasa/parser/env.ex @@ -0,0 +1,323 @@ +defmodule Vyasa.Parser.Env do + @moduledoc """ + Simple parser for .env files. + + Supports simple variable–value pairs with upper and lower cased variables. Values are trimmed of extra whitespace. + + Blank lines and lines starting with `#` are ignored. Additionally inline comments can be added after values with a + `#`, i.e. `FOO=bar # comment`. + + Single quote or double quote value to prevent trimming of whitespace and allow usage of `#` in value, i.e. `FOO=' bar # not comment ' # comment`. + + Single quoted values don't do any unescaping. Double quoted values will unescape the following: + + * `\\n` - Linefeed + * `\\r` - Carriage return + * `\\t` - Tab + * `\\f` - Form feed + * `\\b` - Backspace + * `\\"` and `\\'` - Quotes + * `\\\\` - Backslash + * `\\uFFFF` - Unicode escape (4 hex characters to denote the codepoint) + * A backslash at the end of the line in a multiline value will remove the linefeed. + + Values can span multiple lines when single or double quoted: + + ```sh + MULTILINE="This is a + multiline value." + ``` + + This will result in the following: + + ```elixir + System.fetch_env!("MULTILINE") == "This is a\\nmultiline value." + ``` + + A line can start with `export ` for easier interoperation with regular shell scripts. These lines are treated the + same as any others. + + ## Serving suggestion + + If you load lots of environment variables in `config/runtime.exs`, you can easily configure them for development by + having an `.env` file in your development environment and using the parser at the start of the file: + + ```elixir + import Config + + if Config.config_env() == :dev do + Vyasa.Parser.Env.load_file(".env") + end + + # Now variables from `.env` are loaded into system env + config :your_project, + database_url: System.fetch_env!("DB_URL") + """ + + @linefeed_re ~r/\r?\n/ + @line_re ~r/^(?:\s*export)?\s*[a-z_][a-z_0-9]*\s*=/i + @dquoted_val_re ~r/^"([^"\\]*(?:\\.[^"\\]*)*)"\s*(?:#.*)?$/ + @squoted_val_re ~r/^\s*'(.*)'\s*(?:#.*)?$/ + @dquoted_multiline_end ~r/^([^"\\]*(?:\\.[^"\\]*)*)"\s*(?:#.*)?$/ + @squoted_multiline_end ~r/^(.*)'\s*(?:#.*)?$/ + @hex_re ~r/^[0-9a-f]+$/i + + @quote_chars ~w(" ') + + @typedoc "Pair of variable name, variable value." + @type value_pair :: {String.t(), String.t()} + + defmodule ParseError do + @moduledoc "Error raised when a line cannot be parsed." + defexception [:message] + end + + defmodule Continuation do + @typedoc """ + A multiline value continuation. When a function returns this, it means that a multiline value + was started and more needs to be parsed to get the rest of the value. + """ + @type t :: %__MODULE__{ + name: String.t(), + value: String.t(), + start_quote: String.t() + } + @enforce_keys [:name, :value, :start_quote] + defstruct [:name, :value, :start_quote] + end + + @doc """ + Parse given file and load the variables to the environment. + + If a line cannot be parsed or the file cannot be read, an error is raised and no values are loaded to the + environment. + """ + @spec load_file(String.t()) :: :ok + def load_file(file) do + file + |> File.read!() + |> load_data() + end + + @doc """ + Parse given data and load the variables to the environment. + + If a line cannot be parsed, an error is raised and no values are loaded to the environment. + """ + @spec load_data(String.t()) :: :ok + def load_data(data) do + data + |> parse_data() + |> Enum.each(fn {var, val} -> System.put_env(var, val) end) + end + + @doc """ + Parse given file and return a list of variable–value tuples. + + If a line cannot be parsed or the file cannot be read, an error is raised. + """ + @spec parse_file(String.t()) :: [value_pair()] + def parse_file(file) do + file + |> File.read!() + |> parse_data() + end + + @doc """ + Parse given data and return a list of variable–value tuples. + + If a line cannot be parsed, an error is raised. + """ + @spec parse_data(String.t()) :: [value_pair()] + def parse_data(data) do + {value_pairs, continuation} = + data + |> String.split(@linefeed_re) + |> Enum.reduce({[], nil}, fn + line, {ret, nil} -> + trimmed = String.trim(line) + + if not is_comment?(trimmed) and not is_blank?(trimmed) do + reduce_line(ret, line, nil) + else + {ret, nil} + end + + line, {ret, continuation} -> + reduce_line(ret, line, continuation) + end) + + if not is_nil(continuation) do + raise ParseError, + "Could not find end for quote #{continuation.start_quote} in variable #{continuation.name}" + end + + Enum.reverse(value_pairs) + end + + @doc """ + Parse given single line and return a variable–value tuple, or a continuation value if the line + started or continued a multiline value. + + If line cannot be parsed, an error is raised. + + The second argument needs to be `nil` or a continuation value returned from parsing the previous + line. + """ + @spec parse_line(String.t(), Continuation.t() | nil) :: value_pair() | Continuation.t() + def parse_line(line, state) + + def parse_line(line, nil) do + if not Regex.match?(@line_re, line) do + raise ParseError, "Malformed line cannot be parsed: #{line}" + else + [var, val] = String.split(line, "=", parts: 2) + var = var |> String.trim() |> String.replace_leading("export ", "") + trimmed = String.trim(val) + + with {:dquoted, nil} <- {:dquoted, Regex.run(@dquoted_val_re, trimmed)}, + {:squoted, nil} <- {:squoted, Regex.run(@squoted_val_re, trimmed)}, + trimmed_leading = String.trim_leading(val), + {:quoted_start, false} <- + {:quoted_start, String.starts_with?(trimmed_leading, @quote_chars)} do + # Value is plain value + {var, trimmed |> remove_comment() |> String.trim()} + else + {:dquoted, [_, inner_val]} -> + {var, stripslashes(inner_val)} + + {:squoted, [_, inner_val]} -> + {var, inner_val} + + {:quoted_start, _} -> + parse_multiline_start(var, val) + end + end + end + + def parse_line(line, %Continuation{} = continuation) do + trimmed = String.trim_trailing(line) + + end_match = + if continuation.start_quote == "\"" do + Regex.run(@dquoted_multiline_end, trimmed) + else + Regex.run(@squoted_multiline_end, trimmed) + end + + with [_, line_content] <- end_match do + ret = maybe_stripslashes(continuation, line_content) + {continuation.name, continuation.value <> ret} + else + _ -> + next_line = maybe_stripslashes(continuation, line) + next_line = maybe_linefeed(continuation, next_line) + + %Continuation{ + continuation + | value: continuation.value <> next_line + } + end + end + + @spec parse_multiline_start(String.t(), String.t()) :: Continuation.t() + defp parse_multiline_start(name, input) do + {start_quote, rest} = input |> String.trim_leading() |> String.split_at(1) + + continuation = %Continuation{ + name: name, + value: "", + start_quote: start_quote + } + + value = maybe_stripslashes(continuation, rest) + value = maybe_linefeed(continuation, value) + + %Continuation{continuation | value: value} + end + + @spec reduce_line([value_pair()], String.t(), Continuation.t() | nil) :: + {[value_pair()], Continuation.t() | nil} + defp reduce_line(ret, line, continuation) do + case parse_line(line, continuation) do + %Continuation{} = new_continuation -> + {ret, new_continuation} + + result -> + {[result | ret], nil} + end + end + + @spec remove_comment(String.t()) :: String.t() + defp remove_comment(val) do + case String.split(val, "#", parts: 2) do + [true_val, _comment] -> true_val + [true_val] -> true_val + end + end + + @spec is_comment?(String.t()) :: boolean() + defp is_comment?(line) + defp is_comment?("#" <> _rest), do: true + defp is_comment?(_line), do: false + + @spec is_blank?(String.t()) :: boolean() + defp is_blank?(line) + defp is_blank?(""), do: true + defp is_blank?(_line), do: false + + @spec stripslashes(String.t(), :slash | :no_slash, String.t()) :: String.t() + defp stripslashes(input, mode \\ :no_slash, acc \\ "") + + defp stripslashes("\\" <> rest, :no_slash, acc) do + stripslashes(rest, :slash, acc) + end + + defp stripslashes("", :no_slash, acc), do: acc + + defp stripslashes(input, :no_slash, acc) do + case String.split(input, "\\", parts: 2) do + [all] -> acc <> all + [head, tail] -> stripslashes(tail, :slash, acc <> head) + end + end + + defp stripslashes("n" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\n") + defp stripslashes("r" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\r") + defp stripslashes("t" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\t") + defp stripslashes("f" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\f") + defp stripslashes("b" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\b") + defp stripslashes("\"" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\"") + defp stripslashes("'" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "'") + defp stripslashes("\\" <> rest, :slash, acc), do: stripslashes(rest, :no_slash, acc <> "\\") + + defp stripslashes(<<"u", hex::binary-size(4), rest::binary>>, :slash, acc) do + with true <- Regex.match?(@hex_re, hex), + {int, _rest} <- Integer.parse(hex, 16) do + stripslashes(rest, :no_slash, acc <> <>) + else + _ -> stripslashes(rest, :no_slash, acc <> "\\u" <> hex) + end + end + + defp stripslashes(input, :slash, acc), do: stripslashes(input, :no_slash, acc <> "\\") + + @spec maybe_stripslashes(Continuation.t(), String.t()) :: String.t() + defp maybe_stripslashes(continuation, input) + + defp maybe_stripslashes(%Continuation{start_quote: "\""}, input), do: stripslashes(input) + defp maybe_stripslashes(_, input), do: input + + @spec maybe_linefeed(Continuation.t(), String.t()) :: String.t() + defp maybe_linefeed(continuation, input) + + defp maybe_linefeed(%Continuation{start_quote: "\""}, input) do + if String.ends_with?(input, "\\") do + String.slice(input, 0..-2) + else + input <> "\n" + end + end + + defp maybe_linefeed(_, input), do: input <> "\n" +end diff --git a/lib/vyasa/parser/mp3.ex b/lib/vyasa/parser/mp3.ex new file mode 100644 index 00000000..ece61245 --- /dev/null +++ b/lib/vyasa/parser/mp3.ex @@ -0,0 +1,398 @@ +defmodule Vyasa.Parser.MP3 do + @moduledoc """ + Decodes MP3s and parses out information. + + MP3 decoding and duration calculation credit to: + https://shadowfacts.net/2021/mp3-duration/ + """ + import Bitwise + + defstruct duration: 0, size: 0, path: nil, title: nil, artist: nil, tags: nil + + @declared_frame_ids ~w(AENC APIC ASPI COMM COMR ENCR EQU2 ETCO GEOB GRID LINK MCDI MLLT OWNE PRIV PCNT POPM POSS RBUF RVA2 RVRB SEEK SIGN SYLT SYTC TALB TBPM TCOM TCON TCOP TDEN TDLY TDOR TDRC TDRL TDTG TENC TEXT TFLT TIPL TIT1 TIT2 TIT3 TKEY TLAN TLEN TMCL TMED TMOO TOAL TOFN TOLY TOPE TOWN TPE1 TPE2 TPE3 TPE4 TPOS TPRO TPUB TRCK TRSN TRSO TSOA TSOP TSOT TSRC TSSE TSST TXXX UFID USER USLT WCOM WCOP WOAF WOAR WOAS WORS WPAY WPUB WXXX) + + @v1_l1_bitrates {:invalid, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, + :invalid} + @v1_l2_bitrates {:invalid, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, + :invalid} + @v1_l3_bitrates {:invalid, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, + :invalid} + @v2_l1_bitrates {:invalid, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, + :invalid} + @v2_l2_l3_bitrates {:invalid, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, + :invalid} + + def to_mmss(duration) when is_integer(duration) do + hours = div(duration, 60 * 60) + minutes = div(duration - hours * 60 * 60, 60) + seconds = rem(duration - hours * 60 * 60 - minutes * 60, 60) + + [minutes, seconds] + |> Enum.map(fn count -> String.pad_leading("#{count}", 2, ["0"]) end) + |> Enum.join(":") + end + + def parse(path) do + stat = File.stat!(path) + {tag_info, rest} = parse_tag(File.read!(path)) + duration = parse_frame(rest, 0, 0, 0) + + case duration do + duration when is_float(duration) and duration > 0 -> + title = Enum.at(tag_info["TIT2"] || [], 0) + artist = Enum.at(tag_info["TPE1"] || [], 0) + seconds = round(duration * 1000) + + {:ok, + %__MODULE__{ + duration: seconds, + size: stat.size, + path: path, + tags: tag_info, + title: title, + artist: artist + }} + + _other -> + {:error, :bad_file} + end + rescue + _ -> {:error, :bad_file} + end + + defp parse_tag(<< + "ID3", + major_version::integer, + _revision::integer, + _unsynchronized::size(1), + extended_header::size(1), + _experimental::size(1), + _footer::size(1), + 0::size(4), + tag_size_synchsafe::binary-size(4), + rest::binary + >>) do + tag_size = decode_synchsafe_integer(tag_size_synchsafe) + + {rest, ext_header_size} = + if extended_header == 1 do + skip_extended_header(major_version, rest) + else + {rest, 0} + end + + parse_frames(major_version, rest, tag_size - ext_header_size, []) + end + + defp parse_tag(<< + _first::integer, + _second::integer, + _third::integer, + rest::binary + >>) do + # has no ID3 + {%{}, rest} + end + + defp parse_tag(_), do: {%{}, ""} + + defp decode_synchsafe_integer(<>), do: bin + + defp decode_synchsafe_integer(binary) do + binary + |> :binary.bin_to_list() + |> Enum.reverse() + |> Enum.with_index() + |> Enum.reduce(0, fn {el, index}, acc -> acc ||| el <<< (index * 7) end) + end + + defp skip_extended_header(3, << + ext_header_size::size(32), + _flags::size(16), + _padding_size::size(32), + rest::binary + >>) do + remaining_ext_header_size = ext_header_size - 6 + <<_::binary-size(remaining_ext_header_size), rest::binary>> = rest + {rest, ext_header_size} + end + + defp skip_extended_header(4, << + ext_header_size_synchsafe::size(32), + 1::size(8), + _flags::size(8), + rest::binary + >>) do + ext_header_size = decode_synchsafe_integer(ext_header_size_synchsafe) + remaining_ext_header_size = ext_header_size - 6 + <<_::binary-size(remaining_ext_header_size), rest::binary>> = rest + {rest, ext_header_size} + end + + defp parse_frames(_, data, tag_length_remaining, frames) + when tag_length_remaining <= 0 do + {Map.new(frames), data} + end + + defp parse_frames( + major_version, + << + frame_id::binary-size(4), + frame_size_maybe_synchsafe::binary-size(4), + 0::size(1), + _tag_alter_preservation::size(1), + _file_alter_preservation::size(1), + _read_only::size(1), + 0::size(4), + _grouping_identity::size(1), + 0::size(2), + _compression::size(1), + _encryption::size(1), + _unsynchronized::size(1), + _has_data_length_indicator::size(1), + _unused::size(1), + rest::binary + >>, + tag_length_remaining, + frames + ) do + frame_size = + case major_version do + 4 -> + decode_synchsafe_integer(frame_size_maybe_synchsafe) + + 3 -> + <> = frame_size_maybe_synchsafe + size + end + + total_frame_size = frame_size + 10 + next_tag_length_remaining = tag_length_remaining - total_frame_size + + result = decode_frame(frame_id, frame_size, rest) + + case result do + {nil, rest, :halt} -> + {Map.new(frames), rest} + + {nil, rest, :cont} -> + parse_frames(major_version, rest, next_tag_length_remaining, frames) + + {new_frame, rest} -> + parse_frames(major_version, rest, next_tag_length_remaining, [new_frame | frames]) + end + end + + defp parse_frames(_, data, _, frames) do + {Map.new(frames), data} + end + + defp decode_frame("TXXX", frame_size, <>) do + {description, desc_size, rest} = decode_string(text_encoding, frame_size - 1, rest) + {value, _, rest} = decode_string(text_encoding, frame_size - 1 - desc_size, rest) + {{"TXXX", {description, value}}, rest} + end + + defp decode_frame( + "COMM", + frame_size, + <> + ) do + {short_desc, desc_size, rest} = decode_string(text_encoding, frame_size - 4, rest) + {value, _, rest} = decode_string(text_encoding, frame_size - 4 - desc_size, rest) + {{"COMM", {language, short_desc, value}}, rest} + end + + defp decode_frame("APIC", frame_size, <>) do + {mime_type, mime_len, rest} = decode_string(0, frame_size - 1, rest) + + <> = rest + + {description, desc_len, rest} = + decode_string(text_encoding, frame_size - 1 - mime_len - 1, rest) + + image_data_size = frame_size - 1 - mime_len - 1 - desc_len + {image_data, rest} = :erlang.split_binary(rest, image_data_size) + + {{"APIC", {mime_type, picture_type, description, image_data}}, rest} + end + + defp decode_frame(id, frame_size, rest) do + cond do + Regex.match?(~r/^T[0-9A-Z]+$/, id) -> + decode_text_frame(id, frame_size, rest) + + id in @declared_frame_ids -> + <<_frame_data::binary-size(frame_size), rest::binary>> = rest + {nil, rest, :cont} + + true -> + {nil, rest, :halt} + end + end + + defp decode_text_frame(id, frame_size, <>) do + {strs, rest} = decode_string_sequence(text_encoding, frame_size - 1, rest) + {{id, strs}, rest} + end + + defp decode_string_sequence(encoding, max_byte_size, data, acc \\ []) + + defp decode_string_sequence(_, max_byte_size, data, acc) when max_byte_size <= 0 do + {Enum.reverse(acc), data} + end + + defp decode_string_sequence(encoding, max_byte_size, data, acc) do + {str, str_size, rest} = decode_string(encoding, max_byte_size, data) + decode_string_sequence(encoding, max_byte_size - str_size, rest, [str | acc]) + end + + defp convert_string(encoding, str) when encoding in [0, 3] do + str + end + + defp convert_string(1, data) do + {encoding, bom_length} = :unicode.bom_to_encoding(data) + {_, string_data} = String.split_at(data, bom_length) + :unicode.characters_to_binary(string_data, encoding) + end + + defp convert_string(2, data) do + :unicode.characters_to_binary(data, {:utf16, :big}) + end + + defp decode_string(encoding, max_byte_size, data) when encoding in [1, 2] do + {str, rest} = get_double_null_terminated(data, max_byte_size) + + {convert_string(encoding, str), byte_size(str) + 2, rest} + end + + defp decode_string(encoding, max_byte_size, data) when encoding in [0, 3] do + case :binary.split(data, <<0>>) do + [str, rest] when byte_size(str) + 1 <= max_byte_size -> + {str, byte_size(str) + 1, rest} + + _ -> + {str, rest} = :erlang.split_binary(data, max_byte_size) + {str, max_byte_size, rest} + end + end + + defp get_double_null_terminated(data, max_byte_size, acc \\ []) + + defp get_double_null_terminated(rest, 0, acc) do + {acc |> Enum.reverse() |> :binary.list_to_bin(), rest} + end + + defp get_double_null_terminated(<<0, 0, rest::binary>>, _, acc) do + {acc |> Enum.reverse() |> :binary.list_to_bin(), rest} + end + + defp get_double_null_terminated(<>, max_byte_size, acc) do + next_max_byte_size = max_byte_size - 2 + get_double_null_terminated(rest, next_max_byte_size, [b, a | acc]) + end + + defp parse_frame( + << + 0xFF::size(8), + 0b111::size(3), + version_bits::size(2), + layer_bits::size(2), + _protected::size(1), + bitrate_index::size(4), + sampling_rate_index::size(2), + padding::size(1), + _private::size(1), + _channel_mode_index::size(2), + _mode_extension::size(2), + _copyright::size(1), + _original::size(1), + _emphasis::size(2), + _rest::binary + >> = data, + acc, + frame_count, + offset + ) do + with version when version != :invalid <- lookup_version(version_bits), + layer when layer != :invalid <- lookup_layer(layer_bits), + sampling_rate when sampling_rate != :invalid <- + lookup_sampling_rate(version, sampling_rate_index), + bitrate when bitrate != :invalid <- lookup_bitrate(version, layer, bitrate_index) do + samples = lookup_samples_per_frame(version, layer) + frame_size = get_frame_size(samples, layer, bitrate, sampling_rate, padding) + frame_duration = samples / sampling_rate + <<_skipped::binary-size(frame_size), rest::binary>> = data + parse_frame(rest, acc + frame_duration, frame_count + 1, offset + frame_size) + else + _ -> + <<_::size(8), rest::binary>> = data + parse_frame(rest, acc, frame_count, offset + 1) + end + end + + defp parse_frame(<<_::size(8), rest::binary>>, acc, frame_count, offset) do + parse_frame(rest, acc, frame_count, offset + 1) + end + + defp parse_frame(<<>>, acc, _frame_count, _offset) do + acc + end + + defp lookup_version(0b00), do: :version25 + defp lookup_version(0b01), do: :invalid + defp lookup_version(0b10), do: :version2 + defp lookup_version(0b11), do: :version1 + + defp lookup_layer(0b00), do: :invalid + defp lookup_layer(0b01), do: :layer3 + defp lookup_layer(0b10), do: :layer2 + defp lookup_layer(0b11), do: :layer1 + + defp lookup_sampling_rate(_version, 0b11), do: :invalid + defp lookup_sampling_rate(:version1, 0b00), do: 44100 + defp lookup_sampling_rate(:version1, 0b01), do: 48000 + defp lookup_sampling_rate(:version1, 0b10), do: 32000 + defp lookup_sampling_rate(:version2, 0b00), do: 22050 + defp lookup_sampling_rate(:version2, 0b01), do: 24000 + defp lookup_sampling_rate(:version2, 0b10), do: 16000 + defp lookup_sampling_rate(:version25, 0b00), do: 11025 + defp lookup_sampling_rate(:version25, 0b01), do: 12000 + defp lookup_sampling_rate(:version25, 0b10), do: 8000 + + defp lookup_bitrate(_version, _layer, 0), do: :invalid + defp lookup_bitrate(_version, _layer, 0xF), do: :invalid + defp lookup_bitrate(:version1, :layer1, index), do: elem(@v1_l1_bitrates, index) + defp lookup_bitrate(:version1, :layer2, index), do: elem(@v1_l2_bitrates, index) + defp lookup_bitrate(:version1, :layer3, index), do: elem(@v1_l3_bitrates, index) + + defp lookup_bitrate(v, :layer1, index) when v in [:version2, :version25], + do: elem(@v2_l1_bitrates, index) + + defp lookup_bitrate(v, l, index) when v in [:version2, :version25] and l in [:layer2, :layer3], + do: elem(@v2_l2_l3_bitrates, index) + + defp lookup_samples_per_frame(:version1, :layer1), do: 384 + defp lookup_samples_per_frame(:version1, :layer2), do: 1152 + defp lookup_samples_per_frame(:version1, :layer3), do: 1152 + defp lookup_samples_per_frame(v, :layer1) when v in [:version2, :version25], do: 384 + defp lookup_samples_per_frame(v, :layer2) when v in [:version2, :version25], do: 1152 + defp lookup_samples_per_frame(v, :layer3) when v in [:version2, :version25], do: 576 + + defp get_frame_size(samples, layer, kbps, sampling_rate, padding) do + sample_duration = 1 / sampling_rate + frame_duration = samples * sample_duration + bytes_per_second = kbps * 1000 / 8 + size = floor(frame_duration * bytes_per_second) + + if padding == 1 do + size + lookup_slot_size(layer) + else + size + end + end + + defp lookup_slot_size(:layer1), do: 4 + defp lookup_slot_size(l) when l in [:layer2, :layer3], do: 1 +end diff --git a/lib/vyasa/pubsub.ex b/lib/vyasa/pubsub.ex new file mode 100644 index 00000000..b795c373 --- /dev/null +++ b/lib/vyasa/pubsub.ex @@ -0,0 +1,48 @@ +defmodule Vyasa.PubSub do + @moduledoc """ + Publish Subscriber Pattern + """ + alias Phoenix.PubSub + + def subscribe(topic, opts \\ []) do + PubSub.subscribe(Vyasa.PubSub, topic, opts) + end + + def unsubscribe(topic) do + PubSub.unsubscribe(Vyasa.PubSub, topic) + end + + def publish({:ok, message}, event, topics) when is_list(topics) do + topics + |> Enum.map(fn topic -> publish(message, event, topic) end) + {:ok, message} + end + + def publish({:ok, message}, event, topic) do + PubSub.broadcast(Vyasa.PubSub, topic, {__MODULE__, event, message}) + {:ok, message} + end + + def publish(message, event, topics) when is_list(topics) do + topics |> Enum.map(fn topic -> publish(message, event, topic) end) + message + end + + def publish(%Vyasa.Medium.Voice{} = voice, event, sess_id) do + PubSub.broadcast(__MODULE__, "media:session:#{sess_id}", {__MODULE__, event, voice}) + voice + end + + def publish(message, event, topic) when not is_nil(topic) do + PubSub.broadcast(Vyasa.PubSub, topic, {__MODULE__, event, message}) + message + end + + def publish(message, _event, _topic) do + message + end + + def publish({:error, reason}, _event) do + {:error, reason} + end +end diff --git a/lib/vyasa/written.ex b/lib/vyasa/written.ex index 1b96a1aa..07004dab 100644 --- a/lib/vyasa/written.ex +++ b/lib/vyasa/written.ex @@ -6,7 +6,24 @@ defmodule Vyasa.Written do import Ecto.Query, warn: false alias Vyasa.Repo - alias Vyasa.Written.{Text, Source, Chapter} + alias Vyasa.Written.{Text, Source, Verse, Chapter, Translation} + + @doc """ + Guards for any uuidV4 + + ## Examples + + iex> is_uuid?("hanuman") + false + + """ + defguard is_uuid?(value) + when is_bitstring(value) and + byte_size(value) == 36 and + binary_part(value, 8, 1) == "-" and + binary_part(value, 13, 1) == "-" and + binary_part(value, 18, 1) == "-" and + binary_part(value, 23, 1) == "-" @doc """ Returns the list of texts. @@ -35,6 +52,35 @@ defmodule Vyasa.Written do |> Repo.preload([:chapters, :verses]) end + @doc """ + Returns the list of verses. + + ## Examples + + iex> list_verses() + [%Verse{}, ...] + + """ + def list_verses do + Repo.all(Verse) + |> Repo.preload([:chapter]) + end + + @doc """ + Returns the list of chapters. + + ## Examples + + iex> list_chapters() + [%Chapter{}, ...] + + """ + def list_chapters do + Repo.all(Chapter) + |> Repo.preload([:verses]) + end + + @doc """ Gets a single text. @@ -68,7 +114,6 @@ defmodule Vyasa.Written do """ def get_source!(id), do: Repo.get!(Source, id) |> Repo.preload([:chapters, :verses]) - |> Repo.preload(:verses) def get_source_by_title(title) do query = from src in Source, @@ -79,9 +124,19 @@ defmodule Vyasa.Written do end def get_chapter(no, source_title) do - src = get_source_by_title(source_title) - Repo.get_by(Chapter, no: no, source_id: src.id) - |> Repo.preload([:translations, verses: [:translations]]) + (from c in Chapter, where: c.no == ^no, + inner_join: src in assoc(c, :source), + where: src.title == ^source_title) + |> Repo.one() + end + + def get_chapter(no, source_title, lang) do + target_lang = (from ts in Translation, where: ts.lang == ^lang) + (from c in Chapter, where: c.no == ^no, + inner_join: src in assoc(c, :source), + where: src.title == ^source_title, + preload: [verses: ^(from v in Verse, preload: [translations: ^target_lang]) , translations: ^target_lang]) + |> Repo.one() end def get_verses_in_chapter(no, source_id) do @@ -153,4 +208,67 @@ defmodule Vyasa.Written do def change_text(%Text{} = text, attrs \\ %{}) do Text.changeset(text, attrs) end + + @doc """ + Creates a source. + + ## Examples + + iex> create_source(%{field: value}) + {:ok, %Source{}} + + iex> create_source(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_source(attrs \\ %{}) do + %Source{} + |> Source.gen_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a source. + + ## Examples + + iex> update_source(source, %{field: new_value}) + {:ok, %Source{}} + + iex> update_source(source, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_source(%Source{} = source, attrs) do + source + |> Source.mutate_changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a source. + + ## Examples + + iex> delete_source(source) + {:ok, %Source{}} + + iex> delete_source(source) + {:error, %Ecto.Changeset{}} + + """ + def delete_source(%Source{} = source) do + Repo.delete(source) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking source changes. + + ## Examples + iex> change_source(source) + %Ecto.Changeset{data: %Source{}} + """ + def change_source(%Source{} = source, attrs \\ %{}) do + Source.mutate_changeset(source, attrs) end +end diff --git a/lib/vyasa/written/chapter.ex b/lib/vyasa/written/chapter.ex index d4208323..721a9832 100644 --- a/lib/vyasa/written/chapter.ex +++ b/lib/vyasa/written/chapter.ex @@ -2,17 +2,21 @@ defmodule Vyasa.Written.Chapter do use Ecto.Schema import Ecto.Changeset - alias Vyasa.Written.{Source, Verse, Translation} + alias Vyasa.Written.{Source, Verse, Translation, Chapter} + alias Vyasa.Medium.{Voice} @primary_key false schema "chapters" do field :no, :integer, primary_key: :true + field :key, :string field :body, :string field :title, :string + belongs_to :chapter, Chapter, references: :no, foreign_key: :parent_no belongs_to :source, Source, references: :id, foreign_key: :source_id, type: :binary_id, primary_key: :true has_many :verses, Verse, references: :no, foreign_key: :chapter_no has_many :translations, Translation, references: :no, foreign_key: :chapter_no + has_many :voices, Voice, references: :no, foreign_key: :chapter_no end @doc false diff --git a/lib/vyasa/written/medium.ex b/lib/vyasa/written/medium.ex deleted file mode 100644 index 55499225..00000000 --- a/lib/vyasa/written/medium.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Vyasa.Written.Medium do - use Ecto.Schema - # import Ecto.Changeset - - alias Vyasa.Written.{Verse} - - @primary_key {:id, Ecto.UUID, autogenerate: true} - schema "media" do - # TODO: create schema for transcripts... - belongs_to :verse, Verse - end - - # @doc false - # def changeset(text, _attrs) do - # text - # # |> cast(attrs, [:title]) - # # |> validate_required([:title]) - # end -end diff --git a/lib/vyasa/written/transcript.ex b/lib/vyasa/written/transcript.ex deleted file mode 100644 index 33a1d33b..00000000 --- a/lib/vyasa/written/transcript.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule Vyasa.Written.Transcript do - use Ecto.Schema - # import Ecto.Changeset - - alias Vyasa.Written.{Verse} - - @primary_key {:id, Ecto.UUID, autogenerate: true} - schema "transcripts" do - # TODO: create schema for transcripts... - belongs_to :verse, Verse - end -end diff --git a/lib/vyasa/written/verse.ex b/lib/vyasa/written/verse.ex index cf70f280..3485b74a 100644 --- a/lib/vyasa/written/verse.ex +++ b/lib/vyasa/written/verse.ex @@ -2,7 +2,7 @@ defmodule Vyasa.Written.Verse do use Ecto.Schema import Ecto.Changeset - alias Vyasa.Written.{Source, Chapter, Translation, Transcript, Medium} + alias Vyasa.Written.{Source, Chapter, Translation} @primary_key {:id, Ecto.UUID, autogenerate: true} schema "verses" do @@ -12,8 +12,6 @@ defmodule Vyasa.Written.Verse do belongs_to :source, Source, type: Ecto.UUID belongs_to :chapter, Chapter, type: :integer, references: :no, foreign_key: :chapter_no has_many :translations, Translation - has_many :transcripts, Transcript - has_many :media, Medium end @doc false @@ -21,8 +19,6 @@ defmodule Vyasa.Written.Verse do text |> cast(attrs, [:body, :no, :source_id]) |> cast_assoc(:translations) - |> cast_assoc(:transcripts) - |> cast_assoc(:media) |> validate_required([:no, :body]) end end diff --git a/lib/vyasa_web.ex b/lib/vyasa_web.ex index e8c53dd5..d52574a6 100644 --- a/lib/vyasa_web.ex +++ b/lib/vyasa_web.ex @@ -49,6 +49,23 @@ defmodule VyasaWeb do end end + def live_view(opts) do + quote do + @opts Keyword.merge( + [ + layout: {VyasaWeb.Layouts, :app}, + container: {:div, class: "relative h-screen flex overflow-hidden bg-white"} + ], + unquote(opts) + ) + + use Phoenix.LiveView, @opts + + unquote(html_helpers()) + + end + end + def live_view do quote do use Phoenix.LiveView, @@ -58,6 +75,8 @@ defmodule VyasaWeb do end end + + def live_component do quote do use Phoenix.LiveComponent @@ -104,10 +123,16 @@ defmodule VyasaWeb do end end + @doc """ When used, dispatch to the appropriate controller/view/etc. """ + defmacro __using__({which, opts}) when is_atom(which) do + apply(__MODULE__, which, [opts]) + end + defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end -end + + end diff --git a/lib/vyasa_web/components/layouts/app.html.heex b/lib/vyasa_web/components/layouts/app.html.heex index 038bc210..f298710e 100644 --- a/lib/vyasa_web/components/layouts/app.html.heex +++ b/lib/vyasa_web/components/layouts/app.html.heex @@ -17,6 +17,7 @@ + <%= live_render(@socket, VyasaWeb.MediaLive.Player, id: "MediaPlayer", session: @session, sticky: true) %>
diff --git a/lib/vyasa_web/components/layouts/root.html.heex b/lib/vyasa_web/components/layouts/root.html.heex index 19d988a9..ea1e6741 100644 --- a/lib/vyasa_web/components/layouts/root.html.heex +++ b/lib/vyasa_web/components/layouts/root.html.heex @@ -15,6 +15,6 @@ <.meta_tags contents={assigns[:meta]} /> - <%= @inner_content %> + <%= @inner_content %> diff --git a/lib/vyasa_web/controllers/og_image_controller.ex b/lib/vyasa_web/controllers/og_image_controller.ex index 80a4cd79..c845e6a2 100644 --- a/lib/vyasa_web/controllers/og_image_controller.ex +++ b/lib/vyasa_web/controllers/og_image_controller.ex @@ -1,6 +1,6 @@ defmodule VyasaWeb.OgImageController do use VyasaWeb, :controller - alias VyasaWeb.GitaLive.ImageGenerator + alias VyasaWeb.SourceLive.ImageGenerator alias Vyasa.Adapters.OgAdapter action_fallback VyasaWeb.FallbackController diff --git a/lib/vyasa_web/live/gita_live/generate_image.ex b/lib/vyasa_web/live/gita_live/generate_image.ex deleted file mode 100644 index 2845292a..00000000 --- a/lib/vyasa_web/live/gita_live/generate_image.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule VyasaWeb.GitaLive.ImageGenerator do - @moduledoc """ - Contains logic for creating images, initially for opengraph purposes mainly. - """ - @fallback_text "Gita -- The Song Celestial" - @col_width 20 - alias VyasaWeb.GitaLive.ImageGenerator - alias Vix.Vips.Operation - - @doc """ - Returns a url string that can be used for the open-graph image meta-tag. - Currently stores images locally in a temp directory. - """ - def generate_opengraph_image!(filename, content \\ @fallback_text) do - content - |> generate_svg() - |> write_opengraph_image(filename) - end - - # NOTE: The fs-write is a side-effect here - defp write_opengraph_image(svg, filename) do - {img, _} = Operation.svgload_buffer!(svg) - - System.tmp_dir() - |> Path.join(filename) - |> tap(fn target_url -> Image.write(img, target_url) end) - end - - defp generate_svg(content) do - content - |> ImageGenerator.wrap_text(@col_width) - |> Enum.with_index() - |> Enum.map(fn - {line, idx} -> get_svg_for_text(line, idx) - end) - |> Enum.join("") - |> gen_text_svg() - end - - # Rudimentary function that generates svg, given the svg text nodes that should be interspersed. - defp gen_text_svg(text_nodes) do - svg_precursor = """ - - - - - - - - - - """ - svg_end = """ - - - """ - svg_precursor <> text_nodes <> svg_end - end - - defp get_svg_for_text(text, offset) do - initial_y = 250 - vert_line_space = 90 - y_resolved = Integer.to_string(initial_y + vert_line_space * offset) - - """ - #{text} - """ - end - - - - - @doc """ - Manually wraps a text to width of size @col_width. - """ - def wrap_text(text, col_length \\ @col_width) do - words = String.split(text, " ") - - # TODO: the accumulator pattern here can be cleaner, using pattern matching. Ref: https://github.com/ve1ld/vyasa/pull/27/files#r1477036476 - Enum.reduce( - words, - [], - fn word, acc_lines -> - curr_line = List.last(acc_lines, "") - new_combined_line = curr_line <> " " <> word - has_space_in_curr_line = String.length(new_combined_line) <= col_length - - if has_space_in_curr_line do - if acc_lines == [] do - [word] - else - List.replace_at(acc_lines, length(acc_lines) - 1, new_combined_line) - end - else - acc_lines ++ [word] - end - end) - end -end diff --git a/lib/vyasa_web/live/gita_live/index.ex b/lib/vyasa_web/live/gita_live/index.ex deleted file mode 100644 index 8fbda382..00000000 --- a/lib/vyasa_web/live/gita_live/index.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule VyasaWeb.GitaLive.Index do - use VyasaWeb, :live_view - alias Vyasa.Corpus.Gita - - @impl true - def mount(_params, _session, socket) do - {:ok, stream(socket, :chapters, Gita.chapters())} - end - - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Chapters in Gita") - |> assign(:text, nil) - |> assign_meta() - - end - - defp assign_meta(socket) do - assign(socket, :meta, %{ - title: "Gita", - description: "The Song Celestial", - type: "website", - url: url(socket, ~p"/gita/"), - }) - end -end diff --git a/lib/vyasa_web/live/gita_live/index.html.heex b/lib/vyasa_web/live/gita_live/index.html.heex deleted file mode 100644 index efa2d7be..00000000 --- a/lib/vyasa_web/live/gita_live/index.html.heex +++ /dev/null @@ -1,12 +0,0 @@ - <.header> - <%= @page_title %> - - - <.table - id="texts" - rows={@streams.chapters} - row_click={fn {_id, text} -> JS.navigate(~p"/gita/#{text}") end} - > - <:col :let={{_id, text}} label="Title"><%= text.name_transliterated %> - <:col :let={{_id, text}} label="Description"><%= text.name_meaning %> - diff --git a/lib/vyasa_web/live/gita_live/show.ex b/lib/vyasa_web/live/gita_live/show.ex deleted file mode 100644 index 6e9638ac..00000000 --- a/lib/vyasa_web/live/gita_live/show.ex +++ /dev/null @@ -1,70 +0,0 @@ -defmodule VyasaWeb.GitaLive.Show do - use VyasaWeb, :live_view - alias Vyasa.Corpus.Gita - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"chapter_id" => id}, _, socket) do - {:noreply, - socket - |> assign(:chapter, Gita.chapters(id)) - |> stream(:verses, Gita.verses(id)) - |> assign_meta()} - end - - defp assign_meta(socket) do - %{ - :chapter_number => chapter, - :chapter_summary => summary, - :name => chapter_name, - :name_meaning => meaning - } = socket.assigns.chapter - - assign(socket, :meta, %{ - title: "Chapter #{chapter} | #{chapter_name} -- #{meaning}", - description: summary, - type: "website", - url: url(socket, ~p"/gita/#{chapter}") - }) - end - - @doc """ - Renders a clickable verse list. - - ## Examples - <.verse_list> - <:item title="Title" navigate={~p"/myPath"}><%= @post.title %> - - """ - slot :item, required: true do - attr :title, :string - attr :navigate, :any, required: false - end - - def verse_list(assigns) do - ~H""" -
-
-
-
- <.link - navigate={item[:navigate]} - class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" - > - <%= item.title %> - -
-
<%= render_slot(item) %>
-
-
-
- """ - end -end diff --git a/lib/vyasa_web/live/gita_live/show.html.heex b/lib/vyasa_web/live/gita_live/show.html.heex deleted file mode 100644 index 0ccb33da..00000000 --- a/lib/vyasa_web/live/gita_live/show.html.heex +++ /dev/null @@ -1,15 +0,0 @@ - <.header> - <%= @chapter.name_transliterated %> - <:subtitle> <%= @chapter.chapter_summary %> - - - <.verse_list :for={{_dom_id, text} <- @streams.verses}> - <:item title={"#{text.chapter_number}.#{text.verse_number}"} - navigate={~p"/gita/#{text.chapter_number}/#{text.verse_number}"} > -

<%= text.text |> String.split("।।") |> List.first() %>

- - <:item><%= text.transliteration %> - <:item><%= text.word_meanings %> - - - <.back navigate={~p"/gita"}>Back to Gita diff --git a/lib/vyasa_web/live/gita_live/show_verse.ex b/lib/vyasa_web/live/gita_live/show_verse.ex deleted file mode 100644 index c547cc90..00000000 --- a/lib/vyasa_web/live/gita_live/show_verse.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule VyasaWeb.GitaLive.ShowVerse do - use VyasaWeb, :live_view - alias Vyasa.Corpus.Gita - alias VyasaWeb.GitaLive.ImageGenerator - alias Vyasa.Adapters.OgAdapter - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"chapter_id" => chapter_no, "verse_id" => verse_no}, _, socket) do - {:noreply, - socket - |> assign(:chapter, Gita.chapters(chapter_no)) - |> stream(:verses, Gita.verses(chapter_no)) - |> assign(:verse, Gita.verse(chapter_no, verse_no)) - |> assign_meta()} - end - - defp assign_meta(socket) do - IO.inspect(socket.assigns.verse) - %{:chapter_id => chapter, :verse_number => verse, :text => text} = socket.assigns.verse - - assign(socket, :meta, %{ - title: "Chapter #{chapter} | Verse #{verse}", - description: text, - type: "website", - image: url(~p"/og/#{get_image_url(socket, chapter, verse)}"), - url: url(socket, ~p"/gita/#{chapter}/#{verse}") - }) - end - - defp get_image_url(socket, chapter_num, verse_num) do - filename = OgAdapter.encode_filename(:gita, [chapter_num, verse_num]) - target_url = OgAdapter.get_og_file_url(filename) - - if File.exists?(target_url) do - target_url - else - text = socket.assigns.verse.text - ImageGenerator.generate_opengraph_image!(filename, text) - end - - filename - end -end diff --git a/lib/vyasa_web/live/gita_live/show_verse.html.heex b/lib/vyasa_web/live/gita_live/show_verse.html.heex deleted file mode 100644 index 118626b2..00000000 --- a/lib/vyasa_web/live/gita_live/show_verse.html.heex +++ /dev/null @@ -1,68 +0,0 @@ - - -
- <.header> - <:subtitle><%= @verse.chapter_number %>:<%= @verse.verse_number %> -

<%= @verse.text |> String.split("।।") |> List.first() %>

- -
-

<%= @verse.transliteration %>

-
-

<%= @verse.word_meanings %>

-
- <.button - phx-hook="ShareQuoteButton" - id="ShareQuoteButton" - aria-describedby="tooltip" - data-verse={Jason.encode!(@verse)} - data-share-title={"Gita Chapter #{@verse.chapter_number} #{@verse.title}"} - > - Share - - <.button - id="button-YouTubePlayer" - > - Toggle Player - -
- It's me, tooltip content... -
- - <.back navigate={~p"/gita/#{@verse.chapter_number}"}> - Back to Gita Chapter <%= @verse.chapter_number %> - - <.back navigate={~p"/gita"}>Back to Gita -
- <.live_component - module={VyasaWeb.YouTubePlayer} - id={"YouTubePlayer"} - /> -
-
diff --git a/lib/vyasa_web/live/media_live/player.html.heex b/lib/vyasa_web/live/media_live/player.html.heex new file mode 100644 index 00000000..6ca4e990 --- /dev/null +++ b/lib/vyasa_web/live/media_live/player.html.heex @@ -0,0 +1,49 @@ +
+ +
+
+ +
+
+
+
+
+

+ <%= if @playback && @playback.medium, do: @playback.medium.title, else: raw(" ") %> +

+

+ <%= if @playback && @playback.medium, do: "Stub artist", else: raw(" ") %> +

+
+
+ <.progress_bar + id="player-progress" + max={@playback && @playback.medium && @playback.medium.duration || 100} + value={@playback && @playback.elapsed || 0} + /> +
+
+
+
+
+
+
+ + + + + <.play_pause_button + playback={@playback} + /> + + + + +
+
+ +
diff --git a/lib/vyasa_web/live/media_live/player_live.ex b/lib/vyasa_web/live/media_live/player_live.ex new file mode 100644 index 00000000..84690a8e --- /dev/null +++ b/lib/vyasa_web/live/media_live/player_live.ex @@ -0,0 +1,284 @@ +defmodule VyasaWeb.MediaLive.Player do + use VyasaWeb, :live_view + alias Vyasa.Medium.{Voice, Event, Playback} + + @impl true + def mount(_params, _sess, socket) do + socket = socket + |> assign(playback: nil) + |> sync_session() + + {:ok, socket, layout: false} + end + + defp sync_session(%{assigns: %{session: %{"id" => id} = sess}} = socket) when is_binary(id) do + Vyasa.PubSub.subscribe("media:session:" <> id) + Vyasa.PubSub.publish(:init, :media_handshake, "written:session:" <> id) + + socket + |> push_event("initSession", sess |> Map.take(["id"])) + end + + defp sync_session(socket) do + socket + end + + @impl true + def handle_event("play_pause", _, socket) do + %{ + playback: %Playback{ + medium: %Voice{} = _voice = _medium, + playing?: playing?, + } = playback} = socket.assigns + + {:noreply, + cond do + playing? -> socket |> pause_voice(playback) + !playing? -> socket |> play_voice(playback) + end + } + end + + @impl true + def handle_event("seekToMs", %{"position_ms" => position_ms} = _payload, socket) do + IO.puts("[handleEvent] seekToMs #{position_ms} is_integer? #{is_integer(position_ms)} is string? #{is_binary(position_ms)}") + + %{playback: %Playback{ + medium: %Voice{} = _voice, + playing?: playing?, + played_at: played_at, + } = playback} = socket.assigns + + + position_s = round(position_ms / 1000) + played_at = cond do + !playing? -> played_at + playing? -> DateTime.add(DateTime.utc_now, -position_s, :second) + end + + {:noreply, socket + |> push_event("seekTo", %{positionS: position_s}) + |> assign(playback: %{playback | played_at: played_at, elapsed: position_s}) + } + end + + @impl true + @doc""" + On receiving a voice_ack, the written and player contexts are now synced. + A playback struct is created that represents this synced-state and the client-side hook is triggerred + to register the associated events timeline. + """ + def handle_info({_, :voice_ack, voice}, socket) do + %Playback{ + medium: %Voice{events: events}, + } = playback = voice |> Playback.create_playback() + # } = playback = voice |> MediaLibrary.gen_voice_playback() + + socket = socket + |> assign(playback: playback) + # Registers Events Timeline on Client-Side: + |> push_event("registerEventsTimeline", %{voice_events: events |> create_events_payload()}) + + {:noreply, socket} + end + + def handle_info({_, :written_handshake, :init}, %{assigns: %{session: %{"id" => id}}} = socket) do + Vyasa.PubSub.publish(:init, :media_handshake, "written:session:" <> id) + {:noreply, socket} + end + + def handle_info(msg, socket) do + IO.inspect(msg, label: "unexpected message in @player_live") + {:noreply, socket} + end + + +defp create_events_payload([%Event{} | _] = events) do + events|> Enum.map(&(&1 |> Map.take([:origin, :duration, :phase, :fragments, :verse_id]))) +end + + + +defp play_voice(socket, %Playback{ + elapsed: elapsed, + medium: %Voice{} = voice + } = playback) do + IO.puts("play_voice triggerred with elapsed = #{elapsed}") + now = DateTime.utc_now() + played_at = cond do + elapsed > 0 -> # resume case + DateTime.add(now, -elapsed, :second) + elapsed == 0 -> # fresh start case + now + true -> + now + end + + playback = %{playback | playing?: true, played_at: played_at} + + socket + |>push_event("play", %{ + artist: "testArtist", + # artist: hd(voice.meta.artists), + title: voice.title, + isPlaying: playback.playing?, + elapsed: playback.elapsed, + filePath: voice.file_path, + duration: voice.duration, + }) + |> assign(playback: playback) +end + +defp pause_voice(socket, %Playback{ + medium: %Voice{} = _voice, + } = playback) do + + now = DateTime.utc_now() + elapsed = DateTime.diff(now, playback.played_at, :second) + playback = %{playback | playing?: false, paused_at: now, elapsed: elapsed} + + IO.puts("pause_voice triggerred with elapsed = #{elapsed}") + + socket + |> push_event("pause", %{ + elapsed: elapsed, + }) + |> assign(playback: playback) +end + + defp js_play_pause() do + JS.push("play_pause") # server event + |> JS.dispatch("js:play_pause", to: "#audio-player") # client-side event, dispatches to DOM + end + + + defp js_prev() do + end + + defp js_next() do + end + + + attr :id, :string, required: true + attr :min, :integer, default: 0 + attr :max, :integer, default: 100 + attr :value, :integer + def progress_bar(assigns) do + assigns = assign_new(assigns, :value, fn -> assigns[:min] || 0 end) + + ~H""" +
+
+
+
+ """ + end + + attr :playback, Playback, required: false + def play_pause_button(assigns) do + ~H""" + + """ + end + + def next_button(assigns) do + ~H""" + + """ + end + + def prev_button(assigns) do + ~H""" + + """ + end + + end diff --git a/lib/vyasa_web/live/source_live/chapter/index.ex b/lib/vyasa_web/live/source_live/chapter/index.ex index 108cb457..12392001 100644 --- a/lib/vyasa_web/live/source_live/chapter/index.ex +++ b/lib/vyasa_web/live/source_live/chapter/index.ex @@ -1,56 +1,94 @@ defmodule VyasaWeb.SourceLive.Chapter.Index do use VyasaWeb, :live_view alias Vyasa.Written - alias Vyasa.Written.{Chapter} @default_lang "en" + @default_voice_lang "sa" @impl true def mount(_params, _session, socket) do - {:ok, socket} + {:ok, socket + |> stream_configure(:verses, dom_id: &("verse-#{&1.id}"))} end @impl true def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} + IO.puts("chapter/index handle params") + + {:noreply, socket + |> sync_session() + |> apply_action(socket.assigns.live_action, params) + + #|> register_client_state() + } end - defp apply_action(socket, :index, %{ - "source_title" => source_title, - "chap_no" => chap_no, - } = _params) do + # defp register_client_state(%{assigns: %{voice_events: voice_events}} = socket) do + # desired_keys = [:origin, :duration, :phase, :fragments, :verse_id] + # events = Enum.map(voice_events, fn e -> Map.take(Map.from_struct(e), desired_keys) end) + # socket + # |> push_event("registerEventsTimeline", + # %{voice_events: events}) + # end + + defp sync_session(%{assigns: %{session: %{"id" => sess_id}}} = socket) do + # written channel for reading and media channel for writing to media bridge and to player + Vyasa.PubSub.subscribe("written:session:" <> sess_id) + Vyasa.PubSub.publish(:init, :written_handshake, "media:session:" <> sess_id) + socket + end - %Chapter{ - verses: verses, - title: title, - body: body, - translations: translations, - } = Written.get_chapter(chap_no, source_title) + defp sync_session(socket), do: socket - selected_transl = translations |> Enum.find(fn t -> t.lang == @default_lang end) + defp apply_action(socket, :index, %{"source_title" => source_title, "chap_no" => chap_no} = _params) do + chap = %{verses: verses, translations: [ts | _]} = Written.get_chapter(chap_no, source_title, @default_lang) socket |> stream(:verses, verses) |> assign(:source_title, source_title) - |> assign(:chap_no, chap_no) - |> assign(:chap_body, body) - |> assign(:chap_title, title) - |> assign(:selected_transl, selected_transl) - |> assign(:page_title, "#{source_title} Chapter #{chap_no} | #{title}") - |> assign(:text, nil) + |> assign(:chap, chap) + |> assign(:selected_transl, ts) |> assign_meta() end + @doc """ + Upon rcv of :media_handshake, which indicates an intention to sync by the player, + returns a message containing %Voice{} info that can be used to generate a playback. + """ + @impl true + def handle_info({_, :media_handshake, :init}, + %{assigns: %{ + session: %{"id" => sess_id}, + chap: %Written.Chapter{no: c_no, source_id: src_id} + }} = socket) do + Vyasa.PubSub.publish(%Vyasa.Medium.Voice{ + source_id: src_id, + chapter_no: c_no, + lang: @default_voice_lang + }, + :voice_ack, + sess_id) + {:noreply, socket} + end + + def handle_info(msg, socket) do + IO.inspect(msg, label: "unexpected message in @chapter") + {:noreply, socket} + end + defp assign_meta(socket) do - assign(socket, :meta, %{ - title: "#{socket.assigns.source_title} Chapter #{socket.assigns.chap_no} | #{socket.assigns.chap_title}", - description: socket.assigns.chap_body, - type: "website", - url: url(socket, ~p"/explore/#{socket.assigns.source_title}/#{socket.assigns.chap_no}"), - }) + socket + |> assign(:page_title, "#{socket.assigns.source_title} Chapter #{socket.assigns.chap.no} | #{socket.assigns.chap.title}") + |> assign(:meta, %{ + title: "#{socket.assigns.source_title} Chapter #{socket.assigns.chap.no} | #{socket.assigns.chap.title}", + description: socket.assigns.chap.body, + type: "website", + url: url(socket, ~p"/explore/#{socket.assigns.source_title}/#{socket.assigns.chap.no}"), + }) end + @doc """ Renders a clickable verse list. @@ -59,14 +97,14 @@ defmodule VyasaWeb.SourceLive.Chapter.Index do <:item title="Title" navigate={~p"/myPath"}><%= @post.title %> """ + attr :id, :string, required: false slot :item, required: true do attr :title, :string attr :navigate, :any, required: false end - def verse_list(assigns) do ~H""" -
+
+
<.header>
- <%= @selected_transl.target.translit_title %> | <%= @chap_title %> + <%= @selected_transl.target.translit_title %> | <%= @chap.title%>
- Chapter <%= @chap_no%> - <%= @selected_transl.target.title %> + Chapter <%= @chap.no%> - <%= @selected_transl.target.title %>
<:subtitle> @@ -11,24 +13,25 @@ - <.verse_list :for={{_dom_id, verse} <- @streams.verses}> + <.verse_list id={dom_id} :for={{dom_id, verse} <- @streams.verses}> <:item title={"#{verse.chapter_no}.#{verse.no}"} - navigate={~p"/explore/#{@source_title}/#{@chap_no}/#{verse.no}"} > + navigate={~p"/explore/#{@source_title}/#{@chap.no}/#{verse.no}"} > -

+

<%= verse.body |> String.split("।।") |> List.first() %>

<:item title={"Transliteration"}> -

+

<%= hd(verse.translations).target.body_translit %>

<:item title={"Transliteration Meaning"}> -

+

<%= hd(verse.translations).target.body_translit_meant %>

<.back navigate={~p"/explore/#{@source_title}"}>Back to <%= @source_title %> Chapters +
diff --git a/lib/vyasa_web/live/source_live/show_verse.html.heex b/lib/vyasa_web/live/source_live/show_verse.html.heex index 118626b2..4beef9ee 100644 --- a/lib/vyasa_web/live/source_live/show_verse.html.heex +++ b/lib/vyasa_web/live/source_live/show_verse.html.heex @@ -52,10 +52,10 @@ It's me, tooltip content...
- <.back navigate={~p"/gita/#{@verse.chapter_number}"}> + <.back navigate={~p"/explore/#{@source_title}/@{chap_no}"}> Back to Gita Chapter <%= @verse.chapter_number %> - <.back navigate={~p"/gita"}>Back to Gita + <.back navigate={~p"/explore/#{@source_title}"}>Back to <%= @source_title %>
- <.header> - <%= @title %> - <:subtitle>Use this form to manage text records in your database. - - - <.simple_form - for={@form} - id="text-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:title]} type="text" label="Title" /> - <:actions> - <.button phx-disable-with="Saving...">Save Text - - -
- """ - end - - @impl true - def update(%{text: text} = assigns, socket) do - changeset = Written.change_text(text) - - {:ok, - socket - |> assign(assigns) - |> assign_form(changeset)} - end - - @impl true - def handle_event("validate", %{"text" => text_params}, socket) do - changeset = - socket.assigns.text - |> Written.change_text(text_params) - |> Map.put(:action, :validate) - - {:noreply, assign_form(socket, changeset)} - end - - def handle_event("save", %{"text" => text_params}, socket) do - save_text(socket, socket.assigns.action, text_params) - end - - defp save_text(socket, :edit, text_params) do - case Written.update_text(socket.assigns.text, text_params) do - {:ok, text} -> - notify_parent({:saved, text}) - - {:noreply, - socket - |> put_flash(:info, "Text updated successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - defp save_text(socket, :new, text_params) do - case Written.create_text(text_params) do - {:ok, text} -> - notify_parent({:saved, text}) - - {:noreply, - socket - |> put_flash(:info, "Text created successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - defp assign_form(socket, %Ecto.Changeset{} = changeset) do - assign(socket, :form, to_form(changeset)) - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) -end diff --git a/lib/vyasa_web/live/text_live/index.ex b/lib/vyasa_web/live/text_live/index.ex deleted file mode 100644 index 59c567e9..00000000 --- a/lib/vyasa_web/live/text_live/index.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule VyasaWeb.TextLive.Index do - use VyasaWeb, :live_view - - alias Vyasa.Written - alias Vyasa.Written.Text - - @impl true - def mount(_params, _session, socket) do - {:ok, stream(socket, :texts, Written.list_texts())} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :edit, %{"id" => id}) do - socket - |> assign(:page_title, "Edit Text") - |> assign(:text, Written.get_text!(id)) - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, "New Text") - |> assign(:text, %Text{}) - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Listing Texts") - |> assign(:text, nil) - end - - @impl true - def handle_info({VyasaWeb.TextLive.FormComponent, {:saved, text}}, socket) do - {:noreply, stream_insert(socket, :texts, text)} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - text = Written.get_text!(id) - {:ok, _} = Written.delete_text(text) - - {:noreply, stream_delete(socket, :texts, text)} - end -end diff --git a/lib/vyasa_web/live/text_live/index.html.heex b/lib/vyasa_web/live/text_live/index.html.heex deleted file mode 100644 index 9f46256c..00000000 --- a/lib/vyasa_web/live/text_live/index.html.heex +++ /dev/null @@ -1,41 +0,0 @@ -<.header> - Listing Texts - <:actions> - <.link patch={~p"/texts/new"}> - <.button>New Text - - - - -<.table - id="texts" - rows={@streams.texts} - row_click={fn {_id, text} -> JS.navigate(~p"/texts/#{text}") end} -> - <:col :let={{_id, text}} label="Title"><%= text.title %> - <:action :let={{_id, text}}> -
- <.link navigate={~p"/texts/#{text}"}>Show -
- <.link patch={~p"/texts/#{text}/edit"}>Edit - - <:action :let={{id, text}}> - <.link - phx-click={JS.push("delete", value: %{id: text.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - - -<.modal :if={@live_action in [:new, :edit]} id="text-modal" show on_cancel={JS.patch(~p"/texts")}> - <.live_component - module={VyasaWeb.TextLive.FormComponent} - id={@text.id || :new} - title={@page_title} - action={@live_action} - text={@text} - patch={~p"/texts"} - /> - diff --git a/lib/vyasa_web/live/text_live/show.ex b/lib/vyasa_web/live/text_live/show.ex deleted file mode 100644 index b94fe853..00000000 --- a/lib/vyasa_web/live/text_live/show.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule VyasaWeb.TextLive.Show do - use VyasaWeb, :live_view - - alias Vyasa.Written - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:text, Written.get_text!(id))} - end - - defp page_title(:show), do: "Show Text" - defp page_title(:edit), do: "Edit Text" -end diff --git a/lib/vyasa_web/live/text_live/show.html.heex b/lib/vyasa_web/live/text_live/show.html.heex deleted file mode 100644 index efecb714..00000000 --- a/lib/vyasa_web/live/text_live/show.html.heex +++ /dev/null @@ -1,26 +0,0 @@ -<.header> - Text <%= @text.id %> - <:subtitle>This is a text record from your database. - <:actions> - <.link patch={~p"/texts/#{@text}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit text - - - - -<.list> - <:item title="Title"><%= @text.title %> - - -<.back navigate={~p"/texts"}>Back to texts - -<.modal :if={@live_action == :edit} id="text-modal" show on_cancel={JS.patch(~p"/texts/#{@text}")}> - <.live_component - module={VyasaWeb.TextLive.FormComponent} - id={@text.id} - title={@page_title} - action={@live_action} - text={@text} - patch={~p"/texts/#{@text}"} - /> - diff --git a/lib/vyasa_web/router.ex b/lib/vyasa_web/router.ex index cc4bc3f9..28b56f75 100644 --- a/lib/vyasa_web/router.ex +++ b/lib/vyasa_web/router.ex @@ -23,22 +23,17 @@ defmodule VyasaWeb.Router do get "/og/:filename", OgImageController, :show - get "/", PageController, :home - live "/explore/", SourceLive.Index, :index - live "/explore/:source_title/", SourceLive.Show, :show - live "/explore/:source_title/:chap_no", SourceLive.Chapter.Index, :index - live "/explore/:source_title/:chap_no/:verse_no", SourceLive.Chapter.ShowVerse, :show - live "/gita/", GitaLive.Index, :index - live "/gita/:chapter_id", GitaLive.Show, :show - live "/gita/:chapter_id/:verse_id", GitaLive.ShowVerse, :show_verse - live "/texts", TextLive.Index, :index - live "/texts/new", TextLive.Index, :new - live "/texts/:id/edit", TextLive.Index, :edit - live "/texts/:id", TextLive.Show, :show - live "/texts/:id/show/edit", TextLive.Show, :edit + get "/", PageController, :home - end + live_session :gen_anon_session, + on_mount: [{VyasaWeb.Session, :anon}] do + live "/explore/", SourceLive.Index, :index + live "/explore/:source_title/", SourceLive.Show, :show + live "/explore/:source_title/:chap_no", SourceLive.Chapter.Index, :index + live "/explore/:source_title/:chap_no/:verse_no", SourceLive.Chapter.ShowVerse, :show + end + end # Other scopes may use custom stacks. # scope "/api", VyasaWeb do diff --git a/lib/vyasa_web/session.ex b/lib/vyasa_web/session.ex new file mode 100644 index 00000000..8e3a4908 --- /dev/null +++ b/lib/vyasa_web/session.ex @@ -0,0 +1,25 @@ +defmodule VyasaWeb.Session do + import Phoenix.Component, only: [assign: 2] + import Phoenix.LiveView, only: [get_connect_params: 1] + + @default_locale "en" + @timezone "UTC" + @timezone_offset 0 + + def on_mount(:anon, _params, _sessions, socket) do + # get_connect_params returns nil on the first (static rendering) mount call, and has the added connect params from the js LiveSocket creation on the subsequent (LiveSocket) call + {:cont, + socket + |> assign( + locale: get_connect_params(socket)["locale"] || @default_locale, + tz: %{timezone: get_connect_params(socket)["timezone"] || @timezone, + timezone_offset: get_connect_params(socket)["timezone_offset"] || @timezone_offset}, + session: get_connect_params(socket)["session"] |> mutate_session() + )} + end + + defp mutate_session(%{"id" => id} = sess) when is_binary(id), do: sess + defp mutate_session(%{"active" => true}), do: %{"id" => :crypto.strong_rand_bytes(18) |> :base64.encode()} + defp mutate_session(%{}), do: %{"id" => :crypto.strong_rand_bytes(18) |> :base64.encode()} + defp mutate_session(_), do: %{"active" => false} + end diff --git a/media/gita/1.mp3 b/media/gita/1.mp3 new file mode 100644 index 00000000..510b2817 Binary files /dev/null and b/media/gita/1.mp3 differ diff --git a/mix.exs b/mix.exs index 85bcee32..f2d4e843 100644 --- a/mix.exs +++ b/mix.exs @@ -64,7 +64,9 @@ defmodule Vyasa.MixProject do {:vix, "~> 0.5"}, {:kino, "~> 0.12.0"}, {:cors_plug, "~> 3.0"}, - {:req, "~> 0.4.8"} + {:ex_aws, "~> 2.0"}, + {:req, "~> 0.4.0"}, + {:ex_aws_s3, "~> 2.5"} ] end diff --git a/mix.lock b/mix.lock index c178568f..f5578ec4 100644 --- a/mix.lock +++ b/mix.lock @@ -13,6 +13,8 @@ "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, + "ex_aws": {:hex, :ex_aws, "2.5.1", "7418917974ea42e9e84b25e88b9f3d21a861d5f953ad453e212f48e593d8d39f", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1b95431f70c446fa1871f0eb9b183043c5a625f75f9948a42d25f43ae2eff12b"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [: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", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, diff --git a/priv/repo/migrations/20240131122233_gen_media_events.exs b/priv/repo/migrations/20240131122233_gen_media_events.exs new file mode 100644 index 00000000..2943ce94 --- /dev/null +++ b/priv/repo/migrations/20240131122233_gen_media_events.exs @@ -0,0 +1,49 @@ +defmodule Vyasa.Repo.Migrations.GenMediaEvents do + use Ecto.Migration + + def change do + create table(:tracks, primary_key: false) do + add :id, :uuid, primary_key: true + add :title, :string + end + + create table(:voices, primary_key: false) do + add :id, :uuid, primary_key: true + add :lang, :string + add :title, :string + add :duration, :integer + add :meta, :jsonb + add :track_id, references(:tracks, column: :id, type: :uuid) + add :chapter_no, references(:chapters, column: :no, type: :integer, with: [source_id: :source_id]) + add :source_id, references(:sources, column: :id, type: :uuid) + + timestamps([:utc_datetime]) + end + + create table(:videos, primary_key: false) do + add :id, :uuid, primary_key: true + add :type, :string + add :ext_uri, :string + add :voice_id, references(:voices, column: :id, type: :uuid, on_delete: :nothing) + end + + create table(:events, primary_key: false) do + add :id, :uuid, primary_key: true + add :origin, :integer + add :duration, :integer + add :phase, :string + + add :verse_id, references(:verses, column: :id, type: :uuid, on_delete: :nothing) + add :voice_id, references(:voices, column: :id, type: :uuid, on_delete: :nothing) + add :fragments, {:array, :map}, null: false, default: [] + add :source_id, references(:sources, column: :id, type: :uuid) + end + + alter table(:chapters) do + add :key, :string + add :parent_no, references(:chapters, column: :no, type: :integer, with: [source_id: :source_id]) + end + + create unique_index(:sources, [:title]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index ad988ca7..01f37bf3 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,12 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. +require Logger +try do + ExAws.S3.put_bucket("vyasa", "ap-southeast-1") + |> ExAws.request!() + IO.inspect("ok good", "bucket creation") +rescue + e -> + Logger.debug(Exception.format(:error, e, __STACKTRACE__)) +end