From c816f2de4ad67d45e34d4f3833ddc7012a8e4537 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Wed, 15 Apr 2026 14:56:38 +0100 Subject: [PATCH] Add scrobbled tracks search parser Introduces ListeningStats.SearchParser with NimbleParsec supporting record:, album_mbid:, artist_mbid:, artist:, album:, track: filters. Links scrobble count on record page to filtered scrobbled tracks view. --- lib/music_library/listening_stats.ex | 59 ++++++- .../listening_stats/search_parser.ex | 145 ++++++++++++++++++ .../live/collection_live/show.ex | 8 +- .../listening_stats/search_parser_test.exs | 5 + 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 lib/music_library/listening_stats/search_parser.ex create mode 100644 test/music_library/listening_stats/search_parser_test.exs diff --git a/lib/music_library/listening_stats.ex b/lib/music_library/listening_stats.ex index a8834c66..d62d09fe 100644 --- a/lib/music_library/listening_stats.ex +++ b/lib/music_library/listening_stats.ex @@ -15,6 +15,7 @@ defmodule MusicLibrary.ListeningStats do alias MusicLibrary.{ Artists, BackgroundRepo, + ListeningStats.SearchParser, Records.Record, Repo, Worker @@ -569,14 +570,62 @@ defmodule MusicLibrary.ListeningStats do defp search_query(base_query, ""), do: base_query - defp search_query(base_query, query) do - query_term = "%#{String.downcase(query)}%" + defp search_query(base_query, query_string) do + {:ok, parsed} = SearchParser.parse(query_string) + + Enum.reduce(parsed, base_query, fn + {:record, record_id}, q -> + apply_record_filter(q, record_id) + + {:album_mbid, mbid}, q -> + from t in q, + where: fragment("json_extract(?, '$.musicbrainz_id')", t.album) == ^mbid + + {:artist_mbid, mbid}, q -> + from t in q, + where: fragment("json_extract(?, '$.musicbrainz_id')", t.artist) == ^mbid + + {:artist, artist}, q -> + term = "%#{String.downcase(artist)}%" + + from t in q, + where: like(fragment("lower(json_extract(?, '$.name'))", t.artist), ^term) + + {:album, album}, q -> + term = "%#{String.downcase(album)}%" + + from t in q, + where: like(fragment("lower(json_extract(?, '$.title'))", t.album), ^term) + + {:track, track}, q -> + term = "%#{String.downcase(track)}%" + from t in q, where: like(fragment("lower(?)", t.title), ^term) + + {:query, ""}, q -> + q + + {:query, text}, q -> + term = "%#{String.downcase(text)}%" + + from t in q, + where: + like(fragment("lower(?)", t.title), ^term) or + like(fragment("lower(json_extract(?, '$.name'))", t.artist), ^term) or + like(fragment("lower(json_extract(?, '$.title'))", t.album), ^term) + end) + end + + defp apply_record_filter(base_query, record_id) do + record = Repo.get!(Record, record_id) + release_ids = record.release_ids + main_artist_name = Record.main_artist(record).name + record_title = record.title from t in base_query, where: - like(fragment("lower(?)", t.title), ^query_term) or - like(fragment("lower(json_extract(?, '$.name'))", t.artist), ^query_term) or - like(fragment("lower(json_extract(?, '$.title'))", t.album), ^query_term) + fragment("json_extract(?, '$.musicbrainz_id')", t.album) in ^release_ids or + (fragment("json_extract(?, '$.title')", t.album) == ^record_title and + fragment("json_extract(?, '$.name')", t.artist) == ^main_artist_name) end defp polyfill_artist(artist, musicbrainz_id) do diff --git a/lib/music_library/listening_stats/search_parser.ex b/lib/music_library/listening_stats/search_parser.ex new file mode 100644 index 00000000..94963594 --- /dev/null +++ b/lib/music_library/listening_stats/search_parser.ex @@ -0,0 +1,145 @@ +defmodule MusicLibrary.ListeningStats.SearchParser do + @moduledoc """ + Parses structured search queries for scrobbled tracks into filter maps. + + Supports the following filters: + + - `record:value` — filter by record ID (matches all release IDs + artist/title fallback) + - `album_mbid:value` — filter by album MusicBrainz ID (exact match) + - `artist_mbid:value` — filter by artist MusicBrainz ID (exact match) + - `artist:value` — filter by artist name + - `album:value` — filter by album title + - `track:value` — filter by track title + + Multi-word values can be quoted: `artist:"the pineapple thief"`. + Bare words become free-text query terms searched across all fields. + """ + + @type search_result :: %{ + optional(:query) => String.t(), + optional(:record) => String.t(), + optional(:album_mbid) => String.t(), + optional(:artist_mbid) => String.t(), + optional(:artist) => String.t(), + optional(:album) => String.t(), + optional(:track) => String.t() + } + + import NimbleParsec + + word = utf8_string([not: ?\s, not: ?,], min: 1) + space = ignore(string(" ")) + comma = ignore(string(",")) + + quoted_words = + ignore(string(~s("))) + |> utf8_string([not: ?\"], min: 1) + |> ignore(string(~s("))) + + query = choice([quoted_words, word]) |> tag(:query) + + record_filter = ignore(string("record:")) + record = concat(record_filter, query) |> tag(:record) + + album_mbid_filter = ignore(string("album_mbid:")) + album_mbid = concat(album_mbid_filter, query) |> tag(:album_mbid) + + artist_mbid_filter = ignore(string("artist_mbid:")) + artist_mbid = concat(artist_mbid_filter, query) |> tag(:artist_mbid) + + artist_filter = ignore(string("artist:")) + artist = concat(artist_filter, query) |> tag(:artist) + + album_filter = ignore(string("album:")) + album = concat(album_filter, query) |> tag(:album) + + track_filter = ignore(string("track:")) + track = concat(track_filter, query) |> tag(:track) + + search = + repeat( + choice([ + record, + album_mbid, + artist_mbid, + artist, + album, + track, + space, + comma, + query + ]) + ) + + defparsecp(:search_parser, search) + + @doc """ + Parse a search query. + + iex> MusicLibrary.ListeningStats.SearchParser.parse("") + {:ok, %{query: ""}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("hello, bye") + {:ok, %{query: "hello bye"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("marbles") + {:ok, %{query: "marbles"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("marillion marbles") + {:ok, %{query: "marillion marbles"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("artist:marillion album:marbles") + {:ok, %{artist: "marillion", album: "marbles"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("artist:marillion album:fugazi artist:fish") + {:ok, %{artist: "marillion fish", album: "fugazi"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse(~s(artist:"the pineapple thief" wilderness)) + {:ok, %{artist: "the pineapple thief", query: "wilderness"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("track:neverland") + {:ok, %{track: "neverland"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse(~s(track:"the start of something beautiful")) + {:ok, %{track: "the start of something beautiful"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("album_mbid:abc-123") + {:ok, %{album_mbid: "abc-123"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("artist_mbid:def-456") + {:ok, %{artist_mbid: "def-456"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("record:abc-123-def") + {:ok, %{record: "abc-123-def"}} + iex> MusicLibrary.ListeningStats.SearchParser.parse("album_mbid:abc-123 artist:marillion") + {:ok, %{album_mbid: "abc-123", artist: "marillion"}} + """ + @spec parse(String.t()) :: {:ok, search_result()} + def parse(""), do: {:ok, %{query: ""}} + + def parse(query) do + {:ok, result, _rest, _context, _line, _byte_offset} = search_parser(query) + + {:ok, normalize(result)} + end + + defp normalize(result) do + Enum.reduce(result, %{}, fn + {:record, [{:query, [value]}]}, acc -> + Map.put(acc, :record, value) + + {:album_mbid, [{:query, [value]}]}, acc -> + Map.put(acc, :album_mbid, value) + + {:artist_mbid, [{:query, [value]}]}, acc -> + Map.put(acc, :artist_mbid, value) + + {:artist, [{:query, [value]}]}, acc -> + Map.update(acc, :artist, value, &(&1 <> " " <> value)) + + {:album, [{:query, [value]}]}, acc -> + Map.update(acc, :album, value, &(&1 <> " " <> value)) + + {:track, [{:query, [value]}]}, acc -> + Map.update(acc, :track, value, &(&1 <> " " <> value)) + + {:query, [value]}, acc -> + Map.update(acc, :query, value, &(&1 <> " " <> value)) + + _, acc when map_size(acc) == 0 -> + %{query: ""} + + _, acc -> + acc + end) + end +end diff --git a/lib/music_library_web/live/collection_live/show.ex b/lib/music_library_web/live/collection_live/show.ex index 2f27c524..666fa771 100644 --- a/lib/music_library_web/live/collection_live/show.ex +++ b/lib/music_library_web/live/collection_live/show.ex @@ -245,9 +245,13 @@ defmodule MusicLibraryWeb.CollectionLive.Show do {gettext("Never")} - 0}> + <.link + :if={@play_count > 0} + navigate={~p"/scrobbled-tracks?#{%{query: "record:#{@record.id}"}}"} + class="text-zinc-900 underline dark:text-zinc-100" + > {ngettext("(1 scrobble)", "(%{count} scrobbles)", @play_count)} - + <.record_published_releases record={@record} /> <.dl_row label={gettext("Collected release")}> diff --git a/test/music_library/listening_stats/search_parser_test.exs b/test/music_library/listening_stats/search_parser_test.exs new file mode 100644 index 00000000..bfc19ef9 --- /dev/null +++ b/test/music_library/listening_stats/search_parser_test.exs @@ -0,0 +1,5 @@ +defmodule MusicLibrary.ListeningStats.SearchParserTest do + use ExUnit.Case, async: true + + doctest MusicLibrary.ListeningStats.SearchParser +end