From 4569c6727ecad7619a13052e4909ef123494f984 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Mon, 9 Mar 2026 13:39:12 +0000 Subject: [PATCH] Add function to download a record's tracklist for printing --- assets/js/app.js | 13 ++ docs/architecture.md | 2 + lib/music_library/records/tracklist_pdf.ex | 102 +++++++++++++ lib/music_library_web/components/release.ex | 78 +++++++--- mix.exs | 3 + mix.lock | 1 + priv/gettext/default.pot | 5 + priv/gettext/en/LC_MESSAGES/default.po | 5 + .../records/tracklist_pdf_test.exs | 140 ++++++++++++++++++ 9 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 lib/music_library/records/tracklist_pdf.ex create mode 100644 test/music_library/records/tracklist_pdf_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index ded9b8ab..bb42d713 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -89,6 +89,19 @@ window.addEventListener("music_library:confetti", (_event) => { spread: 200, }); }); +window.addEventListener("phx:music_library:download", (event) => { + const { data, filename, content_type } = event.detail; + const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { type: content_type }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}); // connect if there are any LiveViews on the page liveSocket.connect(); diff --git a/docs/architecture.md b/docs/architecture.md index 6a2f4237..7aecacf7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,6 +115,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB): |--------|---------| | `Records.SearchParser` | Parses search syntax: `artist:X`, `album:X`, `genre:"Y"`, `format:cd`, `type:album`, `purchase_year:2024`, free text | | `Records.Similarity` | Embedding generation (OpenAI, enriched with Last.fm tags), cosine-distance search (sqlite-vec) | +| `Records.TracklistPdf` | Generates 120mm×120mm PDF tracklist from record + release data (Typst) | | `Batch` | Generic batch runner: stream + transaction + error accumulation | | `Records.Batch` | Batch operations: refresh all MusicBrainz data, generate all embeddings (uses `Batch`) | | `Artists.Batch` | Batch refresh: MusicBrainz, Discogs, Wikipedia, Last.fm for all artists (uses `Batch`) | @@ -324,6 +325,7 @@ All events are namespaced with `music_library:` prefix. | `music_library:clipcopy` | Copy text to clipboard | | `music_library:scroll_top` | Scroll window to top | | `music_library:confetti` | Trigger canvas-confetti animation | +| `music_library:download` | Decode base64 blob and trigger browser file download (dispatched via `push_event`, prefixed `phx:` on client) | ### NPM Dependencies diff --git a/lib/music_library/records/tracklist_pdf.ex b/lib/music_library/records/tracklist_pdf.ex new file mode 100644 index 00000000..54ce8d7e --- /dev/null +++ b/lib/music_library/records/tracklist_pdf.ex @@ -0,0 +1,102 @@ +defmodule MusicLibrary.Records.TracklistPdf do + alias MusicBrainz.Release + alias MusicLibraryWeb.Duration + alias Typst.Format + + @spec generate(MusicLibrary.Records.Record.t(), Release.t()) :: + {:ok, binary()} | {:error, term()} + def generate(record, release) do + markup = build_markup(record, release) + Typst.render_to_pdf(markup) + end + + defp build_markup(record, release) do + """ + #set page(width: 120mm, height: 120mm, margin: 5mm) + #set text(font: "Liberation Sans", size: 8pt) + + #align(center)[ + #text(size: 10pt, weight: "bold")[#{Format.escape(artist_names(record))}] + #linebreak() + #text(size: 9pt, style: "italic")[#{Format.escape(record.title)}] + ] + + #v(3mm) + + #{build_media(release)} + """ + end + + defp build_media(release) do + media_count = Release.media_count(release) + + release.media + |> Enum.map_join("\n", fn medium -> + build_medium(medium, media_count) + end) + end + + defp build_medium(medium, media_count) do + header = + if media_count > 1 do + label = medium_label(medium) + + """ + #v(2mm) + #text(size: 8pt, weight: "bold")[#{Format.escape(label)}] + #v(1mm) + """ + else + "" + end + + tracks = + medium.tracks + |> Enum.map_join("\n", &build_track/1) + + header <> tracks + end + + defp build_track(track) do + number = Format.escape(track.number || to_string(track.position)) + title = Format.escape(track.title) + + duration = + if track.length do + Format.escape(Duration.format_duration(track.length)) + else + "" + end + + """ + #grid( + columns: (12pt, 1fr, auto), + gutter: 4pt, + align(right)[#{number}], + [#{title}], + align(right)[#{duration}] + )\ + """ + end + + defp artist_names(record) do + Enum.map_join(record.artists, fn artist -> + artist.name <> (artist.joinphrase || "") + end) + end + + defp medium_label(medium) do + title = + if medium.title && medium.title != "" do + medium.title + else + "Disc #{medium.number}" + end + + if medium.format do + "#{title} (#{medium.format})" + else + title + end + end +end diff --git a/lib/music_library_web/components/release.ex b/lib/music_library_web/components/release.ex index 1fc86e8a..48891546 100644 --- a/lib/music_library_web/components/release.ex +++ b/lib/music_library_web/components/release.ex @@ -4,6 +4,7 @@ defmodule MusicLibraryWeb.Components.Release do require Logger alias MusicBrainz.Release + alias MusicLibrary.Records.TracklistPdf alias MusicLibrary.ScrobbleActivity alias MusicLibraryWeb.Duration alias MusicLibraryWeb.ErrorMessages @@ -69,23 +70,34 @@ defmodule MusicLibraryWeb.Components.Release do /> {gettext("Tracks")} - <.button - :if={@can_scrobble? && @release_with_tracks.ok?} - size="sm" - disabled={@already_scrobbled} - phx-click={ - if MapSet.size(@selected_tracks) > 0, - do: "scrobble_selected_tracks", - else: "scrobble_release" - } - phx-target={@myself} - phx-disable-with={gettext("Scrobbling...")} - > - {scrobble_button_label(@selected_tracks)} - - <.button :if={!@can_scrobble?} size="sm" href={LastFm.auth_url()}> - {gettext("Connect your Last.fm account")} - +
+ <.button + :if={@release_with_tracks.ok?} + variant="ghost" + size="sm" + phx-click="print_tracklist" + phx-target={@myself} + > + <.icon name="hero-printer" class="h-4 w-4" /> + + <.button + :if={@can_scrobble? && @release_with_tracks.ok?} + size="sm" + disabled={@already_scrobbled} + phx-click={ + if MapSet.size(@selected_tracks) > 0, + do: "scrobble_selected_tracks", + else: "scrobble_release" + } + phx-target={@myself} + phx-disable-with={gettext("Scrobbling...")} + > + {scrobble_button_label(@selected_tracks)} + + <.button :if={!@can_scrobble?} size="sm" href={LastFm.auth_url()}> + {gettext("Connect your Last.fm account")} + +
@@ -387,6 +399,38 @@ defmodule MusicLibraryWeb.Components.Release do {:noreply, socket} end + def handle_event("print_tracklist", _params, socket) when release_loaded?(socket.assigns) do + release = socket.assigns.release_with_tracks.result + record = socket.assigns.record + + case TracklistPdf.generate(record, release) do + {:ok, pdf_binary} -> + filename = "#{record.title} - Tracklist.pdf" + + {:noreply, + push_event(socket, "music_library:download", %{ + data: Base.encode64(pdf_binary), + filename: filename, + content_type: "application/pdf" + })} + + {:error, reason} -> + Logger.error("Error generating tracklist PDF: #{inspect(reason)}") + + put_toast!( + :error, + gettext("Error generating tracklist PDF") <> + ": " <> ErrorMessages.friendly_message(reason) + ) + + {:noreply, socket} + end + end + + def handle_event("print_tracklist", _params, socket) do + {:noreply, socket} + end + def handle_event("load_release_tracks", _params, socket) do selected_release_id = socket.assigns.record.selected_release_id diff --git a/mix.exs b/mix.exs index 9105f73c..dc638c18 100644 --- a/mix.exs +++ b/mix.exs @@ -106,6 +106,9 @@ defmodule MusicLibrary.MixProject do # Notes {:mdex, "~> 0.11.6"}, + # PDF generation + {:typst, "~> 0.3.1"}, + # Syntax highlighting {:lumis, "~> 0.1"}, diff --git a/mix.lock b/mix.lock index c35ed3f8..6b924cdf 100644 --- a/mix.lock +++ b/mix.lock @@ -80,6 +80,7 @@ "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"}, "time_zone_info": {:hex, :time_zone_info, "0.7.12", "17d3e841e0421258c42c5cbb0b9a008935a7cf5db184ee8307510f133f0bed09", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "1b131992044bf1306bf6ce5f7246c29ed3769548c141249cc28991fa8ec13ed2"}, + "typst": {:hex, :typst, "0.3.1", "fc0a455feaece0e1e352a92082c22a6c8ade89f0e3661c5375f66a1818853270", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "545cae10fd2727316a40014832e4b1aac076f35e29e78c2206397f1a11cba790"}, "usage_rules": {:hex, :usage_rules, "1.2.4", "c718c429dd7de89bf57cd3e75fe218108fcdd342e34aa6ccac7123ae676c532d", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "2b9a913340b2a74e327b64eedc1cdd58d71b64c8fce09b8d18acb2db3884a548"}, "vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 1dd5601b..6295398e 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2200,3 +2200,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "validation failed" msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Error generating tracklist PDF" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7747c69f..41c2bbd7 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2200,3 +2200,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "validation failed" msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Error generating tracklist PDF" +msgstr "" diff --git a/test/music_library/records/tracklist_pdf_test.exs b/test/music_library/records/tracklist_pdf_test.exs new file mode 100644 index 00000000..eee75cd6 --- /dev/null +++ b/test/music_library/records/tracklist_pdf_test.exs @@ -0,0 +1,140 @@ +defmodule MusicLibrary.Records.TracklistPdfTest do + use ExUnit.Case, async: true + + alias MusicBrainz.Release + alias MusicLibrary.Records.{Record, TracklistPdf} + + @pdf_magic_bytes <<37, 80, 68, 70>> + + describe "generate/2" do + test "generates PDF for single-disc release" do + record = build_record(%{title: "OK Computer", artists: [%{name: "Radiohead"}]}) + + release = + build_release([ + build_medium(1, [ + build_track(1, "Airbag", 284_533), + build_track(2, "Paranoid Android", 383_000) + ]) + ]) + + assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release) + end + + test "generates PDF for multi-disc release" do + record = build_record(%{title: "Marbles", artists: [%{name: "Marillion"}]}) + api_response = MusicBrainz.Fixtures.Release.release_with_media(:marbles) + release = Release.from_api_response(api_response) + + assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release) + end + + test "renders artist joinphrase correctly" do + record = + build_record(%{ + title: "Collab Album", + artists: [ + %{name: "Artist A", joinphrase: " & "}, + %{name: "Artist B"} + ] + }) + + release = + build_release([ + build_medium(1, [build_track(1, "Track One", 200_000)]) + ]) + + assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release) + end + + test "handles track without duration" do + record = build_record(%{title: "Test Album", artists: [%{name: "Test Artist"}]}) + + release = + build_release([ + build_medium(1, [ + build_track(1, "Has Duration", 180_000), + build_track(2, "No Duration", nil) + ]) + ]) + + assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release) + end + + test "handles special characters in title" do + record = + build_record(%{ + title: "Album *with* #special @chars", + artists: [%{name: "Artist #1"}] + }) + + release = + build_release([ + build_medium(1, [ + build_track(1, "Track *starring* @someone", 200_000), + build_track(2, "#Hashtag Title", 180_000) + ]) + ]) + + assert {:ok, <<@pdf_magic_bytes, _::binary>>} = TracklistPdf.generate(record, release) + end + end + + defp build_record(attrs) do + artists = + Enum.map(Map.get(attrs, :artists, []), fn a -> + %{ + name: a[:name] || a.name, + sort_name: a[:sort_name] || a[:name] || a.name, + musicbrainz_id: "00000000-0000-0000-0000-000000000000", + disambiguation: "", + joinphrase: a[:joinphrase] || "" + } + end) + + %Record{ + id: Ecto.UUID.generate(), + title: attrs[:title] || "Test Album", + artists: artists, + genres: [], + type: :album, + format: :cd + } + end + + defp build_release(media) do + %Release{ + id: "00000000-0000-0000-0000-000000000000", + title: "Test Release", + disambiguation: nil, + packaging: nil, + artists: [], + date: "2024-01-01", + barcode: nil, + catalog_number: "", + country: nil, + media: media + } + end + + defp build_medium(number, tracks) do + %Release.Medium{ + title: nil, + format: "CD", + number: number, + track_count: length(tracks), + tracks: tracks + } + end + + defp build_track(position, title, length) do + %Release.Track{ + id: Ecto.UUID.generate(), + title: title, + artists: [], + length: length, + number: to_string(position), + position: position + } + end +end