Files
music_library/lib/music_library_web/live/stats_live/index.ex
T
Claudio Ortolina 268aff9918 Add top albums to the stats page
Authored with Claude.

> I'd like to have a new section in the Stats page. In this section I
want to see the top albums I listened to in the past 30,
  90, 365 days. Use the data in `scrobbled_tracks` to determine which
albums need to be displayed.

> This all looks great as a starter. I'd like to make some
modifications.

  1. Invert the position of the artist name and the album title (so that
it's consistent with the Scrobble Activity)
  2. Remove the border and shadow from around each album
  3. Rename the "Last 365 days" section to "Last Year"
2025-06-14 09:38:44 +03:00

232 lines
7.0 KiB
Elixir

defmodule MusicLibraryWeb.StatsLive.Index do
use MusicLibraryWeb, :live_view
import MusicLibraryWeb.ChartComponents
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
alias MusicLibrary.{Collection, Records, ScrobbleActivity, Wishlist}
attr :record, MusicLibrary.Records.Record, required: true
attr :title, :string, required: true
attr :class, :string
defp album_preview(assigns) do
~H"""
<div
class={[
"relative overflow-hidden rounded-md bg-white dark:bg-zinc-800 px-4 pb-5 pt-5 shadow-sm sm:px-6 sm:pt-6 cursor-pointer",
@class
]}
phx-click={JS.navigate(~p"/collection/#{@record}")}
>
<dt>
<img
class="absolute w-20 rounded-md shadow-sm"
src={~p"/covers/#{@record.id}?vsn=#{@record.cover_hash}"}
alt={@record.title}
/>
<p class="ml-24 truncate text-xs sm:text-sm font-medium text-zinc-500 dark:text-zinc-400">
{@title}
</p>
</dt>
<dd class="ml-24 flex items-baseline pb-4 sm:pb-6">
<p class="font-semibold">
<span class="text-sm md:text-base lg:text-2xl block text-zinc-900 dark:text-zinc-300">
{@record.title}
</span>
<.link
:for={artist <- @record.artists}
class="text-sm md:text-base text-zinc-600 dark:text-zinc-200 hover:text-zinc-500 dark:text-zinc-300 dark:hover:text-zinc-200"
navigate={~p"/artists/#{artist.musicbrainz_id}"}
>
{artist.name}
</.link>
</p>
</dd>
</div>
"""
end
attr :title, :string, required: true
attr :count, :integer, required: true
attr :path, :string, required: true
defp counter(assigns) do
~H"""
<div class="overflow-hidden rounded-md bg-white dark:bg-zinc-800 px-4 pb-3 pt-5 shadow-sm sm:px-6 sm:pt-6">
<dt class="sm:mt-3">
<p class="truncate text-sm font-medium text-center text-zinc-500 dark:text-zinc-400">
{@title}
</p>
</dt>
<dd class="mt-1">
<.link
navigate={@path}
class="block text-2xl sm:text-3xl font-semibold text-center text-zinc-900 hover:text-zinc-500 dark:text-zinc-300 dark:hover:text-zinc-200"
>
{@count}
</.link>
</dd>
</div>
"""
end
def mount(_params, _session, socket) do
latest_record = Collection.get_latest_record!()
recent_tracks = LastFm.get_scrobbled_tracks()
records_by_artists = Collection.count_records_by_artist(limit: 20)
records_by_genre = Collection.count_records_by_genre(limit: 20)
top_albums = ScrobbleActivity.get_top_albums_by_periods(10)
if connected?(socket) do
LastFm.subscribe_to_feed()
end
{:ok,
socket
|> assign(:timezone, resolve_timezone!())
|> stream_configure(:recent_tracks,
dom_id: fn track -> "track-#{track.scrobbled_at_uts}" end
)
|> stream_configure(:recent_albums,
dom_id: fn album -> "album-#{album.scrobbled_at_uts}" end
)
|> assign_counts()
|> assign_scrobble_activity(recent_tracks)
|> assign(
scrobble_activity_mode: :albums,
latest_record: latest_record,
page_title: gettext("Stats"),
current_section: :stats,
records_by_artist: records_by_artists,
records_by_genre: records_by_genre,
top_albums: top_albums
)}
end
def handle_event("refresh_lastfm_feed", _, socket) do
LastFm.refresh_scrobbled_tracks()
{:noreply, socket}
end
def handle_event("import", %{"id" => musicbrainz_id, "format" => format}, socket) do
case Records.import_from_musicbrainz_release(musicbrainz_id,
format: format,
purchased_at: nil
) do
{:ok, record} ->
{:noreply,
socket
|> put_flash(:info, gettext("Record wishlisted successfully"))
|> push_navigate(to: ~p"/wishlist/#{record.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> put_flash(
:error,
gettext("Error wishlisting record") <> "," <> inspect(changeset.errors)
)}
{:error, reason} ->
{:noreply,
socket
|> put_flash(:error, gettext("Error wishlisting record") <> "," <> inspect(reason))}
end
end
def handle_event("set_scrobble_activity_mode", %{"mode" => mode}, socket)
when mode in ["tracks", "albums"] do
recent_tracks = LastFm.get_scrobbled_tracks()
{:noreply,
socket
|> assign_scrobble_activity(recent_tracks)
|> assign(scrobble_activity_mode: String.to_existing_atom(mode))}
end
def handle_info(%{track_count: 0}, socket) do
{:noreply, socket}
end
def handle_info(%{track_count: _count}, socket) do
recent_tracks = LastFm.get_scrobbled_tracks()
{:noreply,
socket
|> assign_scrobble_activity(recent_tracks)}
end
defp assign_counts(socket) do
collection_count_by_format = Collection.count_records_by_format()
collection_count_by_type = Collection.count_records_by_type()
collection_count =
Enum.sum_by(collection_count_by_format, fn {_, count} -> count end)
wishlist_count = Wishlist.count()
assign(socket,
collection_count_by_format: collection_count_by_format,
collection_count_by_type: collection_count_by_type,
collection_count: collection_count,
wishlist_count: wishlist_count
)
end
defp assign_scrobble_activity(socket, recent_tracks) do
%{
localized_recent_tracks: localized_recent_tracks,
localized_recent_albums: recent_albums,
collected_releases: collected_releases,
wishlisted_releases: wishlisted_releases,
artist_ids: artist_ids
} = ScrobbleActivity.from_recent_tracks(recent_tracks, socket.assigns.timezone)
socket
|> stream(:recent_tracks, localized_recent_tracks, reset: true)
|> stream(:recent_albums, recent_albums, reset: true)
|> assign(
collected_releases: collected_releases,
wishlisted_releases: wishlisted_releases,
artist_ids: artist_ids
)
end
defp tracked_record?(tracked_releases, release_id) do
Enum.find_value(tracked_releases, fn tracked_release ->
if tracked_release.release_id == release_id, do: tracked_release.record_id
end)
end
# The Tailwind build step requires all needed classes to be explicitly referenced
# in the source code, and not dynamically generated. This implies that one cannot
# (for example) interpolate a number in a class name.
defp stats_class(collection) do
case Enum.count(collection) do
1 -> "grid-cols-1"
2 -> "grid-cols-2"
3 -> "grid-cols-3"
4 -> "grid-cols-4"
5 -> "grid-cols-5"
6 -> "grid-cols-6"
7 -> "grid-cols-7"
8 -> "grid-cols-8"
9 -> "grid-cols-9"
_other -> ""
end
end
defp format_scrobbled_at_uts(uts) do
uts
|> DateTime.from_unix!()
|> DateTime.to_iso8601()
end
defp resolve_timezone! do
Application.get_env(:music_library, MusicLibraryWeb)
|> Keyword.fetch!(:timezone)
end
end