Skip to content

Commit 714ba4a

Browse files
authored
Merge pull request #24 from jfro/jtk/hardcover-mediatype
Use edition media format for ISBN lookups on hardcover
2 parents 2de70c0 + 18c4bbd commit 714ba4a

File tree

22 files changed

+1854
-1135
lines changed

22 files changed

+1854
-1135
lines changed

lib/fuzzy_catalog/catalog/providers/hardcover_provider.ex

Lines changed: 250 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ defmodule FuzzyCatalog.Catalog.Providers.HardcoverProvider do
1919

2020
case validate_isbn(clean_isbn) do
2121
:valid ->
22-
query = build_search_query(clean_isbn, ["isbns"])
22+
query = build_isbn_query(clean_isbn)
2323

2424
case make_graphql_request(query) do
2525
{:ok, response} ->
26-
parse_search_response(response, :single)
26+
parse_editions_response(response)
2727

2828
{:error, reason} ->
2929
{:error, reason}
@@ -76,6 +76,43 @@ defmodule FuzzyCatalog.Catalog.Providers.HardcoverProvider do
7676
end
7777
end
7878

79+
defp build_isbn_query(isbn) do
80+
"""
81+
{
82+
editions(where: {
83+
_or: [
84+
{isbn_10: {_eq: "#{escape_graphql_string(isbn)}"}},
85+
{isbn_13: {_eq: "#{escape_graphql_string(isbn)}"}}
86+
]
87+
}, limit: 1) {
88+
id
89+
title
90+
subtitle
91+
edition_format
92+
isbn_10
93+
isbn_13
94+
pages
95+
release_date
96+
asin
97+
audio_seconds
98+
book {
99+
id
100+
title
101+
description
102+
cached_contributors
103+
book_series {
104+
series {
105+
name
106+
}
107+
}
108+
cached_tags
109+
cached_image
110+
}
111+
}
112+
}
113+
"""
114+
end
115+
79116
defp build_search_query(query_string, fields) do
80117
# Format fields as comma-separated string: "field1,field2"
81118
fields_str = Enum.join(fields, ",")
@@ -194,6 +231,210 @@ defmodule FuzzyCatalog.Catalog.Providers.HardcoverProvider do
194231
{:error, "Invalid response format"}
195232
end
196233

