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:
@@ -15,6 +15,7 @@ defmodule MusicLibrary.ListeningStats do
|
|||||||
alias MusicLibrary.{
|
alias MusicLibrary.{
|
||||||
Artists,
|
Artists,
|
||||||
BackgroundRepo,
|
BackgroundRepo,
|
||||||
|
ListeningStats.SearchParser,
|
||||||
Records.Record,
|
Records.Record,
|
||||||
Repo,
|
Repo,
|
||||||
Worker
|
Worker
|
||||||
@@ -569,14 +570,62 @@ defmodule MusicLibrary.ListeningStats do
|
|||||||
|
|
||||||
defp search_query(base_query, ""), do: base_query
|
defp search_query(base_query, ""), do: base_query
|
||||||
|
|
||||||
defp search_query(base_query, query) do
|
defp search_query(base_query, query_string) do
|
||||||
query_term = "%#{String.downcase(query)}%"
|
{: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,
|
from t in base_query,
|
||||||
where:
|
where:
|
||||||
like(fragment("lower(?)", t.title), ^query_term) or
|
fragment("json_extract(?, '$.musicbrainz_id')", t.album) in ^release_ids or
|
||||||
like(fragment("lower(json_extract(?, '$.name'))", t.artist), ^query_term) or
|
(fragment("json_extract(?, '$.title')", t.album) == ^record_title and
|
||||||
like(fragment("lower(json_extract(?, '$.title'))", t.album), ^query_term)
|
fragment("json_extract(?, '$.name')", t.artist) == ^main_artist_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp polyfill_artist(artist, musicbrainz_id) do
|
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}>
|
<span :if={@play_count == 0}>
|
||||||
{gettext("Never")}
|
{gettext("Never")}
|
||||||
</span>
|
</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)}
|
{ngettext("(1 scrobble)", "(%{count} scrobbles)", @play_count)}
|
||||||
</span>
|
</.link>
|
||||||
</.dl_row>
|
</.dl_row>
|
||||||
<.record_published_releases record={@record} />
|
<.record_published_releases record={@record} />
|
||||||
<.dl_row label={gettext("Collected release")}>
|
<.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
|
||||||
Reference in New Issue
Block a user