diff --git a/bench/listening_stats.exs b/bench/listening_stats.exs new file mode 100644 index 00000000..5955dd2a --- /dev/null +++ b/bench/listening_stats.exs @@ -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 +) diff --git a/bench/scrobble_activity.exs b/bench/scrobble_activity.exs deleted file mode 100644 index 0c432cdf..00000000 --- a/bench/scrobble_activity.exs +++ /dev/null @@ -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 -) diff --git a/docs/architecture.md b/docs/architecture.md index f23f6c81..c10d5493 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | --- diff --git a/lib/mix/tasks/scrobble/audit.ex b/lib/mix/tasks/scrobble/audit.ex index 67d55587..c52a27d1 100644 --- a/lib/mix/tasks/scrobble/audit.ex +++ b/lib/mix/tasks/scrobble/audit.ex @@ -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) diff --git a/lib/music_library/listening_stats.ex b/lib/music_library/listening_stats.ex index 1f38e5f8..5df682e1 100644 --- a/lib/music_library/listening_stats.ex +++ b/lib/music_library/listening_stats.ex @@ -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()), diff --git a/lib/music_library/maintenance.ex b/lib/music_library/maintenance.ex index 1ff71578..83121d1e 100644 --- a/lib/music_library/maintenance.ex +++ b/lib/music_library/maintenance.ex @@ -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 diff --git a/lib/music_library/scrobble_activity.ex b/lib/music_library/scrobble_activity.ex index aec67132..0e2575e8 100644 --- a/lib/music_library/scrobble_activity.ex +++ b/lib/music_library/scrobble_activity.ex @@ -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 diff --git a/lib/music_library_web/live/scrobbled_tracks_live/form.ex b/lib/music_library_web/live/scrobbled_tracks_live/form.ex index b9818b93..5c1b36ea 100644 --- a/lib/music_library_web/live/scrobbled_tracks_live/form.ex +++ b/lib/music_library_web/live/scrobbled_tracks_live/form.ex @@ -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}) diff --git a/lib/music_library_web/live/scrobbled_tracks_live/index.ex b/lib/music_library_web/live/scrobbled_tracks_live/index.ex index f5755350..0ab6d1ee 100644 --- a/lib/music_library_web/live/scrobbled_tracks_live/index.ex +++ b/lib/music_library_web/live/scrobbled_tracks_live/index.ex @@ -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 diff --git a/test/music_library/listening_stats_test.exs b/test/music_library/listening_stats_test.exs index af27bcf0..b0ac43af 100644 --- a/test/music_library/listening_stats_test.exs +++ b/test/music_library/listening_stats_test.exs @@ -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 diff --git a/test/music_library/maintenance_test.exs b/test/music_library/maintenance_test.exs index 754d6e61..d5196d42 100644 --- a/test/music_library/maintenance_test.exs +++ b/test/music_library/maintenance_test.exs @@ -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 diff --git a/test/music_library/scrobble_activity_test.exs b/test/music_library/scrobble_activity_test.exs deleted file mode 100644 index b4ed9e76..00000000 --- a/test/music_library/scrobble_activity_test.exs +++ /dev/null @@ -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 diff --git a/test/music_library_web/live/scrobbled_tracks_live/index_test.exs b/test/music_library_web/live/scrobbled_tracks_live/index_test.exs index 28db445e..33e31e76 100644 --- a/test/music_library_web/live/scrobbled_tracks_live/index_test.exs +++ b/test/music_library_web/live/scrobbled_tracks_live/index_test.exs @@ -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")