234+
defp parse_editions_response(%{"data" => %{"editions" => editions}}) when is_list(editions) do
235+
case editions do
236+
[] ->
237+
{:error, "No results found"}
238+
239+
[edition | _] ->
240+
{:ok, normalize_edition_data(edition)}
241+
end
242+
end
243+
244+
defp parse_editions_response(response) do
245+
Logger.error("Hardcover: Unexpected editions response structure: #{inspect(response)}")
246+
{:error, "Invalid response format"}
247+
end
248+
249+
defp normalize_edition_data(edition) when is_map(edition) do
250+
book_data = edition["book"] || %{}
251+
252+
# Extract edition format and map to media types
253+
edition_format = edition["edition_format"]
254+
media_types = map_edition_format_to_media_types(edition_format)
255+
256+
# Extract book-level data from cached fields
257+
contributors = extract_from_cached(book_data["cached_contributors"])
258+
description = book_data["description"]
259+
book_series = book_data["book_series"] || []
260+
tags = extract_from_cached(book_data["cached_tags"])
261+
image = extract_from_cached(book_data["cached_image"])
262+
263+
# Parse publication date
264+
publication_date = parse_date(edition["release_date"])
265+
266+
%{
267+
title: edition["title"] || book_data["title"] || "Unknown Title",
268+
subtitle: edition["subtitle"],
269+
author: format_contributors(contributors),
270+
isbn10: edition["isbn_10"],
271+
isbn13: edition["isbn_13"],
272+
publisher: nil,
273+
publication_date: publication_date,
274+
pages: edition["pages"],
275+
genre: format_tags_as_genres(tags),
276+
description: description,
277+
series: format_book_series(book_series),
278+
series_number: nil,
279+
cover_url: extract_cover_url(image),
280+
suggested_media_types: media_types
281+
}
282+
end
283+
284+
# Expose for testing
285+
if Mix.env() == :test do
286+
def __map_edition_format_to_media_types__(format),
287+
do: map_edition_format_to_media_types(format)
288+
289+
def __extract_from_cached__(data), do: extract_from_cached(data)
290+
def __format_contributors__(contributors), do: format_contributors(contributors)
291+
def __format_genres__(genres), do: format_tags_as_genres(genres)
292+
def __format_series__(series), do: format_book_series(series)
293+
end
294+
295+
defp map_edition_format_to_media_types(nil), do: []
296+
defp map_edition_format_to_media_types(""), do: []
297+
298+
defp map_edition_format_to_media_types(format) when is_binary(format) do
299+
# Normalize format string (lowercase, trim)
300+
normalized = format |> String.downcase() |> String.trim()
301+
302+
case normalized do
303+
# Direct matches
304+
"hardcover" ->
305+
["hardcover"]
306+
307+
"paperback" ->
308+
["paperback"]
309+
310+
"audiobook" ->
311+
["audiobook"]
312+
313+
"ebook" ->
314+
["ebook"]
315+
316+
"audio" ->
317+
["audiobook"]
318+
319+
"digital" ->
320+
["ebook"]
321+
322+
# Common variations
323+
"mass market paperback" ->
324+
["paperback"]
325+
326+
"trade paperback" ->
327+
["paperback"]
328+
329+
"board book" ->
330+
["hardcover"]
331+
332+
"library binding" ->
333+
["hardcover"]
334+
335+
# Kindle/digital formats
336+
format when format in ["kindle", "kindle edition"] ->
337+
["ebook"]
338+
339+
# Audio formats
340+
format when format in ["audio cd", "audio download", "mp3 cd"] ->
341+
["audiobook"]
342+
343+
# Unknown
344+
_ ->
345+
Logger.debug("Unknown Hardcover edition_format: #{format}")
346+
[]
347+
end
348+
end
349+
350+
defp extract_from_cached(nil), do: nil
351+
352+
defp extract_from_cached(json) when is_binary(json) do
353+
case Jason.decode(json) do
354+
{:ok, data} -> data
355+
{:error, _} -> nil
356+
end
357+
end
358+
359+
defp extract_from_cached(data) when is_map(data) or is_list(data), do: data
360+
361+
defp format_contributors(nil), do: "Unknown Author"
362+
defp format_contributors([]), do: "Unknown Author"
363+
364+
defp format_contributors(contributors) when is_list(contributors) do
365+
contributors
366+
|> Enum.map(fn
367+
%{"author" => %{"name" => name}} -> name
368+
%{"name" => name} -> name
369+
name when is_binary(name) -> name
370+
_ -> nil
371+
end)
372+
|> Enum.reject(&is_nil/1)
373+
|> case do
374+
[] -> "Unknown Author"
375+
names -> Enum.join(names, ", ")
376+
end
377+
end
378+
379+
defp format_book_series(nil), do: nil
380+
defp format_book_series([]), do: nil
381+
382+
defp format_book_series(book_series) when is_list(book_series) do
383+
# book_series is a list of %{"series" => %{"name" => "..."}}
384+
book_series
385+
|> Enum.map(fn
386+
%{"series" => %{"name" => name}} -> name
387+
_ -> nil
388+
end)
389+
|> Enum.reject(&is_nil/1)
390+
|> List.first()
391+
end
392+
393+
defp format_tags_as_genres(nil), do: nil
394+
defp format_tags_as_genres([]), do: nil
395+
396+
defp format_tags_as_genres(tags) when is_map(tags) do
397+
# cached_tags is a map with genre/tag categories as keys
398+
# Try to extract genre-like tags from the structure
399+
tags
400+
|> Map.values()
401+
|> List.flatten()
402+
|> Enum.take(3)
403+
|> Enum.map(fn
404+
%{"tag" => tag} -> tag
405+
%{"name" => name} -> name
406+
tag when is_binary(tag) -> tag
407+
_ -> nil
408+
end)
409+
|> Enum.reject(&is_nil/1)
410+
|> case do
411+
[] -> nil
412+
names -> Enum.join(names, ", ")
413+
end
414+
end
415+
416+
defp format_tags_as_genres(tags) when is_list(tags) do
417+
tags
418+
|> Enum.take(3)
419+
|> Enum.map(fn
420+
%{"tag" => tag} -> tag
421+
%{"name" => name} -> name
422+
tag when is_binary(tag) -> tag
423+
_ -> nil
424+
end)
425+
|> Enum.reject(&is_nil/1)
426+
|> case do
427+
[] -> nil
428+
names -> Enum.join(names, ", ")
429+
end
430+
end
431+
432+
defp parse_date(nil), do: nil
433+
434+
defp parse_date(date_str) when is_binary(date_str) do
435+
FuzzyCatalog.DateUtils.parse_date(date_str)
436+
end
437+
197438
defp normalize_hardcover_data(book_data) when is_map(book_data) do
198439
# Extract ISBNs
199440
isbns = book_data["isbns"] || []
@@ -218,6 +459,11 @@ defmodule FuzzyCatalog.Catalog.Providers.HardcoverProvider do
218459
# Extract cover image URL (if available)
219460
cover_url = extract_cover_url(book_data["image"]) || book_data["cover_url"]
220461

