d8c84e787b
Three independent refactors of lib/music_library/listening_stats.ex, measured against the dev DB (104k tracks) via bench/listening_stats.exs: | Query | Baseline | After | Speedup | |---------------------------------|-----------|-----------|---------| | recent_activity(tz, 100) | 38.98 ms | 2.87 ms | 13.6x | | list_tracks(page 1, 200) | 47.30 ms | 4.81 ms | 9.8x | | get_top_artists_by_days(7) | 66.51 ms | 1.02 ms | 65x | | get_top_artists_by_days(30) | 67.41 ms | 2.84 ms | 23.7x | | get_top_artists_by_days(365) | 76.03 ms | 25.57 ms | 3.0x | | get_top_albums_by_days(7) | 92.63 ms | 1.61 ms | 57.5x | | get_top_albums_by_days(30) | 94.52 ms | 3.69 ms | 25.6x | | get_top_albums_by_days(365) | 105.23 ms | 32.81 ms | 3.2x | | get_top_artists(limit: 10) | 123.96 ms | 101.68 ms | 1.22x | | get_top_albums(limit: 10) | 209.64 ms | 108.23 ms | 1.94x | tracks_with_record_info_query/0 now uses correlated scalar subqueries against record_releases and artist_records instead of materializing helper subqueries on every call. The cost scales with the outer LIMIT, not with the size of record_releases. top_albums_base_query/0 and top_artists_base_query/0 are replaced by aggregate_query + attach_metadata pairs. The pattern is aggregate first, attach metadata second: GROUP BY runs against the raw track scan, then a tiny outer SELECT attaches record_releases / artist_infos lookups for the <= 10 result rows via correlated subqueries. tracks_since_query/1 wraps the date-filtered inner scan with limit: -1. SQLite cannot flatten a subquery that has a LIMIT, so it materializes the date-bounded subset and the optimizer uses the timestamp index for the range scan instead of the album/artist composite index. This trick replaces SQLite's WITH ... AS MATERIALIZED (which ecto_sqlite3 doesn't expose). All json_extract(?, '\$.path') fragments use the canonical form rather than the equivalent ? ->> '\$.path' shorthand. SQLite's index matcher requires the GROUP BY expression to match the index expression textually to use the composite index for natural ordering. collected_releases_query/0 and wishlisted_releases_query/0 are removed from Collection and Wishlist — they had a single internal caller that no longer exists after the refactor. New regression tests lock the semantics that the optimized queries must preserve: - count(DISTINCT scrobbled_at_uts) — 579 duplicate timestamps exist in the dev DB from rapid Last.fm scrobbles, so replacing with count(*) would silently change results - :artist_id key in list_tracks result maps — ScrobbledTracksLive.Index destructures it even though the template body never references it Closes #148
199 lines
5.8 KiB
Elixir
199 lines
5.8 KiB
Elixir
defmodule MusicLibrary.Collection do
|
|
@moduledoc """
|
|
Queries for collected records (where `purchased_at` is set).
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
import MusicLibrary.Records, only: [order_alphabetically: 0]
|
|
|
|
alias MusicLibrary.Records
|
|
alias MusicLibrary.Records.{ArtistRecord, Record, SearchIndex}
|
|
alias MusicLibrary.Repo
|
|
|
|
@excluded_genres Application.compile_env!(:music_library, :excluded_genres)
|
|
@pagination Application.compile_env!(:music_library, :pagination)
|
|
|
|
@spec search_records(String.t(), MusicLibrary.Types.pagination_opts()) :: [SearchIndex.t()]
|
|
def search_records(query, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, @pagination[:default_page_size])
|
|
offset = Keyword.get(opts, :offset, 0)
|
|
order = Keyword.get(opts, :order, :alphabetical)
|
|
|
|
Records.search_records(base_search(), query, limit: limit, offset: offset, order: order)
|
|
end
|
|
|
|
@spec search_records_count(String.t()) :: non_neg_integer()
|
|
def search_records_count(query) do
|
|
Records.search_records_count(base_search(), query)
|
|
end
|
|
|
|
@spec count_records_by_format() :: [{String.t(), non_neg_integer()}]
|
|
def count_records_by_format do
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
group_by: r.format,
|
|
order_by: [desc: count(r.id)],
|
|
select: {r.format, count(r.id)}
|
|
|
|
Repo.all(q)
|
|
end
|
|
|
|
@spec count_records_by_type() :: [{String.t(), non_neg_integer()}]
|
|
def count_records_by_type do
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
group_by: r.type,
|
|
order_by: [desc: count(r.id)],
|
|
select: {r.type, count(r.id)}
|
|
|
|
Repo.all(q)
|
|
end
|
|
|
|
@spec get_records_on_this_day(Date.t()) :: [SearchIndex.t()]
|
|
def get_records_on_this_day(date \\ Date.utc_today()) do
|
|
month_day = Calendar.strftime(date, "%m-%d")
|
|
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
where: fragment("strftime('%m-%d', ?) = ?", r.release_date, ^month_day),
|
|
order_by: [{:desc, r.release_date}, order_alphabetically()],
|
|
select: ^Records.essential_fields()
|
|
|
|
Repo.all(q)
|
|
end
|
|
|
|
@type grouped_record ::
|
|
{:single, SearchIndex.t()}
|
|
| {:group, %{representative: SearchIndex.t(), records: [SearchIndex.t()]}}
|
|
|
|
@spec group_records_by_release_group([SearchIndex.t()]) :: [grouped_record()]
|
|
def group_records_by_release_group(records) do
|
|
records
|
|
|> Enum.group_by(& &1.musicbrainz_id)
|
|
|> Enum.map(fn
|
|
{_mbid, [single]} ->
|
|
{:single, single}
|
|
|
|
{_mbid, [first | _] = group} ->
|
|
sorted = Enum.sort_by(group, & &1.purchased_at, DateTime)
|
|
{:group, %{representative: first, records: sorted}}
|
|
end)
|
|
|> Enum.sort_by(
|
|
fn
|
|
{:single, r} -> r.release_date
|
|
{:group, %{representative: r}} -> r.release_date
|
|
end,
|
|
:desc
|
|
)
|
|
end
|
|
|
|
@spec get_latest_record() :: SearchIndex.t() | nil
|
|
def get_latest_record do
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
|
limit: 1,
|
|
select: ^Records.essential_fields()
|
|
|
|
Repo.one(q)
|
|
end
|
|
|
|
@spec get_latest_record!() :: SearchIndex.t()
|
|
def get_latest_record! do
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
order_by: [{:desc, r.purchased_at}, order_alphabetically()],
|
|
limit: 1,
|
|
select: ^Records.essential_fields()
|
|
|
|
Repo.one!(q)
|
|
end
|
|
|
|
@spec get_random_record!() :: SearchIndex.t()
|
|
def get_random_record! do
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
order_by: fragment("RANDOM()"),
|
|
limit: 1,
|
|
select: ^Records.essential_fields()
|
|
|
|
Repo.one!(q)
|
|
end
|
|
|
|
@spec count_records_by_artist(keyword()) :: [map()]
|
|
def count_records_by_artist(opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, @pagination[:stats_limit])
|
|
|
|
q =
|
|
from r in fragment("records, json_each(records.artists)"),
|
|
where: fragment("records.purchased_at IS NOT NULL"),
|
|
group_by: fragment("? ->> '$.name'", r.value),
|
|
order_by: [desc: fragment("count(1)")],
|
|
select: %{
|
|
id: fragment("? ->> '$.musicbrainz_id'", r.value),
|
|
name: fragment("? ->> '$.name'", r.value),
|
|
count: fragment("count(1)")
|
|
},
|
|
limit: ^limit
|
|
|
|
Repo.all(q)
|
|
end
|
|
|
|
@spec count_records_by_genre(keyword()) :: [{String.t(), non_neg_integer()}]
|
|
def count_records_by_genre(opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, @pagination[:stats_limit])
|
|
|
|
q =
|
|
from r in fragment("records, json_each(records.genres)"),
|
|
where: fragment("records.purchased_at IS NOT NULL"),
|
|
where: r.value not in @excluded_genres,
|
|
group_by: r.value,
|
|
order_by: [desc: fragment("count(1)")],
|
|
select: {r.value, fragment("count(1)")},
|
|
limit: ^limit
|
|
|
|
Repo.all(q)
|
|
end
|
|
|
|
@spec count_records_by_release_year(keyword()) :: [{String.t(), non_neg_integer()}]
|
|
def count_records_by_release_year(opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, @pagination[:stats_limit])
|
|
|
|
q =
|
|
from r in Record,
|
|
where: not is_nil(r.purchased_at),
|
|
where: not is_nil(r.release_date),
|
|
where: r.release_date != "",
|
|
group_by: fragment("substr(?, 1, 4)", r.release_date),
|
|
order_by: [desc: fragment("count(1)")],
|
|
select: {fragment("substr(?, 1, 4)", r.release_date), fragment("count(1)")},
|
|
limit: ^limit
|
|
|
|
Repo.all(q)
|
|
end
|
|
|
|
@spec collected_artist_ids() :: MapSet.t(String.t())
|
|
def collected_artist_ids do
|
|
from(ar in ArtistRecord,
|
|
join: r in Record,
|
|
on: r.id == ar.record_id,
|
|
where: not is_nil(r.purchased_at),
|
|
distinct: true,
|
|
select: ar.musicbrainz_id
|
|
)
|
|
|> Repo.all()
|
|
|> MapSet.new()
|
|
end
|
|
|
|
defp base_search do
|
|
from r in SearchIndex,
|
|
where: not is_nil(r.purchased_at)
|
|
end
|
|
end
|