diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a6e1ea18..088c633f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -35,7 +35,9 @@ "Bash(mix compile:*)", "mcp__tidewave__get_docs", "mcp__tidewave__search_package_docs", - "Bash(wc:*)" + "Bash(wc:*)", + "Bash(python3:*)", + "Bash(MIX_ENV=test mix ecto.migrate:*)" ] }, "enableAllProjectMcpServers": false diff --git a/config/config.exs b/config/config.exs index 3c206de1..c222ebb7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -47,6 +47,8 @@ config :music_library, MusicBrainz, user_agent: user_agent config :music_library, Discogs, personal_access_token: "change me", user_agent: user_agent +config :music_library, Wikipedia, user_agent: user_agent + # Configure esbuild (the version is required) config :esbuild, version: "0.27.3", @@ -78,7 +80,7 @@ config :phoenix, :json_library, JSON config :music_library, Oban, engine: Oban.Engines.Lite, - queues: [default: 10, heavy_writes: 1, music_brainz: 1, discogs: 1], + queues: [default: 10, heavy_writes: 1, music_brainz: 1, discogs: 1, wikipedia: 1], repo: MusicLibrary.BackgroundRepo, plugins: [ {Oban.Plugins.Cron, diff --git a/config/test.exs b/config/test.exs index 2a65e733..7d858300 100644 --- a/config/test.exs +++ b/config/test.exs @@ -57,6 +57,12 @@ config :music_library, Discogs, max_retries: 0 ] +config :music_library, Wikipedia, + req_options: [ + plug: {Req.Test, Wikipedia.API}, + max_retries: 0 + ] + config :phoenix_test, :endpoint, MusicLibraryWeb.Endpoint config :music_library, Oban, testing: :manual diff --git a/lib/music_brainz/artist.ex b/lib/music_brainz/artist.ex index 957eafb1..c88fe574 100644 --- a/lib/music_brainz/artist.ex +++ b/lib/music_brainz/artist.ex @@ -32,6 +32,14 @@ defmodule MusicBrainz.Artist do end end + def get_wikidata_id(r) do + Enum.find_value(r.relations, fn relation -> + if relation.type == "wikidata" do + parse_wikidata_id(relation.url["resource"]) + end + end) + end + def url(id) do "https://musicbrainz.org/artist/#{id}" end @@ -47,4 +55,7 @@ defmodule MusicBrainz.Artist do defp parse_discogs_id("https://www.discogs.com/artist/" <> id), do: String.to_integer(id) defp parse_discogs_id(_other), do: nil + + defp parse_wikidata_id("https://www.wikidata.org/wiki/" <> id), do: id + defp parse_wikidata_id(_other), do: nil end diff --git a/lib/music_library/artists.ex b/lib/music_library/artists.ex index b962b343..85667667 100644 --- a/lib/music_library/artists.ex +++ b/lib/music_library/artists.ex @@ -134,6 +134,32 @@ defmodule MusicLibrary.Artists do enqueue_worker(Worker.ArtistRefreshDiscogsData, %{"id" => artist_info.id}) end + def fetch_wikipedia_data(artist_id) do + artist_info = get_artist_info!(artist_id) + + if wikidata_id = ArtistInfo.wikidata_id(artist_info) do + case Wikipedia.get_artist_summary(wikidata_id) do + {:ok, summary} -> + artist_info + |> ArtistInfo.changeset(%{wikipedia_data: summary}) + |> Repo.update() + + error -> + error + end + else + {:ok, artist_info} + end + end + + def refresh_wikipedia_data(artist_id) do + fetch_wikipedia_data(artist_id) + end + + def refresh_wikipedia_data_async(artist_info) do + enqueue_worker(Worker.ArtistRefreshWikipediaData, %{"id" => artist_info.id}) + end + def create_artist_info(attrs) do %ArtistInfo{} |> ArtistInfo.changeset(attrs) diff --git a/lib/music_library/artists/artist_info.ex b/lib/music_library/artists/artist_info.ex index 73d38aa3..7b54b2de 100644 --- a/lib/music_library/artists/artist_info.ex +++ b/lib/music_library/artists/artist_info.ex @@ -10,6 +10,7 @@ defmodule MusicLibrary.Artists.ArtistInfo do schema "artist_infos" do field :musicbrainz_data, :map, default: %{} field :discogs_data, :map, default: %{} + field :wikipedia_data, :map, default: %{} field :image_data_hash, :string has_one :note, Note, foreign_key: :musicbrainz_id @@ -23,6 +24,7 @@ defmodule MusicLibrary.Artists.ArtistInfo do :id, :musicbrainz_data, :discogs_data, + :wikipedia_data, :image_data_hash ]) |> validate_required([:musicbrainz_data]) @@ -82,4 +84,33 @@ defmodule MusicLibrary.Artists.ArtistInfo do _ -> nil end end + + def wikidata_id(artist_info) do + relations = get_in(artist_info.musicbrainz_data, ["relations"]) || [] + + Enum.find_value(relations, fn + %{"type" => "wikidata", "url" => %{"resource" => "https://www.wikidata.org/wiki/" <> id}} -> + id + + _ -> + nil + end) + end + + def wikipedia_bio(artist_info) do + get_in(artist_info.wikipedia_data, ["intro_html"]) || + get_in(artist_info.wikipedia_data, ["extract_html"]) + end + + def wikipedia_summary(artist_info) do + get_in(artist_info.wikipedia_data, ["extract"]) + end + + def wikipedia_url(artist_info) do + get_in(artist_info.wikipedia_data, ["content_urls", "desktop", "page"]) + end + + def wikipedia_description(artist_info) do + get_in(artist_info.wikipedia_data, ["description"]) + end end diff --git a/lib/music_library/worker/artist_refresh_wikipedia_data.ex b/lib/music_library/worker/artist_refresh_wikipedia_data.ex new file mode 100644 index 00000000..cedee3fe --- /dev/null +++ b/lib/music_library/worker/artist_refresh_wikipedia_data.ex @@ -0,0 +1,12 @@ +defmodule MusicLibrary.Worker.ArtistRefreshWikipediaData do + use Oban.Worker, queue: :wikipedia, max_attempts: 3 + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"id" => artist_info_id}}) do + result = MusicLibrary.Artists.refresh_wikipedia_data(artist_info_id) + + Process.sleep(1_000) + + result + end +end diff --git a/lib/music_library/worker/fetch_artist_info.ex b/lib/music_library/worker/fetch_artist_info.ex index f6270a6c..5e96740a 100644 --- a/lib/music_library/worker/fetch_artist_info.ex +++ b/lib/music_library/worker/fetch_artist_info.ex @@ -4,6 +4,7 @@ defmodule MusicLibrary.Worker.FetchArtistInfo do @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => artist_id}}) do with {:ok, _artist_info} <- MusicLibrary.Artists.fetch_artist_info(artist_id), + {:ok, _artist_info} <- MusicLibrary.Artists.fetch_wikipedia_data(artist_id), {:ok, _artist_info} <- MusicLibrary.Artists.fetch_image(artist_id) do :ok end diff --git a/lib/music_library_web/live/artist_live/show.ex b/lib/music_library_web/live/artist_live/show.ex index 324de1a9..9f72932f 100644 --- a/lib/music_library_web/live/artist_live/show.ex +++ b/lib/music_library_web/live/artist_live/show.ex @@ -101,6 +101,7 @@ defmodule MusicLibraryWeb.ArtistLive.Show do {:noreply, socket |> assign(:artist_info, artist_info) + |> assign(:biography, build_biography(artist_info)) |> put_toast(:info, gettext("Artist info refreshed successfully"))} {:error, reason} -> @@ -113,6 +114,25 @@ defmodule MusicLibraryWeb.ArtistLive.Show do end end + def handle_event("refresh_wikipedia_data", %{"id" => id}, socket) do + case Artists.refresh_wikipedia_data(id) do + {:ok, artist_info} -> + {:noreply, + socket + |> assign(:artist_info, artist_info) + |> assign(:biography, build_biography(artist_info)) + |> put_toast(:info, gettext("Wikipedia data refreshed successfully"))} + + {:error, reason} -> + {:noreply, + socket + |> put_toast( + :error, + gettext("Error refreshing Wikipedia data") <> "," <> inspect(reason) + )} + end + end + def handle_event("refresh_artist_image", %{"id" => id}, socket) do case Artists.fetch_image(id) do {:ok, artist_info} -> @@ -171,6 +191,7 @@ defmodule MusicLibraryWeb.ArtistLive.Show do |> assign(:current_section, :artists) |> assign(:artist, artist) |> assign(:artist_info, artist_info) + |> assign(:biography, build_biography(artist_info)) |> assign(:external_links, ArtistInfo.external_links(artist_info)) |> assign(:country, ArtistInfo.country(artist_info)) |> assign_async(:lastfm_artist_info, fn -> @@ -250,6 +271,20 @@ defmodule MusicLibraryWeb.ArtistLive.Show do } end + defp build_biography(artist_info) do + bio_html = ArtistInfo.wikipedia_bio(artist_info) + + if bio_html do + %{ + source: "Wikipedia", + summary_html: ArtistInfo.wikipedia_summary(artist_info), + bio_html: bio_html, + url: ArtistInfo.wikipedia_url(artist_info), + description: ArtistInfo.wikipedia_description(artist_info) + } + end + end + # Bios start with text, then a link to read more on Last.fm, followed by a license text. # We split the bio at the read more link in order to render the license separately. defp render_bio(bio) do diff --git a/lib/music_library_web/live/artist_live/show.html.heex b/lib/music_library_web/live/artist_live/show.html.heex index d23f870c..1d86793a 100644 --- a/lib/music_library_web/live/artist_live/show.html.heex +++ b/lib/music_library_web/live/artist_live/show.html.heex @@ -73,6 +73,20 @@ /> {gettext("Refresh info")} + <.dropdown_link + id={"actions-#{@artist.musicbrainz_id}-refresh-wikipedia"} + phx-click={ + JS.push("refresh_wikipedia_data", value: %{id: @artist.musicbrainz_id}) + } + > + <.icon + name="hero-arrow-path" + class="h-4 w-4 mr-1 phx-click-loading:animate-spin" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Refresh Wikipedia")} + @@ -141,34 +155,19 @@ /> - <.async_result :let={lastfm_artist_info} assign={@lastfm_artist_info}> - <:loading> -
+ {@biography.description} +
+{@biography.summary_html}
<.link class="block mt-2 text-sm font-medium text-zinc-900 dark:text-zinc-400" phx-click={Fluxon.open_dialog("bio")} @@ -183,14 +182,80 @@Steven John Wilson (born 3 November 1967) is an English musician. He is most associated with the progressive rock genre, though his influences and work extend beyond it. Wilson first became known as the founder, lead guitarist, singer, and songwriter of the band Porcupine Tree, as well as being a member of several other bands.
\nWilson has been involved in musical projects including No-Man, Bass Communion, Blackfield, and Storm Corrosion. He has also worked as a record producer and is known for his surround sound and high-fidelity stereo remixes of classic albums by artists such as King Crimson, Jethro Tull, Tears for Fears, Roxy Music, and Yes.
\nHis solo career began in earnest in 2008. He has released six solo studio albums, including The Raven That Refused to Sing (And Other Stories) (2013) and Hand. Cannot. Erase. (2015), both of which received critical acclaim. His seventh album, The Overview, was released in 2025.
" + } + } + } +} diff --git a/test/support/fixtures/wikipedia/article_summary.json b/test/support/fixtures/wikipedia/article_summary.json new file mode 100644 index 00000000..3ed2358d --- /dev/null +++ b/test/support/fixtures/wikipedia/article_summary.json @@ -0,0 +1,35 @@ +{ + "type": "standard", + "title": "Steven Wilson", + "displaytitle": "Steven Wilson", + "namespace": { + "id": 0, + "text": "" + }, + "wikibase_item": "Q352766", + "titles": { + "canonical": "Steven_Wilson", + "normalized": "Steven Wilson", + "display": "Steven Wilson" + }, + "pageid": 1845781, + "extract": "Steven John Wilson is an English musician. He is most associated with the progressive rock genre, though his influences and work extend beyond it.", + "extract_html": "Steven John Wilson is an English musician. He is most associated with the progressive rock genre, though his influences and work extend beyond it.
", + "description": "English musician and record producer", + "content_urls": { + "desktop": { + "page": "https://en.wikipedia.org/wiki/Steven_Wilson", + "revisions": "https://en.wikipedia.org/wiki/Steven_Wilson?action=history", + "edit": "https://en.wikipedia.org/wiki/Steven_Wilson?action=edit", + "talk": "https://en.wikipedia.org/wiki/Talk:Steven_Wilson" + }, + "mobile": { + "page": "https://en.m.wikipedia.org/wiki/Steven_Wilson", + "revisions": "https://en.m.wikipedia.org/w/index.php?title=Steven_Wilson&action=history", + "edit": "https://en.m.wikipedia.org/w/index.php?title=Steven_Wilson&action=edit", + "talk": "https://en.m.wikipedia.org/wiki/Talk:Steven_Wilson" + } + }, + "lang": "en", + "dir": "ltr" +} diff --git a/test/support/fixtures/wikipedia/fixtures.ex b/test/support/fixtures/wikipedia/fixtures.ex new file mode 100644 index 00000000..2467e4e5 --- /dev/null +++ b/test/support/fixtures/wikipedia/fixtures.ex @@ -0,0 +1,35 @@ +defmodule Wikipedia.Fixtures do + @fixtures_folder Path.join([File.cwd!(), "test/support/fixtures/wikipedia"]) + + def wikidata_response do + Path.join([@fixtures_folder, "wikidata_response.json"]) + |> File.read!() + |> JSON.decode!() + end + + def wikidata_response_no_enwiki do + Path.join([@fixtures_folder, "wikidata_response_no_enwiki.json"]) + |> File.read!() + |> JSON.decode!() + end + + def article_summary do + Path.join([@fixtures_folder, "article_summary.json"]) + |> File.read!() + |> JSON.decode!() + end + + def article_extract do + Path.join([@fixtures_folder, "article_extract.json"]) + |> File.read!() + |> JSON.decode!() + end + + def article_extract_html do + article_extract() + |> get_in(["query", "pages"]) + |> Map.values() + |> List.first() + |> Map.get("extract") + end +end diff --git a/test/support/fixtures/wikipedia/wikidata_response.json b/test/support/fixtures/wikipedia/wikidata_response.json new file mode 100644 index 00000000..ade6de40 --- /dev/null +++ b/test/support/fixtures/wikipedia/wikidata_response.json @@ -0,0 +1,16 @@ +{ + "entities": { + "Q352766": { + "type": "item", + "id": "Q352766", + "sitelinks": { + "enwiki": { + "site": "enwiki", + "title": "Steven Wilson", + "badges": [] + } + } + } + }, + "success": 1 +} diff --git a/test/support/fixtures/wikipedia/wikidata_response_no_enwiki.json b/test/support/fixtures/wikipedia/wikidata_response_no_enwiki.json new file mode 100644 index 00000000..cabb52c1 --- /dev/null +++ b/test/support/fixtures/wikipedia/wikidata_response_no_enwiki.json @@ -0,0 +1,10 @@ +{ + "entities": { + "Q999999": { + "type": "item", + "id": "Q999999", + "sitelinks": {} + } + }, + "success": 1 +} diff --git a/test/wikipedia_test.exs b/test/wikipedia_test.exs new file mode 100644 index 00000000..775dfdda --- /dev/null +++ b/test/wikipedia_test.exs @@ -0,0 +1,42 @@ +defmodule WikipediaTest do + use ExUnit.Case, async: true + + describe "get_artist_summary/1" do + test "resolves wikidata ID to Wikipedia summary with full intro" do + wikidata_id = "Q352766" + summary = Wikipedia.Fixtures.article_summary() + intro_html = Wikipedia.Fixtures.article_extract_html() + + Req.Test.stub(Wikipedia.API, fn conn -> + case conn.request_path do + "/w/api.php" -> + case conn.params["action"] do + "wbgetentities" -> + Req.Test.json(conn, Wikipedia.Fixtures.wikidata_response()) + + "query" -> + Req.Test.json(conn, Wikipedia.Fixtures.article_extract()) + end + + "/api/rest_v1/page/summary/Steven%20Wilson" -> + Req.Test.json(conn, summary) + end + end) + + assert {:ok, result} = Wikipedia.get_artist_summary(wikidata_id) + assert result["extract"] == summary["extract"] + assert result["description"] == summary["description"] + assert result["intro_html"] == intro_html + end + + test "returns error when no English Wikipedia article exists" do + wikidata_id = "Q999999" + + Req.Test.stub(Wikipedia.API, fn conn -> + Req.Test.json(conn, Wikipedia.Fixtures.wikidata_response_no_enwiki()) + end) + + assert {:error, :no_english_wikipedia} = Wikipedia.get_artist_summary(wikidata_id) + end + end +end