diff --git a/lib/ambry_scraping/audible/products.ex b/lib/ambry_scraping/audible/products.ex
index 6902908e..2977e862 100644
--- a/lib/ambry_scraping/audible/products.ex
+++ b/lib/ambry_scraping/audible/products.ex
@@ -62,6 +62,8 @@ defmodule AmbryScraping.Audible.Products do
@doc """
Returns product details for a given title search query.
"""
+ def search(""), do: {:ok, []}
+
def search(query) do
query =
URI.encode_query(%{
diff --git a/lib/ambry_scraping/goodreads/books/search.ex b/lib/ambry_scraping/goodreads/books/search.ex
index a286e6ed..cfc15dcb 100644
--- a/lib/ambry_scraping/goodreads/books/search.ex
+++ b/lib/ambry_scraping/goodreads/books/search.ex
@@ -21,6 +21,8 @@ defmodule AmbryScraping.GoodReads.Books.Search do
defstruct [:src, :data_url]
end
+ def search(""), do: {:ok, []}
+
def search(query_string) do
query = URI.encode_query(%{utf8: "✓", query: query_string})
path = "/search" |> URI.new!() |> URI.append_query(query) |> URI.to_string()
diff --git a/lib/ambry_web/live/admin/book_live/form.ex b/lib/ambry_web/live/admin/book_live/form.ex
index 1e7a0c35..7a158c96 100644
--- a/lib/ambry_web/live/admin/book_live/form.ex
+++ b/lib/ambry_web/live/admin/book_live/form.ex
@@ -70,25 +70,6 @@ defmodule AmbryWeb.Admin.BookLive.Form do
{:noreply, assign_form(socket, changeset)}
end
- # FIXME: Don't use form submit event for this
- def handle_event("submit", %{"import" => import_type, "book" => book_params}, socket) do
- changeset =
- socket.assigns.book
- |> Books.change_book(book_params)
- |> Map.put(:action, :validate)
-
- if Keyword.has_key?(changeset.errors, :title) do
- {:noreply, assign_form(socket, changeset)}
- else
- socket =
- assign(socket,
- import: %{type: String.to_existing_atom(import_type), query: book_params["title"]}
- )
-
- {:noreply, socket}
- end
- end
-
def handle_event("submit", %{"book" => book_params}, socket) do
with {:ok, _book} <-
socket.assigns.book
@@ -104,6 +85,14 @@ defmodule AmbryWeb.Admin.BookLive.Form do
end
end
+ def handle_event("open-import-form", %{"type" => type}, socket) do
+ query = socket.assigns.form.params["title"]
+ import_type = String.to_existing_atom(type)
+ socket = assign(socket, import: %{type: import_type, query: query})
+
+ {:noreply, socket}
+ end
+
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :image, ref)}
end
@@ -181,4 +170,6 @@ defmodule AmbryWeb.Admin.BookLive.Form do
defp import_form(:goodreads), do: GoodreadsImportForm
defp import_form(:audible), do: AudibleImportForm
+
+ defp open_import_form(type), do: JS.push("open-import-form", value: %{"type" => type})
end
diff --git a/lib/ambry_web/live/admin/book_live/form.html.heex b/lib/ambry_web/live/admin/book_live/form.html.heex
index a87a562c..14d15c6f 100644
--- a/lib/ambry_web/live/admin/book_live/form.html.heex
+++ b/lib/ambry_web/live/admin/book_live/form.html.heex
@@ -12,10 +12,16 @@
<.input field={@form[:title]} show_errors={false} container_class="grow" />
<.label>Import from:
- <.button :if={@scraping_available} color={:zinc} class="flex items-center gap-1" name="import" value="goodreads">
+ <.button
+ :if={@scraping_available}
+ color={:zinc}
+ class="flex items-center gap-1"
+ type="button"
+ phx-click={open_import_form("goodreads")}
+ >
GoodReads
- <.button color={:zinc} class="flex items-center gap-1" name="import" value="audible">
+ <.button color={:zinc} class="flex items-center gap-1" type="button" phx-click={open_import_form("audible")}>
Audible
diff --git a/lib/ambry_web/live/admin/book_live/form/audible_import_form.ex b/lib/ambry_web/live/admin/book_live/form/audible_import_form.ex
index 1b6e8029..52c2fe5e 100644
--- a/lib/ambry_web/live/admin/book_live/form/audible_import_form.ex
+++ b/lib/ambry_web/live/admin/book_live/form/audible_import_form.ex
@@ -9,6 +9,7 @@ defmodule AmbryWeb.Admin.BookLive.Form.AudibleImportForm do
alias Ambry.People.Person
alias Ambry.Search
alias Ambry.Series.Series
+ alias Phoenix.LiveView.AsyncResult
@impl Phoenix.LiveComponent
def mount(socket) do
@@ -17,36 +18,93 @@ defmodule AmbryWeb.Admin.BookLive.Form.AudibleImportForm do
@impl Phoenix.LiveComponent
def update(assigns, socket) do
- socket =
- case Map.pop(assigns, :info) do
- {nil, assigns} ->
- socket
- |> assign(assigns)
- |> async_search(assigns.query)
-
- {forwarded_info_payload, assigns} ->
- socket
- |> assign(assigns)
- |> then(fn socket ->
- handle_forwarded_info(forwarded_info_payload, socket)
- end)
- end
+ {:ok,
+ socket
+ |> assign(assigns)
+ |> assign(
+ books: AsyncResult.loading(),
+ selected_book: AsyncResult.loading(),
+ search_form: to_form(%{"query" => assigns.query}, as: :search),
+ select_book_form: to_form(%{}, as: :select_book),
+ form: to_form(init_import_form_params(assigns.book), as: :import)
+ )
+ |> start_async(:search, fn -> search(assigns.query) end)}
+ end
- {:ok, socket}
+ @impl Phoenix.LiveComponent
+ def handle_async(:search, {:ok, books}, socket) do
+ [first_book | _rest] = books
+
+ {:noreply,
+ socket
+ |> assign(books: AsyncResult.ok(socket.assigns.books, books))
+ |> assign(select_book_form: to_form(%{"book_id" => first_book.id}, as: :select_book))
+ |> start_async(:select_book, fn -> select_book(first_book) end)}
+ end
+
+ def handle_async(:search, {:exit, {:shutdown, :cancel}}, socket) do
+ {:noreply, assign(socket, books: AsyncResult.loading())}
+ end
+
+ def handle_async(:search, {:exit, {exception, _stacktrace}}, socket) do
+ {:noreply, assign(socket, books: AsyncResult.failed(socket.assigns.books, exception.message))}
+ end
+
+ def handle_async(:select_book, {:ok, results}, socket) do
+ %{
+ selected_book: selected_book,
+ matching_authors: matching_authors,
+ matching_series: matching_series
+ } = results
+
+ {:noreply,
+ assign(socket,
+ selected_book: AsyncResult.ok(socket.assigns.selected_book, selected_book),
+ matching_authors: matching_authors,
+ matching_series: matching_series
+ )}
+ end
+
+ def handle_async(:select_book, {:exit, {:shutdown, :cancel}}, socket) do
+ {:noreply, assign(socket, selected_book: AsyncResult.loading())}
+ end
+
+ def handle_async(:select_book, {:exit, {exception, _stacktrace}}, socket) do
+ {:noreply,
+ assign(socket,
+ selected_book: AsyncResult.failed(socket.assigns.selected_book, exception.message)
+ )}
end
@impl Phoenix.LiveComponent
def handle_event("search", %{"search" => %{"query" => query}}, socket) do
- {:noreply, async_search(socket, query)}
+ {:noreply,
+ socket
+ |> assign(
+ books: AsyncResult.loading(),
+ selected_book: AsyncResult.loading(),
+ search_form: to_form(%{"query" => query}, as: :search)
+ )
+ |> cancel_async(:search)
+ |> cancel_async(:select_book)
+ |> start_async(:search, fn -> search(query) end)}
end
def handle_event("select-book", %{"select_book" => %{"book_id" => book_id}}, socket) do
- book = Enum.find(socket.assigns.books, &(&1.id == book_id))
- {:noreply, select_book(socket, book)}
+ book = Enum.find(socket.assigns.books.result, &(&1.id == book_id))
+
+ {:noreply,
+ socket
+ |> assign(
+ selected_book: AsyncResult.loading(),
+ select_book_form: to_form(%{"book_id" => book.id}, as: :select_book)
+ )
+ |> cancel_async(:select_book)
+ |> start_async(:select_book, fn -> select_book(book) end)}
end
def handle_event("import", %{"import" => import_params}, socket) do
- book = socket.assigns.selected_book
+ book = socket.assigns.selected_book.result
params =
Enum.reduce(import_params, %{}, fn
@@ -60,14 +118,14 @@ defmodule AmbryWeb.Admin.BookLive.Form.AudibleImportForm do
Map.put(
acc,
"book_authors",
- build_authors_params(book.authors, socket.assigns.matching_authors)
+ build_authors_params(book.authors, socket.assigns.matching_authors.result)
)
{"use_series", "true"}, acc ->
Map.put(
acc,
"series_books",
- build_series_params(book.series, socket.assigns.matching_series)
+ build_series_params(book.series, socket.assigns.matching_series.result)
)
{"use_cover_image", "true"}, acc ->
@@ -119,7 +177,15 @@ defmodule AmbryWeb.Admin.BookLive.Form.AudibleImportForm do
end)
end
- defp select_book(socket, book) do
+ defp search(query) do
+ case "#{query}" |> String.trim() |> String.downcase() |> Audible.search_books() do
+ {:ok, []} -> raise "No books found"
+ {:ok, books} -> books
+ {:error, reason} -> raise "Unhandled error: #{inspect(reason)}"
+ end
+ end
+
+ defp select_book(book) do
matching_authors =
Enum.map(book.authors, fn author ->
Search.find_first(author.name, Person)
@@ -130,43 +196,7 @@ defmodule AmbryWeb.Admin.BookLive.Form.AudibleImportForm do
Search.find_first(series.title, Series)
end)
- assign(socket,
- selected_book: book,
- matching_authors: matching_authors,
- matching_series: matching_series,
- select_book_form: to_form(%{"book_id" => book.id}, as: :select_book)
- )
- end
-
- defp handle_forwarded_info({:search, {:ok, books}}, socket) do
- socket = assign(socket, search_loading: false, books: books)
-
- case books do
- [] -> socket
- [first_result | _rest] -> select_book(socket, first_result)
- end
- end
-
- defp handle_forwarded_info({:search, {:error, _reason}}, socket) do
- socket
- |> put_flash(:error, "search failed")
- |> assign(search_loading: false)
- end
-
- defp async_search(socket, query) do
- Task.async(fn ->
- response = Audible.search_books(query |> String.trim() |> String.downcase())
- {{:for, __MODULE__, socket.assigns.id}, {:search, response}}
- end)
-
- assign(socket,
- search_form: to_form(%{"query" => query}, as: :search),
- search_loading: true,
- books: [],
- select_book_form: to_form(%{}, as: :select_book),
- selected_book: nil,
- form: to_form(init_import_form_params(socket.assigns.book), as: :import)
- )
+ %{selected_book: book, matching_authors: matching_authors, matching_series: matching_series}
end
defp init_import_form_params(book) do
diff --git a/lib/ambry_web/live/admin/book_live/form/audible_import_form.html.heex b/lib/ambry_web/live/admin/book_live/form/audible_import_form.html.heex
index 895f30c0..71b4e488 100644
--- a/lib/ambry_web/live/admin/book_live/form/audible_import_form.html.heex
+++ b/lib/ambry_web/live/admin/book_live/form/audible_import_form.html.heex
@@ -1,8 +1,6 @@
Import Book from Audible
- <.flash_group flash={@flash} />
-
<.simple_form for={@search_form} phx-submit="search" phx-target={@myself}>
<.input field={@search_form[:query]} label="Search" container_class="grow" />
@@ -10,93 +8,101 @@
- <%= if @search_loading do %>
- <.loading>Searching books...
- <% end %>
+ <.async_result :let={books} assign={@books}>
+ <:loading>
+ <.loading>Searching books...
+
+
+ <:failed :let={failure}>
+ <.error>There was an error searching Audible for books: <%= failure %>
+
- <%= if !@search_loading && length(@books) > 1 do %>
<.simple_form for={@select_book_form} phx-change="select-book" phx-target={@myself}>
- <.label>Select book (<%= length(@books) %> results)
- <.rich_select id="book-select" field={@select_book_form[:book_id]} options={@books}>
+ <.label>Select book (<%= length(books) %> results)
+ <.rich_select id="book-select" field={@select_book_form[:book_id]} options={books}>
<:option :let={book}>
<.book_card book={book} />
- <% end %>
-
- <.simple_form for={@form} phx-submit="import" phx-target={@myself} container_class="!space-y-0">
- <.import_form_row :if={@selected_book.title != ""} field={@form[:use_title]} label="Title">
-
- <%= @selected_book.title %>
-
-
+ <.async_result :let={selected_book} :if={@books.ok?} assign={@selected_book}>
+ <:loading>
+ <.loading>Fetching book details...
+
- <.import_form_row :if={@selected_book.description} field={@form[:use_description]} label="Description">
- <.markdown
- content={@selected_book.description}
- class="max-h-64 max-w-max overflow-y-auto rounded-sm border border-zinc-600 bg-zinc-800 p-2"
- />
-
+ <.simple_form for={@form} phx-submit="import" phx-target={@myself} container_class="!space-y-0">
+ <.import_form_row :if={selected_book.title != ""} field={@form[:use_title]} label="Title">
+
+ <%= selected_book.title %>
+
+
- <.import_form_row :if={@selected_book.authors != []} field={@form[:use_authors]} label="Authors">
-
- <%= if existing_author do %>
-
-
- Existing author
- <%= existing_author.name %>
-
- <% else %>
-
-
- Missing author
- <%= imported_author.name %>
-
- <% end %>
-
-
- Any missing authors will be imported with just their names. You can add additional details by visiting
- <.brand_link navigate={~p"/admin/people"}>Authors & Narrators.
-
-
+ <.import_form_row :if={selected_book.description} field={@form[:use_description]} label="Description">
+ <.markdown
+ content={selected_book.description}
+ class="max-h-64 max-w-max overflow-y-auto rounded-sm border border-zinc-600 bg-zinc-800 p-2"
+ />
+
- <.import_form_row :if={@selected_book.series != []} field={@form[:use_series]} label="Series">
-
- <%= if existing_series do %>
-
-
- Existing series
- <%= existing_series.name %> #<%= imported_series.sequence %>
-
- <% else %>
-
-
- New series
- <%= imported_series.title %> #<%= imported_series.sequence %>
-
- <% end %>
-
-
+ <.import_form_row :if={selected_book.authors != []} field={@form[:use_authors]} label="Authors">
+
+ <%= if existing_author do %>
+
+
+ Existing author
+ <%= existing_author.name %>
+
+ <% else %>
+
+
+ Missing author
+ <%= imported_author.name %>
+
+ <% end %>
+
+
+ Any missing authors will be imported with just their names. You can add additional details by visiting
+ <.brand_link navigate={~p"/admin/people"}>Authors & Narrators.
+
+
- <.import_form_row :if={@selected_book.cover_image} field={@form[:use_cover_image]} label="Image">
- <.image_with_size
- :if={@selected_book.cover_image}
- id={@form[:use_cover_image].id}
- src={@selected_book.cover_image.src}
- class="h-48 rounded-sm"
- />
-
+ <.import_form_row :if={selected_book.series != []} field={@form[:use_series]} label="Series">
+
+ <%= if existing_series do %>
+
+
+ Existing series
+ <%= existing_series.name %> #<%= imported_series.sequence %>
+
+ <% else %>
+
+
+ New series
+ <%= imported_series.title %> #<%= imported_series.sequence %>
+
+ <% end %>
+
+
- <:actions>
- <.button class="mt-2">Import
- <.button type="button" color={:zinc} phx-click={JS.exec("data-cancel", to: "#import-modal")}>
- Cancel
-
-
-
-
+ <.import_form_row :if={selected_book.cover_image} field={@form[:use_cover_image]} label="Image">
+ <.image_with_size
+ :if={selected_book.cover_image}
+ id={@form[:use_cover_image].id}
+ src={selected_book.cover_image.src}
+ class="h-48 rounded-sm"
+ />
+
+
+ <:actions>
+ <.button class="mt-2">Import
+ <.button type="button" color={:zinc} phx-click={JS.exec("data-cancel", to: "#import-modal")}>
+ Cancel
+
+
+
+
+
diff --git a/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.ex b/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.ex
index d3280a56..d404b6ef 100644
--- a/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.ex
+++ b/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.ex
@@ -35,60 +35,53 @@ defmodule AmbryWeb.Admin.BookLive.Form.GoodreadsImportForm do
@impl Phoenix.LiveComponent
def handle_async(:search, {:ok, books}, socket) do
- socket = assign(socket, books: AsyncResult.ok(socket.assigns.books, books))
+ [first_book | _rest] = books
- socket =
- case books do
- [] ->
- socket
-
- [first_book | _rest] ->
- socket
- |> assign(select_book_form: to_form(%{"book_id" => first_book.id}, as: :select_book))
- |> start_async(:fetch_editions, fn -> fetch_editions(first_book) end)
- end
-
- {:noreply, socket}
+ {:noreply,
+ socket
+ |> assign(books: AsyncResult.ok(socket.assigns.books, books))
+ |> assign(select_book_form: to_form(%{"book_id" => first_book.id}, as: :select_book))
+ |> start_async(:fetch_editions, fn -> fetch_editions(first_book) end)}
end
def handle_async(:search, {:exit, {:shutdown, :cancel}}, socket) do
{:noreply, assign(socket, books: AsyncResult.loading())}
end
- def handle_async(:search, {:exit, reason}, socket) do
- {:noreply, assign(socket, books: AsyncResult.failed(socket.assigns.books, {:exit, reason}))}
+ def handle_async(:search, {:exit, {exception, _stacktrace}}, socket) do
+ {:noreply, assign(socket, books: AsyncResult.failed(socket.assigns.books, exception.message))}
end
def handle_async(:fetch_editions, {:ok, editions}, socket) do
- socket = assign(socket, editions: AsyncResult.ok(socket.assigns.editions, editions))
-
selected_edition =
Enum.find(editions.editions, List.first(editions.editions), fn edition ->
edition.format |> String.downcase() |> String.contains?("audio")
end)
- socket =
- if selected_edition do
- socket
- |> assign(select_edition_form: to_form(%{"edition_id" => selected_edition.id}, as: :select_edition))
- |> start_async(:fetch_edition_details, fn -> fetch_edition_details(selected_edition) end)
- else
- socket
- end
-
- {:noreply, socket}
+ {:noreply,
+ socket
+ |> assign(editions: AsyncResult.ok(socket.assigns.editions, editions))
+ |> assign(
+ select_edition_form: to_form(%{"edition_id" => selected_edition.id}, as: :select_edition)
+ )
+ |> start_async(:fetch_edition_details, fn -> fetch_edition_details(selected_edition) end)}
end
def handle_async(:fetch_editions, {:exit, {:shutdown, :cancel}}, socket) do
{:noreply, assign(socket, editions: AsyncResult.loading())}
end
- def handle_async(:fetch_editions, {:exit, reason}, socket) do
- {:noreply, assign(socket, editions: AsyncResult.failed(socket.assigns.editions, {:exit, reason}))}
+ def handle_async(:fetch_editions, {:exit, {exception, _stacktrace}}, socket) do
+ {:noreply,
+ assign(socket, editions: AsyncResult.failed(socket.assigns.editions, exception.message))}
end
def handle_async(:fetch_edition_details, {:ok, results}, socket) do
- %{edition_details: edition_details, matching_authors: matching_authors, matching_series: matching_series} = results
+ %{
+ edition_details: edition_details,
+ matching_authors: matching_authors,
+ matching_series: matching_series
+ } = results
{:noreply,
assign(socket,
@@ -102,8 +95,11 @@ defmodule AmbryWeb.Admin.BookLive.Form.GoodreadsImportForm do
{:noreply, assign(socket, edition_details: AsyncResult.loading())}
end
- def handle_async(:fetch_edition_details, {:exit, reason}, socket) do
- {:noreply, assign(socket, edition_details: AsyncResult.failed(socket.assigns.edition_details, {:exit, reason}))}
+ def handle_async(:fetch_edition_details, {:exit, {exception, _stacktrace}}, socket) do
+ {:noreply,
+ assign(socket,
+ edition_details: AsyncResult.failed(socket.assigns.edition_details, exception.message)
+ )}
end
@impl Phoenix.LiveComponent
@@ -232,26 +228,22 @@ defmodule AmbryWeb.Admin.BookLive.Form.GoodreadsImportForm do
end
defp search(query) do
- Process.sleep(2000)
-
- case query |> String.trim() |> String.downcase() |> GoodReads.search_books() do
+ case "#{query}" |> String.trim() |> String.downcase() |> GoodReads.search_books() do
+ {:ok, []} -> raise "No books found"
{:ok, books} -> books
- {:error, reason} -> raise "Failed to fetch books from GoodReads: #{inspect(reason)}"
+ {:error, reason} -> raise "Unhandled error: #{inspect(reason)}"
end
end
defp fetch_editions(book) do
- Process.sleep(2000)
-
case GoodReads.editions(book.id) do
+ {:ok, %{editions: []}} -> raise "No editions found"
{:ok, editions} -> editions
- {:error, reason} -> raise "Failed to fetch editions from GoodReads: #{inspect(reason)}"
+ {:error, reason} -> raise "Unhandled error: #{inspect(reason)}"
end
end
defp fetch_edition_details(edition) do
- Process.sleep(2000)
-
case GoodReads.edition_details(edition.id) do
{:ok, edition_details} ->
matching_authors =
@@ -264,10 +256,14 @@ defmodule AmbryWeb.Admin.BookLive.Form.GoodreadsImportForm do
Search.find_first(series.name, Series)
end)
- %{edition_details: edition_details, matching_authors: matching_authors, matching_series: matching_series}
+ %{
+ edition_details: edition_details,
+ matching_authors: matching_authors,
+ matching_series: matching_series
+ }
{:error, reason} ->
- raise "Failed to fetch edition details from GoodReads: #{inspect(reason)}"
+ raise "Unhandled error: #{inspect(reason)}"
end
end
diff --git a/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.html.heex b/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.html.heex
index bf9fbb87..7f4eb55b 100644
--- a/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.html.heex
+++ b/lib/ambry_web/live/admin/book_live/form/goodreads_import_form.html.heex
@@ -14,8 +14,7 @@
<:failed :let={failure}>
- <.error>There was an error searching GoodReads for books:
- <%= inspect(failure, pretty: true) %>
+ <.error>There was an error searching GoodReads for books: <%= failure %>
@@ -41,8 +40,7 @@
<:failed :let={failure}>
- <.error>There was an error fetching editions from GoodReads:
-
<%= inspect(failure, pretty: true) %>
+ <.error>There was an error fetching editions from GoodReads: <%= failure %>
@@ -68,8 +66,7 @@
<:failed :let={failure}>
- <.error>There was an error fetching edition details from GoodReads:
-
<%= inspect(failure, pretty: true) %>
+ <.error>There was an error fetching edition details from GoodReads: <%= failure %>
<.simple_form for={@form} phx-submit="import" phx-target={@myself} container_class="!space-y-0">
diff --git a/lib/ambry_web/live/search_live/components.ex b/lib/ambry_web/live/search_live/components.ex
index 447e4470..5a49fb5a 100644
--- a/lib/ambry_web/live/search_live/components.ex
+++ b/lib/ambry_web/live/search_live/components.ex
@@ -32,7 +32,7 @@ defmodule AmbryWeb.SearchLive.Components do
<.link navigate={~p"/people/#{@person}"}>
-
+
<.link navigate={~p"/series/#{@series}"}>
-
+
<.series_images series_books={@series.series_books} />