462+
# Search results don't include edition_format in the document
463+
# They aggregate all editions, so format info isn't available
464+
# Users can get format by doing an ISBN lookup instead
465+
media_types = []
466+
221467
%{
222468
title: book_data["title"] || "Unknown Title",
223469
subtitle: book_data["subtitle"],
@@ -232,7 +478,7 @@ defmodule FuzzyCatalog.Catalog.Providers.HardcoverProvider do
232478
series: series,
233479
series_number: nil,
234480
cover_url: cover_url,
235-
suggested_media_types: []
481+
suggested_media_types: media_types
236482
}
237483
end
238484

@@ -258,6 +504,7 @@ defmodule FuzzyCatalog.Catalog.Providers.HardcoverProvider do
258504
end
259505

260506
defp parse_release_year(nil), do: nil
507+
261508
defp parse_release_year(year) when is_integer(year) do
262509
FuzzyCatalog.DateUtils.parse_date(year)
263510
end

lib/fuzzy_catalog/import_export.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,4 @@ defmodule FuzzyCatalog.ImportExport do
272272

273273
count
274274
end
275-
end
275+
end

lib/fuzzy_catalog/import_export/exporter.ex

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,19 @@ defmodule FuzzyCatalog.ImportExport.Exporter do
130130
descriptions = []
131131

132132
descriptions =
133-
if media_type = filters["media_type"], do: ["Media type: #{media_type}" | descriptions], else: descriptions
133+
if media_type = filters["media_type"],
134+
do: ["Media type: #{media_type}" | descriptions],
135+
else: descriptions
134136

135137
descriptions =
136138
if filters["no_external_id"] == "true",
137139
do: ["Items without external IDs" | descriptions],
138140
else: descriptions
139141

140142
descriptions =
141-
if date_from = filters["date_from"], do: ["Added after: #{date_from}" | descriptions], else: descriptions
143+
if date_from = filters["date_from"],
144+
do: ["Added after: #{date_from}" | descriptions],
145+
else: descriptions
142146

143147
descriptions =
144148
if series = filters["series"], do: ["Series: #{series}" | descriptions], else: descriptions
@@ -330,13 +334,15 @@ defmodule FuzzyCatalog.ImportExport.Exporter do
330334
end
331335

332336
defp escape_csv_field(nil), do: ""
337+
333338
defp escape_csv_field(value) when is_binary(value) do
334339
if String.contains?(value, [",", "\"", "\n", "\r"]) do
335340
"\"#{String.replace(value, "\"", "\"\"")}\""
336341
else
337342
value
338343
end
339344
end
345+
340346
defp escape_csv_field(value), do: to_string(value)
341347

342348
defp store_and_complete_job(%Job{} = job, temp_path, filename) do
@@ -385,4 +391,4 @@ defmodule FuzzyCatalog.ImportExport.Exporter do
385391
formats: @supported_formats
386392
}
387393
end
388-
end
394+
end

lib/fuzzy_catalog/import_export/job.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ defmodule FuzzyCatalog.ImportExport.Job do
108108
Returns true if the job has expired
109109
"""
110110
def expired?(%__MODULE__{expires_at: nil}), do: false
111+
111112
def expired?(%__MODULE__{expires_at: expires_at}) do
112113
DateTime.compare(DateTime.utc_now(), expires_at) == :gt
113114
end
@@ -129,4 +130,4 @@ defmodule FuzzyCatalog.ImportExport.Job do
129130
"""
130131
def failed?(%__MODULE__{status: "failed"}), do: true
131132
def failed?(_), do: false
132-
end
133+
end

lib/fuzzy_catalog/storage.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ defmodule FuzzyCatalog.Storage do
9797
- {:ok, storage_path} on success
9898
- {:error, reason} on failure
9999
"""
100-
def store_file(source_path, target_path) when is_binary(source_path) and is_binary(target_path) do
100+
def store_file(source_path, target_path)
101+
when is_binary(source_path) and is_binary(target_path) do
101102
if File.exists?(source_path) do
102103
case File.read(source_path) do
103104
{:ok, file_content} ->

0 commit comments

Comments
 (0)