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