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.{
|
||||
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
|
||||
Reference in New Issue
Block a user