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
+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