Extract ListeningStats out of ScrobbleActivity
This commit is contained in:
+2
-1
@@ -96,7 +96,8 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
|
|||||||
| `Notes` | Note | Free-text notes for records and artists |
|
| `Notes` | Note | Free-text notes for records and artists |
|
||||||
| `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering |
|
| `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering |
|
||||||
| `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs |
|
| `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs |
|
||||||
| `ScrobbleActivity` | (LastFm.Track, ArtistRecord) | Scrobbling releases, listening stats, top albums/artists |
|
| `ScrobbleActivity` | (LastFm.Track, ArtistRecord) | Scrobbling releases, track CRUD, data quality diagnostics |
|
||||||
|
| `ListeningStats` | (LastFm.Track, ArtistRecord, ArtistInfo) | Read-only listening analytics: scrobble counts, recent activity, top albums/artists by period |
|
||||||
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online |
|
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online |
|
||||||
| `Search` | (cross-context) | Universal search across collection, wishlist, artists, record sets |
|
| `Search` | (cross-context) | Universal search across collection, wishlist, artists, record sets |
|
||||||
| `Secrets` | Secret | Encrypted key-value storage |
|
| `Secrets` | Secret | Encrypted key-value storage |
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
defmodule MusicLibrary.ListeningStats do
|
||||||
|
@moduledoc """
|
||||||
|
Read-only listening analytics derived from Last.fm scrobble data.
|
||||||
|
|
||||||
|
Provides scrobble counts, recent activity feeds, and top albums/artists
|
||||||
|
by time period. All queries are read-only joins across LastFm.Track,
|
||||||
|
Collection, Wishlist, and ArtistInfo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias LastFm.Track
|
||||||
|
alias MusicLibrary.{Artists, Collection, Records.ArtistRecord, Repo, Wishlist}
|
||||||
|
|
||||||
|
@pagination Application.compile_env!(:music_library, :pagination)
|
||||||
|
|
||||||
|
def scrobble_count do
|
||||||
|
Repo.aggregate(Track, :count, :scrobbled_at_uts)
|
||||||
|
end
|
||||||
|
|
||||||
|
def recent_activity(timezone, limit \\ 100) do
|
||||||
|
# When we get recent tracks, we need to:
|
||||||
|
#
|
||||||
|
# - Map each track to a record in the collection (if it exists)
|
||||||
|
# - Map each track to a record in the wishlist (if it exists)
|
||||||
|
# - Map each track to an artist, knowing that sometimes track artists do
|
||||||
|
# not have the necessary information. In that case we can go from
|
||||||
|
# track -> album -> record -> artist
|
||||||
|
|
||||||
|
all_artists_query =
|
||||||
|
from ar in ArtistRecord,
|
||||||
|
distinct: true
|
||||||
|
|
||||||
|
tracks_query =
|
||||||
|
from t in Track,
|
||||||
|
left_join: cr in subquery(Collection.collected_releases_query()),
|
||||||
|
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
||||||
|
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
||||||
|
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
||||||
|
left_join: ar in subquery(all_artists_query),
|
||||||
|
on: wr.record_id == ar.record_id or cr.record_id == ar.record_id,
|
||||||
|
order_by: [desc: t.scrobbled_at_uts],
|
||||||
|
limit: ^limit,
|
||||||
|
select: %{
|
||||||
|
track: t,
|
||||||
|
collected_record_id: cr.record_id,
|
||||||
|
wishlisted_record_id: wr.record_id,
|
||||||
|
artist_id: ar.musicbrainz_id,
|
||||||
|
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
recent_tracks = Repo.all(tracks_query)
|
||||||
|
|
||||||
|
recent_tracks =
|
||||||
|
recent_tracks
|
||||||
|
|> Enum.map(fn %{track: track, artist_id: artist_id} = rt ->
|
||||||
|
%{rt | track: polyfill_track(track, timezone, artist_id)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
recent_albums =
|
||||||
|
recent_tracks
|
||||||
|
|> Enum.dedup_by(fn %{track: track} -> track.album end)
|
||||||
|
|> Enum.map(fn %{track: track} = tr ->
|
||||||
|
tr
|
||||||
|
|> Map.delete(:track)
|
||||||
|
|> Map.put(
|
||||||
|
:album,
|
||||||
|
%{
|
||||||
|
scrobbled_at_uts: track.scrobbled_at_uts,
|
||||||
|
scrobbled_at_label: track.scrobbled_at_label,
|
||||||
|
metadata: track.album,
|
||||||
|
artist: track.artist,
|
||||||
|
cover_url: track.cover_url
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
recent_tracks: recent_tracks,
|
||||||
|
recent_albums: recent_albums
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def localize_scrobbled_at(uts, timezone) do
|
||||||
|
ldt =
|
||||||
|
uts
|
||||||
|
|> DateTime.from_unix!()
|
||||||
|
|> DateTime.shift_zone!(timezone)
|
||||||
|
|
||||||
|
Calendar.strftime(ldt, "%d/%m/%Y %X")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets top albums for the specified time periods (7, 30, 90, 365 days) and all
|
||||||
|
time. Returns a list of maps with album information and play counts.
|
||||||
|
"""
|
||||||
|
def get_top_albums_by_period(opts) do
|
||||||
|
case Keyword.get(opts, :period, :last_7_days) do
|
||||||
|
:all_time -> get_top_albums(opts)
|
||||||
|
:last_7_days -> get_top_albums_by_days(7, opts)
|
||||||
|
:last_30_days -> get_top_albums_by_days(30, opts)
|
||||||
|
:last_90_days -> get_top_albums_by_days(90, opts)
|
||||||
|
:last_365_days -> get_top_albums_by_days(365, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets top artists for a time period (7, 30, 90, 365 days) and all time.
|
||||||
|
"""
|
||||||
|
def get_top_artists_by_period(opts) do
|
||||||
|
case Keyword.get(opts, :period, :last_7_days) do
|
||||||
|
:all_time -> get_top_artists(opts)
|
||||||
|
:last_7_days -> get_top_artists_by_days(7, opts)
|
||||||
|
:last_30_days -> get_top_artists_by_days(30, opts)
|
||||||
|
:last_90_days -> get_top_artists_by_days(90, opts)
|
||||||
|
:last_365_days -> get_top_artists_by_days(365, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the top albums by scrobble count across all time.
|
||||||
|
Returns a list of maps with album information and play counts.
|
||||||
|
"""
|
||||||
|
def get_top_albums(opts) do
|
||||||
|
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
||||||
|
|
||||||
|
top_albums_base_query()
|
||||||
|
|> limit(^limit)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the top albums by scrobble count for the given number of days.
|
||||||
|
Returns a list of maps with album information and play counts.
|
||||||
|
"""
|
||||||
|
def get_top_albums_by_days(days, opts) do
|
||||||
|
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
||||||
|
cutoff_timestamp = cutoff_timestamp(days, opts)
|
||||||
|
|
||||||
|
top_albums_base_query()
|
||||||
|
|> where([t], t.scrobbled_at_uts >= ^cutoff_timestamp)
|
||||||
|
|> limit(^limit)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the top artists by scrobble count across all time.
|
||||||
|
Returns a list of maps with artist information and play counts.
|
||||||
|
"""
|
||||||
|
def get_top_artists(opts) do
|
||||||
|
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
||||||
|
|
||||||
|
top_artists_base_query()
|
||||||
|
|> limit(^limit)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the top artists by scrobble count for the given number of days.
|
||||||
|
Returns a list of maps with artist information and play counts.
|
||||||
|
"""
|
||||||
|
def get_top_artists_by_days(days, opts) do
|
||||||
|
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
||||||
|
cutoff_timestamp = cutoff_timestamp(days, opts)
|
||||||
|
|
||||||
|
top_artists_base_query()
|
||||||
|
|> where([t], t.scrobbled_at_uts >= ^cutoff_timestamp)
|
||||||
|
|> limit(^limit)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared base queries
|
||||||
|
|
||||||
|
defp top_albums_base_query do
|
||||||
|
from t in Track,
|
||||||
|
left_join: cr in subquery(Collection.collected_releases_query()),
|
||||||
|
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
||||||
|
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
||||||
|
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
||||||
|
where: fragment("json_extract(album, '$.title') != ''"),
|
||||||
|
group_by: [
|
||||||
|
fragment("json_extract(album, '$.title')"),
|
||||||
|
fragment("json_extract(artist, '$.name')")
|
||||||
|
],
|
||||||
|
select: %{
|
||||||
|
album_title: fragment("json_extract(album, '$.title')"),
|
||||||
|
artist_name: fragment("json_extract(artist, '$.name')"),
|
||||||
|
artist_musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
|
||||||
|
play_count: count(t.scrobbled_at_uts, :distinct),
|
||||||
|
cover_url: fragment("max(?)", t.cover_url),
|
||||||
|
album_musicbrainz_id: fragment("json_extract(album, '$.musicbrainz_id')"),
|
||||||
|
collected_record_id: cr.record_id,
|
||||||
|
wishlisted_record_id: wr.record_id,
|
||||||
|
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
||||||
|
},
|
||||||
|
order_by: [desc: count(t.scrobbled_at_uts, :distinct)]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp top_artists_base_query do
|
||||||
|
from t in Track,
|
||||||
|
left_join: ai in Artists.ArtistInfo,
|
||||||
|
on: ai.id == fragment("json_extract(?, '$.musicbrainz_id')", t.artist),
|
||||||
|
group_by: [
|
||||||
|
fragment("json_extract(artist, '$.name')")
|
||||||
|
],
|
||||||
|
select: %{
|
||||||
|
name: fragment("json_extract(artist, '$.name')"),
|
||||||
|
musicbrainz_id: max(fragment("json_extract(artist, '$.musicbrainz_id')")),
|
||||||
|
image_hash: max(ai.image_data_hash),
|
||||||
|
play_count: count(t.scrobbled_at_uts, :distinct)
|
||||||
|
},
|
||||||
|
order_by: [desc: count(t.scrobbled_at_uts, :distinct)]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cutoff_timestamp(days, opts) do
|
||||||
|
current_time = Keyword.get_lazy(opts, :current_time, &DateTime.utc_now/0)
|
||||||
|
timezone = Keyword.get(opts, :timezone, &MusicLibrary.default_timezone/0)
|
||||||
|
|
||||||
|
current_time
|
||||||
|
|> DateTime.add(-days, :day)
|
||||||
|
|> NaiveDateTime.beginning_of_day()
|
||||||
|
|> DateTime.from_naive!(timezone)
|
||||||
|
|> DateTime.to_unix()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp polyfill_track(track, timezone, artist_id) do
|
||||||
|
%{
|
||||||
|
track
|
||||||
|
| scrobbled_at_label: localize_scrobbled_at(track.scrobbled_at_uts, timezone),
|
||||||
|
artist: polyfill_artist(track.artist, artist_id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp polyfill_artist(artist, musicbrainz_id) do
|
||||||
|
if is_nil(artist.musicbrainz_id) or artist.musicbrainz_id == "" do
|
||||||
|
%{artist | musicbrainz_id: musicbrainz_id}
|
||||||
|
else
|
||||||
|
artist
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,7 +3,7 @@ defmodule MusicLibrary.ScrobbleActivity do
|
|||||||
|
|
||||||
alias LastFm.{Scrobble, Track}
|
alias LastFm.{Scrobble, Track}
|
||||||
alias MusicBrainz.Release
|
alias MusicBrainz.Release
|
||||||
alias MusicLibrary.{Artists, Collection, Records.ArtistRecord, Repo, Secrets, Wishlist}
|
alias MusicLibrary.{Collection, Records.ArtistRecord, Repo, Secrets, Wishlist}
|
||||||
|
|
||||||
@pagination Application.compile_env!(:music_library, :pagination)
|
@pagination Application.compile_env!(:music_library, :pagination)
|
||||||
|
|
||||||
@@ -179,270 +179,6 @@ defmodule MusicLibrary.ScrobbleActivity do
|
|||||||
defp main_artist_name([]), do: nil
|
defp main_artist_name([]), do: nil
|
||||||
defp main_artist_name([artist | _rest]), do: artist.name
|
defp main_artist_name([artist | _rest]), do: artist.name
|
||||||
|
|
||||||
def scrobble_count do
|
|
||||||
Repo.aggregate(Track, :count, :scrobbled_at_uts)
|
|
||||||
end
|
|
||||||
|
|
||||||
def recent_activity(timezone, limit \\ 100) do
|
|
||||||
# When we get recent tracks, we need to:
|
|
||||||
#
|
|
||||||
# - Map each track to a record in the collection (if it exists)
|
|
||||||
# - Map each track to a record in the wishlist (if it exists)
|
|
||||||
# - Map each track to an artist, knowing that sometimes track artists do
|
|
||||||
# not have the necessary information. In that case we can go from
|
|
||||||
# track -> album -> record -> artist
|
|
||||||
|
|
||||||
all_artists_query =
|
|
||||||
from ar in ArtistRecord,
|
|
||||||
distinct: true
|
|
||||||
|
|
||||||
tracks_query =
|
|
||||||
from t in Track,
|
|
||||||
left_join: cr in subquery(Collection.collected_releases_query()),
|
|
||||||
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
|
||||||
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: ar in subquery(all_artists_query),
|
|
||||||
on: wr.record_id == ar.record_id or cr.record_id == ar.record_id,
|
|
||||||
order_by: [desc: t.scrobbled_at_uts],
|
|
||||||
limit: ^limit,
|
|
||||||
select: %{
|
|
||||||
track: t,
|
|
||||||
collected_record_id: cr.record_id,
|
|
||||||
wishlisted_record_id: wr.record_id,
|
|
||||||
artist_id: ar.musicbrainz_id,
|
|
||||||
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
recent_tracks = Repo.all(tracks_query)
|
|
||||||
|
|
||||||
recent_tracks =
|
|
||||||
recent_tracks
|
|
||||||
|> Enum.map(fn %{track: track, artist_id: artist_id} = rt ->
|
|
||||||
%{rt | track: polifyll_track(track, timezone, artist_id)}
|
|
||||||
end)
|
|
||||||
|
|
||||||
recent_albums =
|
|
||||||
recent_tracks
|
|
||||||
|> Enum.dedup_by(fn %{track: track} -> track.album end)
|
|
||||||
|> Enum.map(fn %{track: track} = tr ->
|
|
||||||
tr
|
|
||||||
|> Map.delete(:track)
|
|
||||||
|> Map.put(
|
|
||||||
:album,
|
|
||||||
%{
|
|
||||||
scrobbled_at_uts: track.scrobbled_at_uts,
|
|
||||||
scrobbled_at_label: track.scrobbled_at_label,
|
|
||||||
metadata: track.album,
|
|
||||||
artist: track.artist,
|
|
||||||
cover_url: track.cover_url
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end)
|
|
||||||
|
|
||||||
%{
|
|
||||||
recent_tracks: recent_tracks,
|
|
||||||
recent_albums: recent_albums
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp polifyll_track(track, timezone, artist_id) do
|
|
||||||
%{
|
|
||||||
track
|
|
||||||
| scrobbled_at_label: localize_scrobbled_at(track.scrobbled_at_uts, timezone),
|
|
||||||
artist: polyfill_artist(track.artist, artist_id)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp polyfill_artist(artist, musicbrainz_id) do
|
|
||||||
if is_nil(artist.musicbrainz_id) or artist.musicbrainz_id == "" do
|
|
||||||
%{artist | musicbrainz_id: musicbrainz_id}
|
|
||||||
else
|
|
||||||
artist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def localize_scrobbled_at(uts, timezone) do
|
|
||||||
ldt =
|
|
||||||
uts
|
|
||||||
|> DateTime.from_unix!()
|
|
||||||
|> DateTime.shift_zone!(timezone)
|
|
||||||
|
|
||||||
Calendar.strftime(ldt, "%d/%m/%Y %X")
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the top albums by scrobble count for the given number of days.
|
|
||||||
Returns a list of maps with album information and play counts.
|
|
||||||
"""
|
|
||||||
def get_top_albums_by_days(days, opts) do
|
|
||||||
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
|
||||||
current_time = Keyword.get_lazy(opts, :current_time, &DateTime.utc_now/0)
|
|
||||||
timezone = Keyword.get(opts, :timezone, &MusicLibrary.default_timezone/0)
|
|
||||||
|
|
||||||
cutoff_timestamp =
|
|
||||||
current_time
|
|
||||||
|> DateTime.add(-days, :day)
|
|
||||||
|> NaiveDateTime.beginning_of_day()
|
|
||||||
|> DateTime.from_naive!(timezone)
|
|
||||||
|> DateTime.to_unix()
|
|
||||||
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
left_join: cr in subquery(Collection.collected_releases_query()),
|
|
||||||
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
|
||||||
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
where: t.scrobbled_at_uts >= ^cutoff_timestamp,
|
|
||||||
where: fragment("json_extract(album, '$.title') != ''"),
|
|
||||||
group_by: [
|
|
||||||
fragment("json_extract(album, '$.title')"),
|
|
||||||
fragment("json_extract(artist, '$.name')")
|
|
||||||
],
|
|
||||||
select: %{
|
|
||||||
album_title: fragment("json_extract(album, '$.title')"),
|
|
||||||
artist_name: fragment("json_extract(artist, '$.name')"),
|
|
||||||
artist_musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
|
|
||||||
play_count: count(t.scrobbled_at_uts, :distinct),
|
|
||||||
cover_url: fragment("max(?)", t.cover_url),
|
|
||||||
album_musicbrainz_id: fragment("json_extract(album, '$.musicbrainz_id')"),
|
|
||||||
collected_record_id: cr.record_id,
|
|
||||||
wishlisted_record_id: wr.record_id,
|
|
||||||
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
|
||||||
},
|
|
||||||
order_by: [desc: count(t.scrobbled_at_uts, :distinct)],
|
|
||||||
limit: ^limit
|
|
||||||
|
|
||||||
Repo.all(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the top albums by scrobble count across all time.
|
|
||||||
Returns a list of maps with album information and play counts.
|
|
||||||
"""
|
|
||||||
def get_top_albums(opts) do
|
|
||||||
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
|
||||||
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
left_join: cr in subquery(Collection.collected_releases_query()),
|
|
||||||
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
|
||||||
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
where: fragment("json_extract(album, '$.title') != ''"),
|
|
||||||
group_by: [
|
|
||||||
fragment("json_extract(album, '$.title')"),
|
|
||||||
fragment("json_extract(artist, '$.name')")
|
|
||||||
],
|
|
||||||
select: %{
|
|
||||||
album_title: fragment("json_extract(album, '$.title')"),
|
|
||||||
artist_name: fragment("json_extract(artist, '$.name')"),
|
|
||||||
artist_musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
|
|
||||||
play_count: count(t.scrobbled_at_uts, :distinct),
|
|
||||||
cover_url: fragment("max(?)", t.cover_url),
|
|
||||||
album_musicbrainz_id: fragment("json_extract(album, '$.musicbrainz_id')"),
|
|
||||||
collected_record_id: cr.record_id,
|
|
||||||
wishlisted_record_id: wr.record_id,
|
|
||||||
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
|
||||||
},
|
|
||||||
order_by: [desc: count(t.scrobbled_at_uts, :distinct)],
|
|
||||||
limit: ^limit
|
|
||||||
|
|
||||||
Repo.all(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the top artists by scrobble count across all time.
|
|
||||||
Returns a list of maps with artist information and play counts.
|
|
||||||
"""
|
|
||||||
def get_top_artists(opts) do
|
|
||||||
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
|
||||||
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
left_join: ai in Artists.ArtistInfo,
|
|
||||||
on: ai.id == fragment("json_extract(?, '$.musicbrainz_id')", t.artist),
|
|
||||||
group_by: [
|
|
||||||
fragment("json_extract(artist, '$.name')")
|
|
||||||
],
|
|
||||||
select: %{
|
|
||||||
name: fragment("json_extract(artist, '$.name')"),
|
|
||||||
musicbrainz_id: max(fragment("json_extract(artist, '$.musicbrainz_id')")),
|
|
||||||
image_hash: max(ai.image_data_hash),
|
|
||||||
play_count: count(t.scrobbled_at_uts, :distinct)
|
|
||||||
},
|
|
||||||
order_by: [desc: count(t.scrobbled_at_uts, :distinct)],
|
|
||||||
limit: ^limit
|
|
||||||
|
|
||||||
Repo.all(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets the top artists by scrobble count for the given number of days.
|
|
||||||
Returns a list of maps with artist information and play counts.
|
|
||||||
"""
|
|
||||||
def get_top_artists_by_days(days, opts) do
|
|
||||||
limit = Keyword.get(opts, :limit, @pagination[:top_items_limit])
|
|
||||||
current_time = Keyword.get_lazy(opts, :current_time, &DateTime.utc_now/0)
|
|
||||||
timezone = Keyword.get(opts, :timezone, &MusicLibrary.default_timezone/0)
|
|
||||||
|
|
||||||
cutoff_timestamp =
|
|
||||||
current_time
|
|
||||||
|> DateTime.add(-days, :day)
|
|
||||||
|> NaiveDateTime.beginning_of_day()
|
|
||||||
|> DateTime.from_naive!(timezone)
|
|
||||||
|> DateTime.to_unix()
|
|
||||||
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
left_join: ai in Artists.ArtistInfo,
|
|
||||||
on: ai.id == fragment("json_extract(?, '$.musicbrainz_id')", t.artist),
|
|
||||||
where: t.scrobbled_at_uts >= ^cutoff_timestamp,
|
|
||||||
group_by: [
|
|
||||||
fragment("json_extract(artist, '$.name')")
|
|
||||||
],
|
|
||||||
select: %{
|
|
||||||
name: fragment("json_extract(artist, '$.name')"),
|
|
||||||
musicbrainz_id: max(fragment("json_extract(artist, '$.musicbrainz_id')")),
|
|
||||||
image_hash: max(ai.image_data_hash),
|
|
||||||
play_count: count(t.scrobbled_at_uts, :distinct)
|
|
||||||
},
|
|
||||||
order_by: [desc: count(t.scrobbled_at_uts, :distinct)],
|
|
||||||
limit: ^limit
|
|
||||||
|
|
||||||
Repo.all(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets top albums for the specified time periods (30, 90, 365 days) and all
|
|
||||||
time. Returns a map with the results, along with collected and wishlisted
|
|
||||||
releases.
|
|
||||||
"""
|
|
||||||
def get_top_albums_by_period(opts) do
|
|
||||||
case Keyword.get(opts, :period, :last_7_days) do
|
|
||||||
:all_time -> get_top_albums(opts)
|
|
||||||
:last_7_days -> get_top_albums_by_days(7, opts)
|
|
||||||
:last_30_days -> get_top_albums_by_days(30, opts)
|
|
||||||
:last_90_days -> get_top_albums_by_days(90, opts)
|
|
||||||
:last_365_days -> get_top_albums_by_days(365, opts)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets top artists for a time period (30, 90, 365 days) and all time.
|
|
||||||
"""
|
|
||||||
def get_top_artists_by_period(opts) do
|
|
||||||
period = Keyword.get(opts, :period, :last_7_days)
|
|
||||||
|
|
||||||
case period do
|
|
||||||
:all_time -> get_top_artists(opts)
|
|
||||||
:last_7_days -> get_top_artists_by_days(7, opts)
|
|
||||||
:last_30_days -> get_top_artists_by_days(30, opts)
|
|
||||||
:last_90_days -> get_top_artists_by_days(90, opts)
|
|
||||||
:last_365_days -> get_top_artists_by_days(365, opts)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Lists scrobbled tracks with pagination and search support.
|
Lists scrobbled tracks with pagination and search support.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
defmodule MusicLibraryWeb.CollectionLive.Show do
|
defmodule MusicLibraryWeb.CollectionLive.Show do
|
||||||
use MusicLibraryWeb, :live_view
|
use MusicLibraryWeb, :live_view
|
||||||
|
|
||||||
import MusicLibrary.ScrobbleActivity, only: [localize_scrobbled_at: 2]
|
import MusicLibrary.ListeningStats, only: [localize_scrobbled_at: 2]
|
||||||
|
|
||||||
import MusicLibraryWeb.RecordComponents,
|
import MusicLibraryWeb.RecordComponents,
|
||||||
only: [
|
only: [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
import MusicLibraryWeb.ScrobbleComponents
|
import MusicLibraryWeb.ScrobbleComponents
|
||||||
|
|
||||||
alias MusicLibrary.Assets.Transform
|
alias MusicLibrary.Assets.Transform
|
||||||
alias MusicLibrary.{Collection, Records, ScrobbleActivity, Wishlist}
|
alias MusicLibrary.{Collection, ListeningStats, Records, Wishlist}
|
||||||
alias MusicLibraryWeb.StatsLive.{TopAlbums, TopArtists}
|
alias MusicLibraryWeb.StatsLive.{TopAlbums, TopArtists}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -461,9 +461,9 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
%{
|
%{
|
||||||
recent_tracks: recent_tracks,
|
recent_tracks: recent_tracks,
|
||||||
recent_albums: recent_albums
|
recent_albums: recent_albums
|
||||||
} = ScrobbleActivity.recent_activity(socket.assigns.timezone)
|
} = ListeningStats.recent_activity(socket.assigns.timezone)
|
||||||
|
|
||||||
scrobble_count = ScrobbleActivity.scrobble_count()
|
scrobble_count = ListeningStats.scrobble_count()
|
||||||
|
|
||||||
last_updated_uts =
|
last_updated_uts =
|
||||||
if rt = List.first(recent_tracks) do
|
if rt = List.first(recent_tracks) do
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule MusicLibraryWeb.StatsLive.TopAlbums do
|
|||||||
use MusicLibraryWeb, :live_component
|
use MusicLibraryWeb, :live_component
|
||||||
|
|
||||||
alias MusicLibrary.Assets.Transform
|
alias MusicLibrary.Assets.Transform
|
||||||
alias MusicLibrary.ScrobbleActivity
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
def live(assigns) do
|
def live(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@@ -165,7 +165,7 @@ defmodule MusicLibraryWeb.StatsLive.TopAlbums do
|
|||||||
:top_albums,
|
:top_albums,
|
||||||
fn ->
|
fn ->
|
||||||
top_albums =
|
top_albums =
|
||||||
ScrobbleActivity.get_top_albums_by_period(
|
ListeningStats.get_top_albums_by_period(
|
||||||
limit: 10,
|
limit: 10,
|
||||||
current_time: current_time,
|
current_time: current_time,
|
||||||
timezone: timezone,
|
timezone: timezone,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ defmodule MusicLibraryWeb.StatsLive.TopArtists do
|
|||||||
|
|
||||||
import MusicLibraryWeb.RecordComponents, only: [artist_image: 1]
|
import MusicLibraryWeb.RecordComponents, only: [artist_image: 1]
|
||||||
|
|
||||||
alias MusicLibrary.ScrobbleActivity
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
def live(assigns) do
|
def live(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@@ -153,7 +153,7 @@ defmodule MusicLibraryWeb.StatsLive.TopArtists do
|
|||||||
:top_artists,
|
:top_artists,
|
||||||
fn ->
|
fn ->
|
||||||
top_artists =
|
top_artists =
|
||||||
ScrobbleActivity.get_top_artists_by_period(
|
ListeningStats.get_top_artists_by_period(
|
||||||
limit: 10,
|
limit: 10,
|
||||||
current_time: current_time,
|
current_time: current_time,
|
||||||
timezone: timezone,
|
timezone: timezone,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
defmodule MusicLibrary.ListeningStatsTest do
|
||||||
|
use MusicLibrary.DataCase
|
||||||
|
|
||||||
|
import MusicLibrary.ScrobbledTracksFixtures
|
||||||
|
|
||||||
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
|
describe "scrobble_count/0" do
|
||||||
|
test "returns correct count" do
|
||||||
|
initial_count = ListeningStats.scrobble_count()
|
||||||
|
|
||||||
|
create_test_tracks(3)
|
||||||
|
|
||||||
|
new_count = ListeningStats.scrobble_count()
|
||||||
|
|
||||||
|
assert new_count == initial_count + 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_top_artists/1" do
|
||||||
|
test "counts tracks with missing artist_infos records" do
|
||||||
|
artist_mbid = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
# Create an artist_info record for the known musicbrainz_id
|
||||||
|
MusicLibrary.Repo.insert!(%MusicLibrary.Artists.ArtistInfo{
|
||||||
|
id: artist_mbid,
|
||||||
|
musicbrainz_data: %{"name" => "Thin Lizzy"}
|
||||||
|
})
|
||||||
|
|
||||||
|
now = System.system_time(:second)
|
||||||
|
|
||||||
|
# Two tracks with a matching artist_infos record
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Thin Lizzy",
|
||||||
|
artist_musicbrainz_id: artist_mbid,
|
||||||
|
title: "The Boys Are Back in Town",
|
||||||
|
scrobbled_at_uts: now - 100
|
||||||
|
})
|
||||||
|
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Thin Lizzy",
|
||||||
|
artist_musicbrainz_id: artist_mbid,
|
||||||
|
title: "Jailbreak",
|
||||||
|
scrobbled_at_uts: now - 200
|
||||||
|
})
|
||||||
|
|
||||||
|
# One track with an empty musicbrainz_id (no matching artist_infos)
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Thin Lizzy",
|
||||||
|
artist_musicbrainz_id: "",
|
||||||
|
title: "Rosalie / Cowgirl's Song",
|
||||||
|
scrobbled_at_uts: now - 300
|
||||||
|
})
|
||||||
|
|
||||||
|
results = ListeningStats.get_top_artists(limit: 10)
|
||||||
|
|
||||||
|
# All 3 tracks should be counted as a single artist entry
|
||||||
|
assert [thin_lizzy] = Enum.filter(results, fn r -> r.name == "Thin Lizzy" end)
|
||||||
|
assert thin_lizzy.play_count == 3
|
||||||
|
# MAX picks the real musicbrainz_id over the empty string
|
||||||
|
assert thin_lizzy.musicbrainz_id == artist_mbid
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns image_hash from artist_infos when available" do
|
||||||
|
artist_mbid = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
MusicLibrary.Repo.insert!(%MusicLibrary.Artists.ArtistInfo{
|
||||||
|
id: artist_mbid,
|
||||||
|
musicbrainz_data: %{"name" => "Test Artist"},
|
||||||
|
image_data_hash: "abc123"
|
||||||
|
})
|
||||||
|
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Test Artist",
|
||||||
|
artist_musicbrainz_id: artist_mbid,
|
||||||
|
title: "Track 1",
|
||||||
|
scrobbled_at_uts: System.system_time(:second) - 100
|
||||||
|
})
|
||||||
|
|
||||||
|
[result] = ListeningStats.get_top_artists(limit: 10)
|
||||||
|
|
||||||
|
assert result.image_hash == "abc123"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil image_hash for tracks without artist_infos" do
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Unknown Artist",
|
||||||
|
artist_musicbrainz_id: "",
|
||||||
|
title: "Track 1",
|
||||||
|
scrobbled_at_uts: System.system_time(:second) - 100
|
||||||
|
})
|
||||||
|
|
||||||
|
[result] = ListeningStats.get_top_artists(limit: 10)
|
||||||
|
|
||||||
|
assert result.image_hash == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_top_artists_by_days/2" do
|
||||||
|
test "counts tracks with missing artist_infos records within date range" do
|
||||||
|
artist_mbid = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
MusicLibrary.Repo.insert!(%MusicLibrary.Artists.ArtistInfo{
|
||||||
|
id: artist_mbid,
|
||||||
|
musicbrainz_data: %{"name" => "Thin Lizzy"}
|
||||||
|
})
|
||||||
|
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
now_unix = DateTime.to_unix(now)
|
||||||
|
|
||||||
|
# Two tracks with matching artist_infos
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Thin Lizzy",
|
||||||
|
artist_musicbrainz_id: artist_mbid,
|
||||||
|
title: "The Boys Are Back in Town",
|
||||||
|
scrobbled_at_uts: now_unix - 100
|
||||||
|
})
|
||||||
|
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Thin Lizzy",
|
||||||
|
artist_musicbrainz_id: artist_mbid,
|
||||||
|
title: "Jailbreak",
|
||||||
|
scrobbled_at_uts: now_unix - 200
|
||||||
|
})
|
||||||
|
|
||||||
|
# One track with empty musicbrainz_id
|
||||||
|
track_fixture(%{
|
||||||
|
artist_name: "Thin Lizzy",
|
||||||
|
artist_musicbrainz_id: "",
|
||||||
|
title: "Rosalie / Cowgirl's Song",
|
||||||
|
scrobbled_at_uts: now_unix - 300
|
||||||
|
})
|
||||||
|
|
||||||
|
results =
|
||||||
|
ListeningStats.get_top_artists_by_days(7,
|
||||||
|
limit: 10,
|
||||||
|
current_time: now,
|
||||||
|
timezone: "Etc/UTC"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [thin_lizzy] = Enum.filter(results, fn r -> r.name == "Thin Lizzy" end)
|
||||||
|
assert thin_lizzy.play_count == 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -271,144 +271,6 @@ defmodule MusicLibrary.ScrobbleActivityTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_top_artists/1" do
|
|
||||||
test "counts tracks with missing artist_infos records" do
|
|
||||||
artist_mbid = Ecto.UUID.generate()
|
|
||||||
|
|
||||||
# Create an artist_info record for the known musicbrainz_id
|
|
||||||
MusicLibrary.Repo.insert!(%MusicLibrary.Artists.ArtistInfo{
|
|
||||||
id: artist_mbid,
|
|
||||||
musicbrainz_data: %{"name" => "Thin Lizzy"}
|
|
||||||
})
|
|
||||||
|
|
||||||
now = System.system_time(:second)
|
|
||||||
|
|
||||||
# Two tracks with a matching artist_infos record
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Thin Lizzy",
|
|
||||||
artist_musicbrainz_id: artist_mbid,
|
|
||||||
title: "The Boys Are Back in Town",
|
|
||||||
scrobbled_at_uts: now - 100
|
|
||||||
})
|
|
||||||
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Thin Lizzy",
|
|
||||||
artist_musicbrainz_id: artist_mbid,
|
|
||||||
title: "Jailbreak",
|
|
||||||
scrobbled_at_uts: now - 200
|
|
||||||
})
|
|
||||||
|
|
||||||
# One track with an empty musicbrainz_id (no matching artist_infos)
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Thin Lizzy",
|
|
||||||
artist_musicbrainz_id: "",
|
|
||||||
title: "Rosalie / Cowgirl's Song",
|
|
||||||
scrobbled_at_uts: now - 300
|
|
||||||
})
|
|
||||||
|
|
||||||
results = ScrobbleActivity.get_top_artists(limit: 10)
|
|
||||||
|
|
||||||
# All 3 tracks should be counted as a single artist entry
|
|
||||||
assert [thin_lizzy] = Enum.filter(results, fn r -> r.name == "Thin Lizzy" end)
|
|
||||||
assert thin_lizzy.play_count == 3
|
|
||||||
# MAX picks the real musicbrainz_id over the empty string
|
|
||||||
assert thin_lizzy.musicbrainz_id == artist_mbid
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns image_hash from artist_infos when available" do
|
|
||||||
artist_mbid = Ecto.UUID.generate()
|
|
||||||
|
|
||||||
MusicLibrary.Repo.insert!(%MusicLibrary.Artists.ArtistInfo{
|
|
||||||
id: artist_mbid,
|
|
||||||
musicbrainz_data: %{"name" => "Test Artist"},
|
|
||||||
image_data_hash: "abc123"
|
|
||||||
})
|
|
||||||
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Test Artist",
|
|
||||||
artist_musicbrainz_id: artist_mbid,
|
|
||||||
title: "Track 1",
|
|
||||||
scrobbled_at_uts: System.system_time(:second) - 100
|
|
||||||
})
|
|
||||||
|
|
||||||
[result] = ScrobbleActivity.get_top_artists(limit: 10)
|
|
||||||
|
|
||||||
assert result.image_hash == "abc123"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns nil image_hash for tracks without artist_infos" do
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Unknown Artist",
|
|
||||||
artist_musicbrainz_id: "",
|
|
||||||
title: "Track 1",
|
|
||||||
scrobbled_at_uts: System.system_time(:second) - 100
|
|
||||||
})
|
|
||||||
|
|
||||||
[result] = ScrobbleActivity.get_top_artists(limit: 10)
|
|
||||||
|
|
||||||
assert result.image_hash == nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_top_artists_by_days/2" do
|
|
||||||
test "counts tracks with missing artist_infos records within date range" do
|
|
||||||
artist_mbid = Ecto.UUID.generate()
|
|
||||||
|
|
||||||
MusicLibrary.Repo.insert!(%MusicLibrary.Artists.ArtistInfo{
|
|
||||||
id: artist_mbid,
|
|
||||||
musicbrainz_data: %{"name" => "Thin Lizzy"}
|
|
||||||
})
|
|
||||||
|
|
||||||
now = DateTime.utc_now()
|
|
||||||
now_unix = DateTime.to_unix(now)
|
|
||||||
|
|
||||||
# Two tracks with matching artist_infos
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Thin Lizzy",
|
|
||||||
artist_musicbrainz_id: artist_mbid,
|
|
||||||
title: "The Boys Are Back in Town",
|
|
||||||
scrobbled_at_uts: now_unix - 100
|
|
||||||
})
|
|
||||||
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Thin Lizzy",
|
|
||||||
artist_musicbrainz_id: artist_mbid,
|
|
||||||
title: "Jailbreak",
|
|
||||||
scrobbled_at_uts: now_unix - 200
|
|
||||||
})
|
|
||||||
|
|
||||||
# One track with empty musicbrainz_id
|
|
||||||
track_fixture(%{
|
|
||||||
artist_name: "Thin Lizzy",
|
|
||||||
artist_musicbrainz_id: "",
|
|
||||||
title: "Rosalie / Cowgirl's Song",
|
|
||||||
scrobbled_at_uts: now_unix - 300
|
|
||||||
})
|
|
||||||
|
|
||||||
results =
|
|
||||||
ScrobbleActivity.get_top_artists_by_days(7,
|
|
||||||
limit: 10,
|
|
||||||
current_time: now,
|
|
||||||
timezone: "Etc/UTC"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [thin_lizzy] = Enum.filter(results, fn r -> r.name == "Thin Lizzy" end)
|
|
||||||
assert thin_lizzy.play_count == 3
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "integration with existing functions" do
|
|
||||||
test "scrobble_count returns correct count" do
|
|
||||||
initial_count = ScrobbleActivity.scrobble_count()
|
|
||||||
|
|
||||||
create_test_tracks(3)
|
|
||||||
|
|
||||||
new_count = ScrobbleActivity.scrobble_count()
|
|
||||||
|
|
||||||
assert new_count == initial_count + 3
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp list_tracks do
|
defp list_tracks do
|
||||||
ScrobbleActivity.list_tracks()
|
ScrobbleActivity.list_tracks()
|
||||||
|> Enum.map(fn r -> r.track end)
|
|> Enum.map(fn r -> r.track end)
|
||||||
|
|||||||
Reference in New Issue
Block a user