diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 52726374..f47493bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -96,7 +96,8 @@ Last.fm schemas (separate, not Ecto-persisted to main DB): | `Notes` | Note | Free-text notes for records and artists | | `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering | | `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 | | `Search` | (cross-context) | Universal search across collection, wishlist, artists, record sets | | `Secrets` | Secret | Encrypted key-value storage | diff --git a/lib/music_library/listening_stats.ex b/lib/music_library/listening_stats.ex new file mode 100644 index 00000000..8330c53d --- /dev/null +++ b/lib/music_library/listening_stats.ex @@ -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 diff --git a/lib/music_library/scrobble_activity.ex b/lib/music_library/scrobble_activity.ex index 0e09332a..73cd3783 100644 --- a/lib/music_library/scrobble_activity.ex +++ b/lib/music_library/scrobble_activity.ex @@ -3,7 +3,7 @@ defmodule MusicLibrary.ScrobbleActivity do alias LastFm.{Scrobble, Track} 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) @@ -179,270 +179,6 @@ defmodule MusicLibrary.ScrobbleActivity do defp main_artist_name([]), do: nil 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 """ Lists scrobbled tracks with pagination and search support. """ diff --git a/lib/music_library_web/live/collection_live/show.ex b/lib/music_library_web/live/collection_live/show.ex index 584614f4..40374bb4 100644 --- a/lib/music_library_web/live/collection_live/show.ex +++ b/lib/music_library_web/live/collection_live/show.ex @@ -1,7 +1,7 @@ defmodule MusicLibraryWeb.CollectionLive.Show do use MusicLibraryWeb, :live_view - import MusicLibrary.ScrobbleActivity, only: [localize_scrobbled_at: 2] + import MusicLibrary.ListeningStats, only: [localize_scrobbled_at: 2] import MusicLibraryWeb.RecordComponents, only: [ diff --git a/lib/music_library_web/live/stats_live/index.ex b/lib/music_library_web/live/stats_live/index.ex index c533bbcb..d7533cbb 100644 --- a/lib/music_library_web/live/stats_live/index.ex +++ b/lib/music_library_web/live/stats_live/index.ex @@ -8,7 +8,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do import MusicLibraryWeb.ScrobbleComponents alias MusicLibrary.Assets.Transform - alias MusicLibrary.{Collection, Records, ScrobbleActivity, Wishlist} + alias MusicLibrary.{Collection, ListeningStats, Records, Wishlist} alias MusicLibraryWeb.StatsLive.{TopAlbums, TopArtists} @impl true @@ -461,9 +461,9 @@ defmodule MusicLibraryWeb.StatsLive.Index do %{ recent_tracks: recent_tracks, 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 = if rt = List.first(recent_tracks) do diff --git a/lib/music_library_web/live/stats_live/top_albums.ex b/lib/music_library_web/live/stats_live/top_albums.ex index 0a940807..42551b5b 100644 --- a/lib/music_library_web/live/stats_live/top_albums.ex +++ b/lib/music_library_web/live/stats_live/top_albums.ex @@ -2,7 +2,7 @@ defmodule MusicLibraryWeb.StatsLive.TopAlbums do use MusicLibraryWeb, :live_component alias MusicLibrary.Assets.Transform - alias MusicLibrary.ScrobbleActivity + alias MusicLibrary.ListeningStats def live(assigns) do ~H""" @@ -165,7 +165,7 @@ defmodule MusicLibraryWeb.StatsLive.TopAlbums do :top_albums, fn -> top_albums = - ScrobbleActivity.get_top_albums_by_period( + ListeningStats.get_top_albums_by_period( limit: 10, current_time: current_time, timezone: timezone, diff --git a/lib/music_library_web/live/stats_live/top_artists.ex b/lib/music_library_web/live/stats_live/top_artists.ex index 1df92687..5b4d84c2 100644 --- a/lib/music_library_web/live/stats_live/top_artists.ex +++ b/lib/music_library_web/live/stats_live/top_artists.ex @@ -3,7 +3,7 @@ defmodule MusicLibraryWeb.StatsLive.TopArtists do import MusicLibraryWeb.RecordComponents, only: [artist_image: 1] - alias MusicLibrary.ScrobbleActivity + alias MusicLibrary.ListeningStats def live(assigns) do ~H""" @@ -153,7 +153,7 @@ defmodule MusicLibraryWeb.StatsLive.TopArtists do :top_artists, fn -> top_artists = - ScrobbleActivity.get_top_artists_by_period( + ListeningStats.get_top_artists_by_period( limit: 10, current_time: current_time, timezone: timezone, diff --git a/test/music_library/listening_stats_test.exs b/test/music_library/listening_stats_test.exs new file mode 100644 index 00000000..901669d6 --- /dev/null +++ b/test/music_library/listening_stats_test.exs @@ -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 diff --git a/test/music_library/scrobble_activity_test.exs b/test/music_library/scrobble_activity_test.exs index 5986fc09..b4ed9e76 100644 --- a/test/music_library/scrobble_activity_test.exs +++ b/test/music_library/scrobble_activity_test.exs @@ -271,144 +271,6 @@ defmodule MusicLibrary.ScrobbleActivityTest do 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 ScrobbleActivity.list_tracks() |> Enum.map(fn r -> r.track end)