defmodule Mix.Tasks.Scrobble.Audit do @shortdoc "Audit scrobbled tracks for data quality issues" @moduledoc """ Audit scrobbled tracks to identify data quality issues such as: - Missing or empty MusicBrainz IDs for artists - Missing or empty MusicBrainz IDs for albums - Tracks that could benefit from scrobble rules ## Usage # Audit all tracks mix scrobble.audit # Audit and output detailed report mix scrobble.audit --verbose # Audit and output as JSON mix scrobble.audit --format json # Audit only tracks with missing artist IDs mix scrobble.audit --type artist # Audit only tracks with missing album IDs mix scrobble.audit --type album ## Output The task generates a report showing: - Total scrobbled tracks - Tracks with missing artist MusicBrainz IDs (grouped by artist name) - Tracks with missing album MusicBrainz IDs (grouped by album title + artist) - Suggested scrobble rules to fix the issues """ use Mix.Task import Ecto.Query alias LastFm.Track alias MusicLibrary.{ListeningStats, Maintenance, Repo} @impl Mix.Task def run(args) do Mix.Task.run("app.start") opts = parse_args(args) audit_type = Keyword.get(opts, :type, :all) format = Keyword.get(opts, :format, :text) verbose = Keyword.get(opts, :verbose, false) report = generate_audit_report(audit_type, verbose) case format do :json -> output_json(report) :text -> output_text(report, verbose) end end defp parse_args(args) do {opts, _, _} = OptionParser.parse(args, switches: [type: :string, format: :string, verbose: :boolean], aliases: [t: :type, f: :format, v: :verbose] ) # Validate and convert type option type = case Keyword.get(opts, :type) do nil -> :all "artist" -> :artist "album" -> :album other -> Mix.raise("Invalid type: #{other}. Valid types are: artist, album") end # Validate and convert format option format = case Keyword.get(opts, :format) do nil -> :text "json" -> :json "text" -> :text other -> Mix.raise("Invalid format: #{other}. Valid formats are: json, text") end verbose = Keyword.get(opts, :verbose, false) [type: type, format: format, verbose: verbose] end defp generate_audit_report(:all, verbose) do %{ total_tracks: ListeningStats.scrobble_count(), artist_issues: audit_artist_musicbrainz_ids(verbose), album_issues: audit_album_musicbrainz_ids(verbose) } end defp generate_audit_report(:artist, verbose) do %{ total_tracks: ListeningStats.scrobble_count(), artist_issues: audit_artist_musicbrainz_ids(verbose) } end defp generate_audit_report(:album, verbose) do %{ total_tracks: ListeningStats.scrobble_count(), album_issues: audit_album_musicbrainz_ids(verbose) } end defp audit_artist_musicbrainz_ids(verbose) do results = Maintenance.get_artists_missing_musicbrainz_id() total_tracks_affected = Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end) report = %{ total_artists: length(results), total_tracks_affected: total_tracks_affected, artists: Enum.map(results, fn %{artist_name: name, track_count: count} -> %{name: name, track_count: count} end) } if verbose do Map.put(report, :sample_tracks, get_sample_tracks_by_artist(Enum.take(results, 5))) else report end end defp audit_album_musicbrainz_ids(verbose) do results = Maintenance.get_albums_missing_musicbrainz_id() total_tracks_affected = Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end) report = %{ total_albums: length(results), total_tracks_affected: total_tracks_affected, albums: Enum.map(results, fn %{album_title: title, artist_name: artist, track_count: count} -> %{title: title, artist: artist, track_count: count} end) } if verbose do Map.put(report, :sample_tracks, get_sample_tracks_by_album(Enum.take(results, 5))) else report end end defp get_sample_tracks_by_artist(artists) do artist_names = Enum.map(artists, & &1.artist_name) query = from t in Track, where: fragment("json_extract(?, '$.name')", t.artist) in ^artist_names and (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), title: t.title, album: fragment("json_extract(?, '$.title')", t.album), scrobbled_at: t.scrobbled_at_label } tracks_by_artist = query |> Repo.all() |> Enum.group_by(& &1.artist_name, fn track -> Map.delete(track, :artist_name) end) Enum.map(artists, fn %{artist_name: artist_name} -> %{ artist: artist_name, sample_tracks: tracks_by_artist |> Map.get(artist_name, []) |> Enum.take(3) } end) end defp get_sample_tracks_by_album(albums) do album_keys = Enum.map(albums, fn %{album_title: album_title, artist_name: artist_name} -> {album_title, artist_name} end) case album_keys do [] -> [] _ -> base_query = from t in Track, where: fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album), select: %{ title: t.title, scrobbled_at: t.scrobbled_at_label, album_title: fragment("json_extract(?, '$.title')", t.album), artist_name: fragment("json_extract(?, '$.name')", t.artist) } album_match_dynamic = Enum.reduce(album_keys, false, fn {album_title, artist_name}, dynamic_acc -> pair_dynamic = dynamic( [t], fragment("json_extract(?, '$.title') = ?", t.album, ^album_title) and fragment("json_extract(?, '$.name') = ?", t.artist, ^artist_name) ) dynamic([t], ^dynamic_acc or ^pair_dynamic) end) query = from t in base_query, where: ^album_match_dynamic tracks = Repo.all(query) tracks_by_key = Enum.group_by(tracks, fn %{album_title: album_title, artist_name: artist_name} -> {album_title, artist_name} end) Enum.map(albums, fn %{album_title: album_title, artist_name: artist_name} -> key = {album_title, artist_name} sample_tracks = tracks_by_key |> Map.get(key, []) |> Enum.take(3) |> Enum.map(fn %{title: title, scrobbled_at: scrobbled_at} -> %{title: title, scrobbled_at: scrobbled_at} end) %{ album: album_title, artist: artist_name, sample_tracks: sample_tracks } end) end end defp output_json(report) do report |> Jason.encode!(pretty: true) |> Mix.Shell.IO.info() end defp output_text(report, verbose) do Mix.Shell.IO.info("\n=== Scrobbled Tracks Data Quality Audit ===\n") Mix.Shell.IO.info("Total scrobbled tracks: #{report.total_tracks}\n") if Map.has_key?(report, :artist_issues) do output_artist_issues(report.artist_issues, verbose) end if Map.has_key?(report, :album_issues) do output_album_issues(report.album_issues, verbose) end output_summary(report) end defp output_artist_issues(artist_issues, verbose) do Mix.Shell.IO.info("--- Artists with Missing MusicBrainz IDs ---") Mix.Shell.IO.info("Unique artists: #{artist_issues.total_artists}") Mix.Shell.IO.info("Affected tracks: #{artist_issues.total_tracks_affected}\n") if artist_issues.total_artists > 0 do Mix.Shell.IO.info("Top artists by track count:") artist_issues.artists |> Enum.take(10) |> Enum.each(fn %{name: name, track_count: count} -> Mix.Shell.IO.info(" • #{name} (#{count} tracks)") end) Mix.Shell.IO.info("") if verbose and Map.has_key?(artist_issues, :sample_tracks) do Mix.Shell.IO.info("Sample tracks:") Enum.each(artist_issues.sample_tracks, fn %{artist: artist, sample_tracks: tracks} -> Mix.Shell.IO.info("\n Artist: #{artist}") # credo:disable-for-next-line Credo.Check.Refactor.Nesting Enum.each(tracks, fn track -> Mix.Shell.IO.info(" - #{track.title} (from #{track.album})") end) end) Mix.Shell.IO.info("") end end end defp output_album_issues(album_issues, verbose) do Mix.Shell.IO.info("--- Albums with Missing MusicBrainz IDs ---") Mix.Shell.IO.info("Unique albums: #{album_issues.total_albums}") Mix.Shell.IO.info("Affected tracks: #{album_issues.total_tracks_affected}\n") if album_issues.total_albums > 0 do Mix.Shell.IO.info("Top albums by track count:") album_issues.albums |> Enum.take(10) |> Enum.each(fn %{title: title, artist: artist, track_count: count} -> Mix.Shell.IO.info(" • #{title} by #{artist} (#{count} tracks)") end) Mix.Shell.IO.info("") if verbose and Map.has_key?(album_issues, :sample_tracks) do Mix.Shell.IO.info("Sample tracks:") Enum.each(album_issues.sample_tracks, fn %{ album: album, artist: artist, sample_tracks: tracks } -> Mix.Shell.IO.info("\n Album: #{album} by #{artist}") # credo:disable-for-next-line Credo.Check.Refactor.Nesting Enum.each(tracks, fn track -> Mix.Shell.IO.info(" - #{track.title}") end) end) Mix.Shell.IO.info("") end end end defp output_summary(report) do total_issues = (Map.get(report, :artist_issues, %{}) |> Map.get(:total_tracks_affected, 0)) + (Map.get(report, :album_issues, %{}) |> Map.get(:total_tracks_affected, 0)) Mix.Shell.IO.info("--- Summary ---") Mix.Shell.IO.info("Total tracks needing enrichment: #{total_issues}") Mix.Shell.IO.info(""" To fix these issues: 1. Create scrobble rules for artists with missing MusicBrainz IDs: - Navigate to the Scrobble Rules page in the app - Add artist rules with the correct MusicBrainz ID 2. Create scrobble rules for albums with missing MusicBrainz IDs: - Add album rules with the correct MusicBrainz ID 3. Apply the rules to update existing tracks: - Use the "Apply Rules" button in the Scrobble Rules page - Or run: MusicLibrary.ScrobbleRules.apply_all_rules() """) end end