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} />