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