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.
This commit is contained in:
Claudio Ortolina
2026-04-15 14:56:38 +01:00
parent 851cb36858
commit c816f2de4a
4 changed files with 210 additions and 7 deletions
+54 -5
View File
@@ -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
@@ -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
@@ -245,9 +245,13 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
<span :if={@play_count == 0}>
{gettext("Never")}
</span>
<span :if={@play_count > 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)}
</span>
</.link>
</.dl_row>
<.record_published_releases record={@record} />
<.dl_row label={gettext("Collected release")}>
@@ -0,0 +1,5 @@
defmodule MusicLibrary.ListeningStats.SearchParserTest do
use ExUnit.Case, async: true
doctest MusicLibrary.ListeningStats.SearchParser
end