Add function to download a record's tracklist for printing

This commit is contained in:
Claudio Ortolina
2026-03-09 13:39:12 +00:00
parent 1bc01a2346
commit 4569c6727e
9 changed files with 332 additions and 17 deletions
+13
View File
@@ -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();
+2
View File
@@ -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
+102
View File
@@ -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
@@ -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,6 +70,16 @@ defmodule MusicLibraryWeb.Components.Release do
/>
{gettext("Tracks")}
</label>
<div class="flex items-center gap-2">
<.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>
<.button
:if={@can_scrobble? && @release_with_tracks.ok?}
size="sm"
@@ -87,6 +98,7 @@ defmodule MusicLibraryWeb.Components.Release do
{gettext("Connect your Last.fm account")}
</.button>
</div>
</div>
<div :if={@release_with_tracks} class="space-y-4 mt-4">
<.async_result :let={release_with_tracks} assign={@release_with_tracks}>
@@ -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
+3
View File
@@ -106,6 +106,9 @@ defmodule MusicLibrary.MixProject do
# Notes
{:mdex, "~> 0.11.6"},
# PDF generation
{:typst, "~> 0.3.1"},
# Syntax highlighting
{:lumis, "~> 0.1"},
+1
View File
@@ -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"},
+5
View File
@@ -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 ""
+5
View File
@@ -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 ""
@@ -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