Split ScrobbleActivity into focused modules

Move track CRUD/listing to ListeningStats and diagnostics
to Maintenance, leaving ScrobbleActivity for scrobbling only.

Closes #111
This commit is contained in:
Claudio Ortolina
2026-03-12 16:51:33 +00:00
parent ee4d2555ee
commit d057f29ba1
13 changed files with 710 additions and 728 deletions
+30
View File
@@ -0,0 +1,30 @@
alias MusicLibrary.ListeningStats
Logger.configure(level: :error)
timezone = MusicLibrary.default_timezone()
Benchee.run(
%{
"get_top_albums(limit: 10)" => fn -> ListeningStats.get_top_albums(limit: 10) end,
"get_top_albums_by_days(7)" =>
fn -> ListeningStats.get_top_albums_by_days(7, limit: 10, timezone: timezone) end,
"get_top_albums_by_days(30)" =>
fn -> ListeningStats.get_top_albums_by_days(30, limit: 10, timezone: timezone) end,
"get_top_albums_by_days(365)" =>
fn -> ListeningStats.get_top_albums_by_days(365, limit: 10, timezone: timezone) end,
"get_top_artists(limit: 10)" => fn -> ListeningStats.get_top_artists(limit: 10) end,
"get_top_artists_by_days(7)" =>
fn -> ListeningStats.get_top_artists_by_days(7, limit: 10, timezone: timezone) end,
"get_top_artists_by_days(30)" =>
fn -> ListeningStats.get_top_artists_by_days(30, limit: 10, timezone: timezone) end,
"get_top_artists_by_days(365)" =>
fn -> ListeningStats.get_top_artists_by_days(365, limit: 10, timezone: timezone) end,
"recent_activity(tz, 100)" => fn -> ListeningStats.recent_activity(timezone, 100) end,
"list_tracks(page: 1, page_size: 200)" =>
fn -> ListeningStats.list_tracks(%{page: 1, page_size: 200}) end
},
warmup: 2,
time: 5,
memory_time: 2
)
-30
View File
@@ -1,30 +0,0 @@
alias MusicLibrary.ScrobbleActivity
Logger.configure(level: :error)
timezone = MusicLibrary.default_timezone()
Benchee.run(
%{
"get_top_albums(limit: 10)" => fn -> ScrobbleActivity.get_top_albums(limit: 10) end,
"get_top_albums_by_days(7)" =>
fn -> ScrobbleActivity.get_top_albums_by_days(7, limit: 10, timezone: timezone) end,
"get_top_albums_by_days(30)" =>
fn -> ScrobbleActivity.get_top_albums_by_days(30, limit: 10, timezone: timezone) end,
"get_top_albums_by_days(365)" =>
fn -> ScrobbleActivity.get_top_albums_by_days(365, limit: 10, timezone: timezone) end,
"get_top_artists(limit: 10)" => fn -> ScrobbleActivity.get_top_artists(limit: 10) end,
"get_top_artists_by_days(7)" =>
fn -> ScrobbleActivity.get_top_artists_by_days(7, limit: 10, timezone: timezone) end,
"get_top_artists_by_days(30)" =>
fn -> ScrobbleActivity.get_top_artists_by_days(30, limit: 10, timezone: timezone) end,
"get_top_artists_by_days(365)" =>
fn -> ScrobbleActivity.get_top_artists_by_days(365, limit: 10, timezone: timezone) end,
"recent_activity(tz, 100)" => fn -> ScrobbleActivity.recent_activity(timezone, 100) end,
"list_tracks(page: 1, page_size: 200)" =>
fn -> ScrobbleActivity.list_tracks(%{page: 1, page_size: 200}) end
},
warmup: 2,
time: 5,
memory_time: 2
)
+3 -3
View File
@@ -99,13 +99,13 @@ 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, track CRUD, data quality diagnostics |
| `ListeningStats` | (LastFm.Track, ArtistRecord, ArtistInfo) | Read-only listening analytics: scrobble counts, recent activity, top albums/artists by period |
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
| `ListeningStats` | (LastFm.Track, ArtistRecord, ArtistInfo) | Listening analytics, track CRUD, search, listing: 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 |
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow |
| `Maintenance` | (Oban.Job) | Background job monitoring, database vacuum/optimize |
| `Maintenance` | (Oban.Job, LastFm.Track) | Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics |
---
+6 -6
View File
@@ -37,7 +37,7 @@ defmodule Mix.Tasks.Scrobble.Audit do
import Ecto.Query
alias LastFm.Track
alias MusicLibrary.{Repo, ScrobbleActivity}
alias MusicLibrary.{ListeningStats, Maintenance, Repo}
@impl Mix.Task
def run(args) do
@@ -102,7 +102,7 @@ defmodule Mix.Tasks.Scrobble.Audit do
defp generate_audit_report(:all, verbose) do
%{
total_tracks: ScrobbleActivity.count_tracks(),
total_tracks: ListeningStats.scrobble_count(),
artist_issues: audit_artist_musicbrainz_ids(verbose),
album_issues: audit_album_musicbrainz_ids(verbose)
}
@@ -110,20 +110,20 @@ defmodule Mix.Tasks.Scrobble.Audit do
defp generate_audit_report(:artist, verbose) do
%{
total_tracks: ScrobbleActivity.count_tracks(),
total_tracks: ListeningStats.scrobble_count(),
artist_issues: audit_artist_musicbrainz_ids(verbose)
}
end
defp generate_audit_report(:album, verbose) do
%{
total_tracks: ScrobbleActivity.count_tracks(),
total_tracks: ListeningStats.scrobble_count(),
album_issues: audit_album_musicbrainz_ids(verbose)
}
end
defp audit_artist_musicbrainz_ids(verbose) do
results = ScrobbleActivity.get_artists_missing_musicbrainz_id()
results = Maintenance.get_artists_missing_musicbrainz_id()
total_tracks_affected =
Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end)
@@ -145,7 +145,7 @@ defmodule Mix.Tasks.Scrobble.Audit do
end
defp audit_album_musicbrainz_ids(verbose) do
results = ScrobbleActivity.get_albums_missing_musicbrainz_id()
results = Maintenance.get_albums_missing_musicbrainz_id()
total_tracks_affected =
Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end)
+120 -23
View File
@@ -1,10 +1,10 @@
defmodule MusicLibrary.ListeningStats do
@moduledoc """
Read-only listening analytics derived from Last.fm scrobble data.
Listening analytics and track management 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.
Provides scrobble counts, recent activity feeds, top albums/artists
by time period, and track CRUD/search/listing. All queries join across
LastFm.Track, Collection, Wishlist, and ArtistInfo.
"""
import Ecto.Query
@@ -36,27 +36,10 @@ defmodule MusicLibrary.ListeningStats 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,
from [t, cr, wr, ar] in tracks_with_record_info_query(),
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)
}
limit: ^limit
recent_tracks = Repo.all(tracks_query)
@@ -100,6 +83,99 @@ defmodule MusicLibrary.ListeningStats do
Calendar.strftime(ldt, "%d/%m/%Y %X")
end
# Track CRUD + listing
@spec list_tracks(map()) :: [map()]
def list_tracks(params \\ %{}) do
query = Map.get(params, :query, "")
page = Map.get(params, :page, 1)
page_size = Map.get(params, :page_size, @pagination[:tracks_page_size])
order = Map.get(params, :order, :scrobbled_at)
base_query = tracks_with_record_info_query()
search_query =
if query == "" do
base_query
else
query_term = "%#{String.downcase(query)}%"
from [t, cr, wr, ar] 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)
end
ordered_query =
case order do
:scrobbled_at ->
from [t] in search_query, order_by: [desc: t.scrobbled_at_uts]
:title ->
from [t] in search_query, order_by: [asc: t.title]
:artist ->
from [t] in search_query,
order_by: [asc: fragment("json_extract(?, '$.name')", t.artist)]
:album ->
from [t] in search_query,
order_by: [asc: fragment("json_extract(?, '$.title')", t.album)]
end
offset = (page - 1) * page_size
from(t in ordered_query, limit: ^page_size, offset: ^offset)
|> Repo.all()
end
@spec get_track!(integer() | String.t()) :: LastFm.Track.t()
def get_track!(scrobbled_at_uts) when is_integer(scrobbled_at_uts) do
Repo.get!(Track, scrobbled_at_uts)
end
def get_track!(scrobbled_at_uts) when is_binary(scrobbled_at_uts) do
case Integer.parse(scrobbled_at_uts) do
{id, ""} -> get_track!(id)
_ -> raise Ecto.NoResultsError, queryable: Track
end
end
@spec update_track(LastFm.Track.t(), map()) ::
{:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
def update_track(%Track{} = track, attrs) do
changeset = Track.changeset(track, attrs)
Repo.update(changeset)
end
@spec delete_track(LastFm.Track.t()) :: {:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
def delete_track(%Track{} = track) do
Repo.delete(track)
end
@spec search_tracks_count(String.t()) :: non_neg_integer()
def search_tracks_count(query \\ "") do
base_query = from(t in Track)
search_query =
if query == "" do
base_query
else
query_term = "%#{String.downcase(query)}%"
from t in base_query,
where:
like(fragment("lower(?)", t.title), ^query_term) or
like(fragment("lower(json_extract(artist, '$.name'))"), ^query_term) or
like(fragment("lower(json_extract(album, '$.title'))"), ^query_term)
end
Repo.aggregate(search_query, :count, :scrobbled_at_uts)
end
# Top albums/artists by period
@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.
@@ -187,6 +263,27 @@ defmodule MusicLibrary.ListeningStats do
# Shared base queries
defp tracks_with_record_info_query do
all_artists_query =
from ar in ArtistRecord,
distinct: true
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,
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)
}
end
defp top_albums_base_query do
from t in Track,
left_join: cr in subquery(Collection.collected_releases_query()),
+95 -1
View File
@@ -1,10 +1,12 @@
defmodule MusicLibrary.Maintenance do
@moduledoc """
Context for database maintenance operations and background job monitoring.
Context for database maintenance operations, background job monitoring,
and scrobble data quality diagnostics.
"""
import Ecto.Query
alias LastFm.Track
alias MusicLibrary.BackgroundRepo
alias MusicLibrary.Repo
@@ -39,4 +41,96 @@ defmodule MusicLibrary.Maintenance do
def optimize do
Repo.optimize()
end
# Scrobble data quality diagnostics
@spec count_tracks_missing_artist_musicbrainz_id() :: non_neg_integer()
def count_tracks_missing_artist_musicbrainz_id do
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
select: count(t.scrobbled_at_uts)
Repo.one(query) || 0
end
@spec count_tracks_missing_album_musicbrainz_id() :: non_neg_integer()
def count_tracks_missing_album_musicbrainz_id do
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
select: count(t.scrobbled_at_uts)
Repo.one(query) || 0
end
@doc """
Gets artists with missing MusicBrainz IDs, grouped by artist name.
Returns a list of maps with artist name and track count.
"""
@spec get_artists_missing_musicbrainz_id(keyword()) :: [map()]
def get_artists_missing_musicbrainz_id(opts \\ []) do
limit = Keyword.get(opts, :limit)
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
select: %{
artist_name: fragment("json_extract(?, '$.name')", t.artist),
track_count: count(t.scrobbled_at_uts)
},
group_by: fragment("json_extract(?, '$.name')", t.artist),
order_by: [desc: count(t.scrobbled_at_uts)]
query =
if limit do
from q in query, limit: ^limit
else
query
end
Repo.all(query)
end
@doc """
Gets albums with missing MusicBrainz IDs, grouped by album title and artist.
Returns a list of maps with album title, artist name, and track count.
"""
@spec get_albums_missing_musicbrainz_id(keyword()) :: [map()]
def get_albums_missing_musicbrainz_id(opts \\ []) do
limit = Keyword.get(opts, :limit)
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
select: %{
album_title: fragment("json_extract(?, '$.title')", t.album),
artist_name: fragment("json_extract(?, '$.name')", t.artist),
track_count: count(t.scrobbled_at_uts)
},
group_by: [
fragment("json_extract(?, '$.title')", t.album),
fragment("json_extract(?, '$.name')", t.artist)
],
order_by: [desc: count(t.scrobbled_at_uts)]
query =
if limit do
from q in query, limit: ^limit
else
query
end
Repo.all(query)
end
end
+2 -206
View File
@@ -1,11 +1,7 @@
defmodule MusicLibrary.ScrobbleActivity do
import Ecto.Query
alias LastFm.{Scrobble, Track}
alias LastFm.Scrobble
alias MusicBrainz.Release
alias MusicLibrary.{Collection, Records.ArtistRecord, Repo, Secrets, Wishlist}
@pagination Application.compile_env!(:music_library, :pagination)
alias MusicLibrary.Secrets
@spec can_scrobble?() :: boolean()
def can_scrobble? do
@@ -200,204 +196,4 @@ defmodule MusicLibrary.ScrobbleActivity do
defp main_artist_name([]), do: nil
defp main_artist_name([artist | _rest]), do: artist.name
@spec list_tracks(map()) :: [map()]
def list_tracks(params \\ %{}) do
query = Map.get(params, :query, "")
page = Map.get(params, :page, 1)
page_size = Map.get(params, :page_size, @pagination[:tracks_page_size])
order = Map.get(params, :order, :scrobbled_at)
all_artists_query =
from ar in ArtistRecord,
distinct: true
base_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,
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)
}
search_query =
if query == "" do
base_query
else
query_term = "%#{String.downcase(query)}%"
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)
end
ordered_query =
case order do
:scrobbled_at ->
from t in search_query, order_by: [desc: t.scrobbled_at_uts]
:title ->
from t in search_query, order_by: [asc: t.title]
:artist ->
from t in search_query, order_by: [asc: fragment("json_extract(?, '$.name')", t.artist)]
:album ->
from t in search_query, order_by: [asc: fragment("json_extract(?, '$.title')", t.album)]
end
offset = (page - 1) * page_size
from(t in ordered_query, limit: ^page_size, offset: ^offset)
|> Repo.all()
end
@spec count_tracks() :: non_neg_integer()
def count_tracks do
Repo.aggregate(Track, :count, :scrobbled_at_uts)
end
@spec get_track!(integer() | String.t()) :: LastFm.Track.t()
def get_track!(scrobbled_at_uts) when is_integer(scrobbled_at_uts) do
Repo.get!(Track, scrobbled_at_uts)
end
def get_track!(scrobbled_at_uts) when is_binary(scrobbled_at_uts) do
case Integer.parse(scrobbled_at_uts) do
{id, ""} -> get_track!(id)
_ -> raise Ecto.NoResultsError, queryable: Track
end
end
@spec update_track(LastFm.Track.t(), map()) ::
{:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
def update_track(%Track{} = track, attrs) do
changeset = Track.changeset(track, attrs)
Repo.update(changeset)
end
@spec delete_track(LastFm.Track.t()) :: {:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
def delete_track(%Track{} = track) do
Repo.delete(track)
end
@spec search_tracks_count(String.t()) :: non_neg_integer()
def search_tracks_count(query \\ "") do
base_query = from(t in Track)
search_query =
if query == "" do
base_query
else
query_term = "%#{String.downcase(query)}%"
from t in base_query,
where:
like(fragment("lower(?)", t.title), ^query_term) or
like(fragment("lower(json_extract(artist, '$.name'))"), ^query_term) or
like(fragment("lower(json_extract(album, '$.title'))"), ^query_term)
end
Repo.aggregate(search_query, :count, :scrobbled_at_uts)
end
@spec count_tracks_missing_artist_musicbrainz_id() :: non_neg_integer()
def count_tracks_missing_artist_musicbrainz_id do
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
select: count(t.scrobbled_at_uts)
Repo.one(query) || 0
end
@spec count_tracks_missing_album_musicbrainz_id() :: non_neg_integer()
def count_tracks_missing_album_musicbrainz_id do
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
select: count(t.scrobbled_at_uts)
Repo.one(query) || 0
end
@doc """
Gets artists with missing MusicBrainz IDs, grouped by artist name.
Returns a list of maps with artist name and track count.
"""
@spec get_artists_missing_musicbrainz_id(keyword()) :: [map()]
def get_artists_missing_musicbrainz_id(opts \\ []) do
limit = Keyword.get(opts, :limit)
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
select: %{
artist_name: fragment("json_extract(?, '$.name')", t.artist),
track_count: count(t.scrobbled_at_uts)
},
group_by: fragment("json_extract(?, '$.name')", t.artist),
order_by: [desc: count(t.scrobbled_at_uts)]
query =
if limit do
from q in query, limit: ^limit
else
query
end
Repo.all(query)
end
@doc """
Gets albums with missing MusicBrainz IDs, grouped by album title and artist.
Returns a list of maps with album title, artist name, and track count.
"""
@spec get_albums_missing_musicbrainz_id(keyword()) :: [map()]
def get_albums_missing_musicbrainz_id(opts \\ []) do
limit = Keyword.get(opts, :limit)
query =
from t in Track,
where:
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
select: %{
album_title: fragment("json_extract(?, '$.title')", t.album),
artist_name: fragment("json_extract(?, '$.name')", t.artist),
track_count: count(t.scrobbled_at_uts)
},
group_by: [
fragment("json_extract(?, '$.title')", t.album),
fragment("json_extract(?, '$.name')", t.artist)
],
order_by: [desc: count(t.scrobbled_at_uts)]
query =
if limit do
from q in query, limit: ^limit
else
query
end
Repo.all(query)
end
end
@@ -2,7 +2,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Form do
use MusicLibraryWeb, :live_component
alias LastFm.Track
alias MusicLibrary.ScrobbleActivity
alias MusicLibrary.ListeningStats
@impl true
def render(assigns) do
@@ -104,7 +104,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Form do
end
defp save_track(socket, track_params) do
case ScrobbleActivity.update_track(socket.assigns.track, track_params) do
case ListeningStats.update_track(socket.assigns.track, track_params) do
{:ok, track} ->
notify_parent({:saved, track})
@@ -7,7 +7,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
alias LastFm.Track
alias MusicLibrary.Assets.Transform
alias MusicLibrary.ScrobbleActivity
alias MusicLibrary.ListeningStats
@default_tracks_list_params %{
query: "",
@@ -229,7 +229,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
end
defp apply_action(socket, :edit, %{"scrobbled_at_uts" => id} = params) do
track = ScrobbleActivity.get_track!(id)
track = ListeningStats.get_track!(id)
socket
|> apply_fallback_index(params, :tracks, &apply_action/3)
@@ -241,7 +241,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
defp apply_action(socket, :index, params) do
query = params["query"] || ""
order = parse_order(params["order"] || "scrobbled_at")
total_tracks = ScrobbleActivity.search_tracks_count(query)
total_tracks = ListeningStats.search_tracks_count(query)
track_list_params =
@default_tracks_list_params
@@ -267,8 +267,8 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
@impl true
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
track = ScrobbleActivity.get_track!(scrobbled_at_uts)
{:ok, _} = ScrobbleActivity.delete_track(track)
track = ListeningStats.get_track!(scrobbled_at_uts)
{:ok, _} = ListeningStats.delete_track(track)
{:noreply, stream_delete(socket, :tracks, %{track: track})}
end
@@ -294,7 +294,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
defp parse_order(_), do: :scrobbled_at
defp load_and_assign_tracks(socket, track_list_params) do
tracks = ScrobbleActivity.list_tracks(track_list_params)
tracks = ListeningStats.list_tracks(track_list_params)
tracks_empty? = tracks == []
socket
+276
View File
@@ -4,6 +4,7 @@ defmodule MusicLibrary.ListeningStatsTest do
import MusicLibrary.ArtistInfoFixtures
import MusicLibrary.ScrobbledTracksFixtures
alias LastFm.Track
alias MusicLibrary.Fixtures.Records, as: RecordsFixtures
alias MusicLibrary.ListeningStats
alias MusicLibrary.Records.Record
@@ -20,6 +21,271 @@ defmodule MusicLibrary.ListeningStatsTest do
end
end
describe "list_tracks/1" do
test "returns all tracks when no parameters provided" do
_track1 = track_fixture(%{title: "First Track"})
_track2 = track_fixture(%{title: "Second Track"})
tracks = list_tracks()
assert length(tracks) == 2
track_titles = Enum.map(tracks, & &1.title)
assert "First Track" in track_titles
assert "Second Track" in track_titles
end
test "returns tracks ordered by scrobbled_at_uts by default" do
_older_track =
track_fixture(%{
title: "Older Track",
scrobbled_at_uts: System.system_time(:second) - 3600
})
_newer_track =
track_fixture(%{
title: "Newer Track",
scrobbled_at_uts: System.system_time(:second)
})
tracks = list_tracks(%{order: :scrobbled_at})
assert length(tracks) == 2
# Should be ordered by scrobbled_at_uts descending (newest first)
assert List.first(tracks).title == "Newer Track"
assert List.last(tracks).title == "Older Track"
end
test "returns tracks ordered by title" do
track_fixture(%{title: "Zebra Track"})
track_fixture(%{title: "Alpha Track"})
tracks = list_tracks(%{order: :title})
assert length(tracks) == 2
assert List.first(tracks).title == "Alpha Track"
assert List.last(tracks).title == "Zebra Track"
end
test "returns tracks ordered by artist name" do
track_fixture(%{artist_name: "Zebra Artist", title: "Track 1"})
track_fixture(%{artist_name: "Alpha Artist", title: "Track 2"})
tracks = list_tracks(%{order: :artist})
assert length(tracks) == 2
assert List.first(tracks).artist.name == "Alpha Artist"
assert List.last(tracks).artist.name == "Zebra Artist"
end
test "returns tracks ordered by album title" do
track_fixture(%{album_title: "Zebra Album", title: "Track 1"})
track_fixture(%{album_title: "Alpha Album", title: "Track 2"})
tracks = list_tracks(%{order: :album})
assert length(tracks) == 2
assert List.first(tracks).album.title == "Alpha Album"
assert List.last(tracks).album.title == "Zebra Album"
end
test "filters tracks by search query matching track title" do
track_fixture(%{title: "Special Track"})
track_fixture(%{title: "Regular Track"})
tracks = list_tracks(%{query: "Special"})
assert length(tracks) == 1
assert List.first(tracks).title == "Special Track"
end
test "filters tracks by search query matching artist name" do
track_fixture(%{artist_name: "Special Artist", title: "Track 1"})
track_fixture(%{artist_name: "Regular Artist", title: "Track 2"})
tracks = list_tracks(%{query: "Special Artist"})
assert length(tracks) == 1
assert List.first(tracks).artist.name == "Special Artist"
end
test "filters tracks by search query matching album title" do
track_fixture(%{album_title: "Special Album", title: "Track 1"})
track_fixture(%{album_title: "Regular Album", title: "Track 2"})
tracks = list_tracks(%{query: "Special Album"})
assert length(tracks) == 1
assert List.first(tracks).album.title == "Special Album"
end
test "applies pagination correctly" do
create_test_tracks(5)
# Get first 2 tracks
tracks_page_1 = list_tracks(%{page: 1, page_size: 2})
assert length(tracks_page_1) == 2
# Get next 2 tracks
tracks_page_2 = list_tracks(%{page: 2, page_size: 2})
assert length(tracks_page_2) == 2
# Ensure they're different tracks
page_1_ids = Enum.map(tracks_page_1, & &1.scrobbled_at_uts)
page_2_ids = Enum.map(tracks_page_2, & &1.scrobbled_at_uts)
assert Enum.empty?(page_1_ids -- (page_1_ids -- page_2_ids))
end
test "returns empty list when query matches no tracks" do
track_fixture(%{title: "Test Track"})
tracks = list_tracks(%{query: "NonexistentTrack"})
assert tracks == []
end
end
describe "get_track!/1" do
test "returns the track with given scrobbled_at_uts as integer" do
track = track_fixture()
found_track = ListeningStats.get_track!(track.scrobbled_at_uts)
assert found_track.scrobbled_at_uts == track.scrobbled_at_uts
assert found_track.title == track.title
end
test "returns the track with given scrobbled_at_uts as string" do
track = track_fixture()
found_track = ListeningStats.get_track!(to_string(track.scrobbled_at_uts))
assert found_track.scrobbled_at_uts == track.scrobbled_at_uts
end
test "raises Ecto.NoResultsError when track does not exist" do
assert_raise Ecto.NoResultsError, fn ->
ListeningStats.get_track!(999_999_999)
end
end
test "raises Ecto.NoResultsError when given invalid string" do
assert_raise Ecto.NoResultsError, fn ->
ListeningStats.get_track!("invalid")
end
end
end
describe "update_track/2" do
test "updates the track with valid attributes" do
track = track_fixture(%{title: "Original Title"})
update_attrs = %{
title: "Updated Title",
artist: %{name: "Updated Artist"},
album: %{title: "Updated Album"}
}
assert {:ok, updated_track} = ListeningStats.update_track(track, update_attrs)
assert updated_track.title == "Updated Title"
assert updated_track.artist.name == "Updated Artist"
assert updated_track.album.title == "Updated Album"
end
test "returns error changeset with invalid attributes" do
track = track_fixture()
invalid_attrs = %{title: ""}
assert {:error, %Ecto.Changeset{}} = ListeningStats.update_track(track, invalid_attrs)
end
test "updates scrobbled_at_label" do
track = track_fixture(%{scrobbled_at_label: "01/01/2024 12:00:00"})
update_attrs = %{scrobbled_at_label: "02/02/2024 14:30:00"}
assert {:ok, updated_track} = ListeningStats.update_track(track, update_attrs)
assert updated_track.scrobbled_at_label == "02/02/2024 14:30:00"
end
test "updates cover_url" do
track = track_fixture(%{cover_url: "https://example.com/old.jpg"})
update_attrs = %{cover_url: "https://example.com/new.jpg"}
assert {:ok, updated_track} = ListeningStats.update_track(track, update_attrs)
assert updated_track.cover_url == "https://example.com/new.jpg"
end
end
describe "delete_track/1" do
test "deletes the track" do
track = track_fixture()
assert {:ok, %Track{}} = ListeningStats.delete_track(track)
assert_raise Ecto.NoResultsError, fn ->
ListeningStats.get_track!(track.scrobbled_at_uts)
end
end
test "returns error when track has already been deleted" do
track = track_fixture()
{:ok, _} = ListeningStats.delete_track(track)
# Attempt to delete again should fail
assert_raise Ecto.StaleEntryError, fn ->
ListeningStats.delete_track(track)
end
end
end
describe "search_tracks_count/1" do
test "returns total count when no query provided" do
create_test_tracks(3)
count = ListeningStats.search_tracks_count()
assert count == 3
end
test "returns filtered count when query provided" do
track_fixture(%{title: "Special Track"})
track_fixture(%{title: "Regular Track"})
track_fixture(%{title: "Another Track"})
count = ListeningStats.search_tracks_count("Special")
assert count == 1
end
test "returns zero when query matches no tracks" do
track_fixture(%{title: "Test Track"})
count = ListeningStats.search_tracks_count("Nonexistent")
assert count == 0
end
test "counts tracks matching artist name" do
track_fixture(%{artist_name: "Special Artist", title: "Track 1"})
track_fixture(%{artist_name: "Regular Artist", title: "Track 2"})
count = ListeningStats.search_tracks_count("Special")
assert count == 1
end
test "counts tracks matching album title" do
track_fixture(%{album_title: "Special Album", title: "Track 1"})
track_fixture(%{album_title: "Regular Album", title: "Track 2"})
count = ListeningStats.search_tracks_count("Special")
assert count == 1
end
end
describe "get_top_artists/1" do
test "counts tracks with missing artist_infos records" do
artist_info =
@@ -199,4 +465,14 @@ defmodule MusicLibrary.ListeningStatsTest do
assert thin_lizzy.play_count == 3
end
end
defp list_tracks do
ListeningStats.list_tracks()
|> Enum.map(fn r -> r.track end)
end
defp list_tracks(params) do
ListeningStats.list_tracks(params)
|> Enum.map(fn r -> r.track end)
end
end
+168
View File
@@ -1,6 +1,8 @@
defmodule MusicLibrary.MaintenanceTest do
use MusicLibrary.DataCase
import MusicLibrary.ScrobbledTracksFixtures
alias MusicLibrary.Maintenance
describe "vacuum/0" do
@@ -23,4 +25,170 @@ defmodule MusicLibrary.MaintenanceTest do
assert Maintenance.count_active_jobs("MusicLibrary.Worker.NonExistent") == 0
end
end
describe "count_tracks_missing_artist_musicbrainz_id/0" do
test "returns zero when no tracks exist" do
count = Maintenance.count_tracks_missing_artist_musicbrainz_id()
assert count == 0
end
test "returns count of tracks with empty artist musicbrainz_id" do
track_fixture(%{artist_musicbrainz_id: ""})
track_fixture(%{artist_musicbrainz_id: ""})
track_fixture(%{artist_musicbrainz_id: "valid-id"})
count = Maintenance.count_tracks_missing_artist_musicbrainz_id()
assert count == 2
end
end
describe "count_tracks_missing_album_musicbrainz_id/0" do
test "returns zero when no tracks exist" do
count = Maintenance.count_tracks_missing_album_musicbrainz_id()
assert count == 0
end
test "returns count of tracks with empty album musicbrainz_id" do
track_fixture(%{album_musicbrainz_id: ""})
track_fixture(%{album_musicbrainz_id: ""})
track_fixture(%{album_musicbrainz_id: "valid-id"})
count = Maintenance.count_tracks_missing_album_musicbrainz_id()
assert count == 2
end
end
describe "get_artists_missing_musicbrainz_id/1" do
test "returns empty list when no tracks exist" do
result = Maintenance.get_artists_missing_musicbrainz_id()
assert result == []
end
test "returns artists grouped by name with track counts" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
result = Maintenance.get_artists_missing_musicbrainz_id()
assert length(result) == 2
artist_a = Enum.find(result, fn r -> r.artist_name == "Artist A" end)
artist_b = Enum.find(result, fn r -> r.artist_name == "Artist B" end)
assert artist_a.track_count == 2
assert artist_b.track_count == 1
end
test "orders artists by track count descending" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
result = Maintenance.get_artists_missing_musicbrainz_id()
assert length(result) == 2
assert List.first(result).artist_name == "Artist B"
assert List.first(result).track_count == 3
end
test "limits results when limit option provided" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist C", artist_musicbrainz_id: ""})
result = Maintenance.get_artists_missing_musicbrainz_id(limit: 2)
assert length(result) == 2
end
test "excludes artists with valid musicbrainz_id" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: "valid-id"})
result = Maintenance.get_artists_missing_musicbrainz_id()
assert length(result) == 1
assert List.first(result).artist_name == "Artist A"
end
end
describe "get_albums_missing_musicbrainz_id/1" do
test "returns empty list when no tracks exist" do
result = Maintenance.get_albums_missing_musicbrainz_id()
assert result == []
end
test "returns albums grouped by title and artist with track counts" do
track_fixture(%{
album_title: "Album A",
artist_name: "Artist X",
album_musicbrainz_id: ""
})
track_fixture(%{
album_title: "Album A",
artist_name: "Artist X",
album_musicbrainz_id: ""
})
track_fixture(%{
album_title: "Album B",
artist_name: "Artist Y",
album_musicbrainz_id: ""
})
result = Maintenance.get_albums_missing_musicbrainz_id()
assert length(result) == 2
album_a = Enum.find(result, fn r -> r.album_title == "Album A" end)
album_b = Enum.find(result, fn r -> r.album_title == "Album B" end)
assert album_a.track_count == 2
assert album_a.artist_name == "Artist X"
assert album_b.track_count == 1
assert album_b.artist_name == "Artist Y"
end
test "orders albums by track count descending" do
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
result = Maintenance.get_albums_missing_musicbrainz_id()
assert length(result) == 2
assert List.first(result).album_title == "Album B"
assert List.first(result).track_count == 3
end
test "limits results when limit option provided" do
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album C", album_musicbrainz_id: ""})
result = Maintenance.get_albums_missing_musicbrainz_id(limit: 2)
assert length(result) == 2
end
test "excludes albums with valid musicbrainz_id" do
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: "valid-id"})
result = Maintenance.get_albums_missing_musicbrainz_id()
assert length(result) == 1
assert List.first(result).album_title == "Album A"
end
end
end
@@ -1,449 +0,0 @@
defmodule MusicLibrary.ScrobbleActivityTest do
use MusicLibrary.DataCase
import MusicLibrary.ScrobbledTracksFixtures
alias LastFm.Track
alias MusicLibrary.ScrobbleActivity
describe "list_tracks/1" do
test "returns all tracks when no parameters provided" do
_track1 = track_fixture(%{title: "First Track"})
_track2 = track_fixture(%{title: "Second Track"})
tracks = list_tracks()
assert length(tracks) == 2
track_titles = Enum.map(tracks, & &1.title)
assert "First Track" in track_titles
assert "Second Track" in track_titles
end
test "returns tracks ordered by scrobbled_at_uts by default" do
_older_track =
track_fixture(%{
title: "Older Track",
scrobbled_at_uts: System.system_time(:second) - 3600
})
_newer_track =
track_fixture(%{
title: "Newer Track",
scrobbled_at_uts: System.system_time(:second)
})
tracks = list_tracks(%{order: :scrobbled_at})
assert length(tracks) == 2
# Should be ordered by scrobbled_at_uts descending (newest first)
assert List.first(tracks).title == "Newer Track"
assert List.last(tracks).title == "Older Track"
end
test "returns tracks ordered by title" do
track_fixture(%{title: "Zebra Track"})
track_fixture(%{title: "Alpha Track"})
tracks = list_tracks(%{order: :title})
assert length(tracks) == 2
assert List.first(tracks).title == "Alpha Track"
assert List.last(tracks).title == "Zebra Track"
end
test "returns tracks ordered by artist name" do
track_fixture(%{artist_name: "Zebra Artist", title: "Track 1"})
track_fixture(%{artist_name: "Alpha Artist", title: "Track 2"})
tracks = list_tracks(%{order: :artist})
assert length(tracks) == 2
assert List.first(tracks).artist.name == "Alpha Artist"
assert List.last(tracks).artist.name == "Zebra Artist"
end
test "returns tracks ordered by album title" do
track_fixture(%{album_title: "Zebra Album", title: "Track 1"})
track_fixture(%{album_title: "Alpha Album", title: "Track 2"})
tracks = list_tracks(%{order: :album})
assert length(tracks) == 2
assert List.first(tracks).album.title == "Alpha Album"
assert List.last(tracks).album.title == "Zebra Album"
end
test "filters tracks by search query matching track title" do
track_fixture(%{title: "Special Track"})
track_fixture(%{title: "Regular Track"})
tracks = list_tracks(%{query: "Special"})
assert length(tracks) == 1
assert List.first(tracks).title == "Special Track"
end
test "filters tracks by search query matching artist name" do
track_fixture(%{artist_name: "Special Artist", title: "Track 1"})
track_fixture(%{artist_name: "Regular Artist", title: "Track 2"})
tracks = list_tracks(%{query: "Special Artist"})
assert length(tracks) == 1
assert List.first(tracks).artist.name == "Special Artist"
end
test "filters tracks by search query matching album title" do
track_fixture(%{album_title: "Special Album", title: "Track 1"})
track_fixture(%{album_title: "Regular Album", title: "Track 2"})
tracks = list_tracks(%{query: "Special Album"})
assert length(tracks) == 1
assert List.first(tracks).album.title == "Special Album"
end
test "applies pagination correctly" do
create_test_tracks(5)
# Get first 2 tracks
tracks_page_1 = list_tracks(%{page: 1, page_size: 2})
assert length(tracks_page_1) == 2
# Get next 2 tracks
tracks_page_2 = list_tracks(%{page: 2, page_size: 2})
assert length(tracks_page_2) == 2
# Ensure they're different tracks
page_1_ids = Enum.map(tracks_page_1, & &1.scrobbled_at_uts)
page_2_ids = Enum.map(tracks_page_2, & &1.scrobbled_at_uts)
assert Enum.empty?(page_1_ids -- (page_1_ids -- page_2_ids))
end
test "returns empty list when query matches no tracks" do
track_fixture(%{title: "Test Track"})
tracks = list_tracks(%{query: "NonexistentTrack"})
assert tracks == []
end
end
describe "get_track!/1" do
test "returns the track with given scrobbled_at_uts as integer" do
track = track_fixture()
found_track = ScrobbleActivity.get_track!(track.scrobbled_at_uts)
assert found_track.scrobbled_at_uts == track.scrobbled_at_uts
assert found_track.title == track.title
end
test "returns the track with given scrobbled_at_uts as string" do
track = track_fixture()
found_track = ScrobbleActivity.get_track!(to_string(track.scrobbled_at_uts))
assert found_track.scrobbled_at_uts == track.scrobbled_at_uts
end
test "raises Ecto.NoResultsError when track does not exist" do
assert_raise Ecto.NoResultsError, fn ->
ScrobbleActivity.get_track!(999_999_999)
end
end
test "raises Ecto.NoResultsError when given invalid string" do
assert_raise Ecto.NoResultsError, fn ->
ScrobbleActivity.get_track!("invalid")
end
end
end
describe "update_track/2" do
test "updates the track with valid attributes" do
track = track_fixture(%{title: "Original Title"})
update_attrs = %{
title: "Updated Title",
artist: %{name: "Updated Artist"},
album: %{title: "Updated Album"}
}
assert {:ok, updated_track} = ScrobbleActivity.update_track(track, update_attrs)
assert updated_track.title == "Updated Title"
assert updated_track.artist.name == "Updated Artist"
assert updated_track.album.title == "Updated Album"
end
test "returns error changeset with invalid attributes" do
track = track_fixture()
invalid_attrs = %{title: ""}
assert {:error, %Ecto.Changeset{}} = ScrobbleActivity.update_track(track, invalid_attrs)
end
test "updates scrobbled_at_label" do
track = track_fixture(%{scrobbled_at_label: "01/01/2024 12:00:00"})
update_attrs = %{scrobbled_at_label: "02/02/2024 14:30:00"}
assert {:ok, updated_track} = ScrobbleActivity.update_track(track, update_attrs)
assert updated_track.scrobbled_at_label == "02/02/2024 14:30:00"
end
test "updates cover_url" do
track = track_fixture(%{cover_url: "https://example.com/old.jpg"})
update_attrs = %{cover_url: "https://example.com/new.jpg"}
assert {:ok, updated_track} = ScrobbleActivity.update_track(track, update_attrs)
assert updated_track.cover_url == "https://example.com/new.jpg"
end
end
describe "delete_track/1" do
test "deletes the track" do
track = track_fixture()
assert {:ok, %Track{}} = ScrobbleActivity.delete_track(track)
assert_raise Ecto.NoResultsError, fn ->
ScrobbleActivity.get_track!(track.scrobbled_at_uts)
end
end
test "returns error when track has already been deleted" do
track = track_fixture()
{:ok, _} = ScrobbleActivity.delete_track(track)
# Attempt to delete again should fail
assert_raise Ecto.StaleEntryError, fn ->
ScrobbleActivity.delete_track(track)
end
end
end
describe "search_tracks_count/1" do
test "returns total count when no query provided" do
create_test_tracks(3)
count = ScrobbleActivity.search_tracks_count()
assert count == 3
end
test "returns filtered count when query provided" do
track_fixture(%{title: "Special Track"})
track_fixture(%{title: "Regular Track"})
track_fixture(%{title: "Another Track"})
count = ScrobbleActivity.search_tracks_count("Special")
assert count == 1
end
test "returns zero when query matches no tracks" do
track_fixture(%{title: "Test Track"})
count = ScrobbleActivity.search_tracks_count("Nonexistent")
assert count == 0
end
test "counts tracks matching artist name" do
track_fixture(%{artist_name: "Special Artist", title: "Track 1"})
track_fixture(%{artist_name: "Regular Artist", title: "Track 2"})
count = ScrobbleActivity.search_tracks_count("Special")
assert count == 1
end
test "counts tracks matching album title" do
track_fixture(%{album_title: "Special Album", title: "Track 1"})
track_fixture(%{album_title: "Regular Album", title: "Track 2"})
count = ScrobbleActivity.search_tracks_count("Special")
assert count == 1
end
end
defp list_tracks do
ScrobbleActivity.list_tracks()
|> Enum.map(fn r -> r.track end)
end
describe "count_tracks_missing_artist_musicbrainz_id/0" do
test "returns zero when no tracks exist" do
count = ScrobbleActivity.count_tracks_missing_artist_musicbrainz_id()
assert count == 0
end
test "returns count of tracks with empty artist musicbrainz_id" do
track_fixture(%{artist_musicbrainz_id: ""})
track_fixture(%{artist_musicbrainz_id: ""})
track_fixture(%{artist_musicbrainz_id: "valid-id"})
count = ScrobbleActivity.count_tracks_missing_artist_musicbrainz_id()
assert count == 2
end
end
describe "count_tracks_missing_album_musicbrainz_id/0" do
test "returns zero when no tracks exist" do
count = ScrobbleActivity.count_tracks_missing_album_musicbrainz_id()
assert count == 0
end
test "returns count of tracks with empty album musicbrainz_id" do
track_fixture(%{album_musicbrainz_id: ""})
track_fixture(%{album_musicbrainz_id: ""})
track_fixture(%{album_musicbrainz_id: "valid-id"})
count = ScrobbleActivity.count_tracks_missing_album_musicbrainz_id()
assert count == 2
end
end
describe "get_artists_missing_musicbrainz_id/1" do
test "returns empty list when no tracks exist" do
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
assert result == []
end
test "returns artists grouped by name with track counts" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
assert length(result) == 2
artist_a = Enum.find(result, fn r -> r.artist_name == "Artist A" end)
artist_b = Enum.find(result, fn r -> r.artist_name == "Artist B" end)
assert artist_a.track_count == 2
assert artist_b.track_count == 1
end
test "orders artists by track count descending" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
assert length(result) == 2
assert List.first(result).artist_name == "Artist B"
assert List.first(result).track_count == 3
end
test "limits results when limit option provided" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist C", artist_musicbrainz_id: ""})
result = ScrobbleActivity.get_artists_missing_musicbrainz_id(limit: 2)
assert length(result) == 2
end
test "excludes artists with valid musicbrainz_id" do
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: "valid-id"})
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
assert length(result) == 1
assert List.first(result).artist_name == "Artist A"
end
end
describe "get_albums_missing_musicbrainz_id/1" do
test "returns empty list when no tracks exist" do
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
assert result == []
end
test "returns albums grouped by title and artist with track counts" do
track_fixture(%{
album_title: "Album A",
artist_name: "Artist X",
album_musicbrainz_id: ""
})
track_fixture(%{
album_title: "Album A",
artist_name: "Artist X",
album_musicbrainz_id: ""
})
track_fixture(%{
album_title: "Album B",
artist_name: "Artist Y",
album_musicbrainz_id: ""
})
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
assert length(result) == 2
album_a = Enum.find(result, fn r -> r.album_title == "Album A" end)
album_b = Enum.find(result, fn r -> r.album_title == "Album B" end)
assert album_a.track_count == 2
assert album_a.artist_name == "Artist X"
assert album_b.track_count == 1
assert album_b.artist_name == "Artist Y"
end
test "orders albums by track count descending" do
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
assert length(result) == 2
assert List.first(result).album_title == "Album B"
assert List.first(result).track_count == 3
end
test "limits results when limit option provided" do
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album C", album_musicbrainz_id: ""})
result = ScrobbleActivity.get_albums_missing_musicbrainz_id(limit: 2)
assert length(result) == 2
end
test "excludes albums with valid musicbrainz_id" do
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
track_fixture(%{album_title: "Album B", album_musicbrainz_id: "valid-id"})
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
assert length(result) == 1
assert List.first(result).album_title == "Album A"
end
end
defp list_tracks(params) do
ScrobbleActivity.list_tracks(params)
|> Enum.map(fn r -> r.track end)
end
end
@@ -4,7 +4,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
import MusicLibrary.ScrobbledTracksFixtures
import Phoenix.LiveViewTest, only: [render_submit: 1, render_change: 1, form: 3]
alias MusicLibrary.ScrobbleActivity
alias MusicLibrary.ListeningStats
# Test data
@invalid_track_attrs %{title: "", artist: %{name: ""}, album: %{title: ""}}
@@ -38,7 +38,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
test "shows empty state when no tracks", %{conn: conn} do
# Delete the created track
ScrobbleActivity.delete_track(track_fixture())
ListeningStats.delete_track(track_fixture())
conn
|> visit(~p"/scrobbled-tracks")