From 233e3091b6fa3698171f1daf1d668b895fd55b6a Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Wed, 13 Aug 2025 13:57:16 +0300 Subject: [PATCH] Support applying scrobble rules only on a given set of tracks --- lib/last_fm/feed.ex | 9 +- lib/music_library/scrobble_rules.ex | 172 +++++++++++++++++- .../worker/apply_scrobble_rules.ex | 37 +--- 3 files changed, 177 insertions(+), 41 deletions(-) diff --git a/lib/last_fm/feed.ex b/lib/last_fm/feed.ex index 3953fa98..28825f69 100644 --- a/lib/last_fm/feed.ex +++ b/lib/last_fm/feed.ex @@ -28,13 +28,16 @@ defmodule LastFm.Feed do |> Enum.map(fn t -> Map.take(t, @insertable_fields) end) |> Enum.map(&Map.to_list/1) - {count, nil} = + {count, tracks} = MusicLibrary.Repo.insert_all(LastFm.Track, track_params, on_conflict: :nothing, - conflict_target: [:scrobbled_at_uts, :title] + conflict_target: [:scrobbled_at_uts, :title], + returning: true ) - MusicLibrary.ScrobbleRules.apply_all_rules() + tracks + |> MusicLibrary.ScrobbleRules.apply_all_rules() + |> MusicLibrary.ScrobbleRules.log_apply_results() Phoenix.PubSub.broadcast(LastFm.PubSub, "feed:update", %{track_count: count}) diff --git a/lib/music_library/scrobble_rules.ex b/lib/music_library/scrobble_rules.ex index 91e5d443..c5136d40 100644 --- a/lib/music_library/scrobble_rules.ex +++ b/lib/music_library/scrobble_rules.ex @@ -5,6 +5,8 @@ defmodule MusicLibrary.ScrobbleRules do import Ecto.Query, warn: false + require Logger + alias LastFm.Track alias MusicLibrary.Repo alias MusicLibrary.ScrobbleRules.ScrobbleRule @@ -174,6 +176,44 @@ defmodule MusicLibrary.ScrobbleRules do end end + @doc """ + Applies an album rule to a specific set of scrobbled tracks. + + ## Examples + + iex> apply_album_rule(rule, tracks) + {:ok, 3} + + iex> apply_album_rule(rule, tracks) + {:error, :invalid_rule_type} + + """ + def apply_album_rule(%ScrobbleRule{type: :album} = rule, tracks) do + track_scrobbled_at_uts = Enum.map(tracks, & &1.scrobbled_at_uts) + + update_query = + from(t in Track, + where: + fragment("json_extract(?, '$.title') = ?", t.album, ^rule.match_value) and + t.scrobbled_at_uts in ^track_scrobbled_at_uts, + update: [ + set: [ + album: + fragment( + "json_set(?, '$.musicbrainz_id', ?)", + t.album, + ^rule.target_musicbrainz_id + ) + ] + ] + ) + + case Repo.update_all(update_query, []) do + {count, _} -> {:ok, count} + error -> {:error, error} + end + end + @doc """ Applies an artist rule to all matching scrobbled tracks. @@ -208,6 +248,44 @@ defmodule MusicLibrary.ScrobbleRules do end end + @doc """ + Applies an artist rule to a specific set of scrobbled tracks. + + ## Examples + + iex> apply_artist_rule(rule, tracks) + {:ok, 7} + + iex> apply_artist_rule(rule, tracks) + {:error, :invalid_rule_type} + + """ + def apply_artist_rule(%ScrobbleRule{type: :artist} = rule, tracks) do + track_scrobbled_at_uts = Enum.map(tracks, & &1.scrobbled_at_uts) + + update_query = + from(t in Track, + where: + fragment("json_extract(?, '$.name') = ?", t.artist, ^rule.match_value) and + t.scrobbled_at_uts in ^track_scrobbled_at_uts, + update: [ + set: [ + artist: + fragment( + "json_set(?, '$.musicbrainz_id', ?)", + t.artist, + ^rule.target_musicbrainz_id + ) + ] + ] + ) + + case Repo.update_all(update_query, []) do + {count, _} -> {:ok, count} + error -> {:error, error} + end + end + @doc """ Applies a single rule based on its type. @@ -228,6 +306,26 @@ defmodule MusicLibrary.ScrobbleRules do apply_artist_rule(rule) end + @doc """ + Applies a single rule to a specific set of tracks based on its type. + + ## Examples + + iex> apply_rule(rule, tracks) + {:ok, 3} + + iex> apply_rule(rule, tracks) + {:error, "Invalid rule type"} + + """ + def apply_rule(%ScrobbleRule{type: :album} = rule, tracks) do + apply_album_rule(rule, tracks) + end + + def apply_rule(%ScrobbleRule{type: :artist} = rule, tracks) do + apply_artist_rule(rule, tracks) + end + @doc """ Applies all enabled rules. @@ -238,9 +336,8 @@ defmodule MusicLibrary.ScrobbleRules do """ def apply_all_rules do - rules = list_enabled_rules() - - Enum.map(rules, fn rule -> + list_enabled_rules() + |> Enum.map(fn rule -> case apply_rule(rule) do {:ok, count} -> {:ok, {rule.type, rule.match_value, count}} {:error, reason} -> {:error, {rule.type, rule.match_value, reason}} @@ -248,6 +345,32 @@ defmodule MusicLibrary.ScrobbleRules do end) end + @doc """ + Applies all enabled rules to a specific set of tracks. + + ## Examples + + iex> apply_all_rules(tracks) + [{:ok, {:album, "Some Album", 5}}, {:error, {:artist, "Some Artist", "reason"}}] + + """ + def apply_all_rules([]) do + list_enabled_rules() + |> Enum.map(fn rule -> + {:ok, {rule.type, rule.match_value, 0}} + end) + end + + def apply_all_rules(tracks) do + list_enabled_rules() + |> Enum.map(fn rule -> + case apply_rule(rule, tracks) do + {:ok, count} -> {:ok, {rule.type, rule.match_value, count}} + {:error, reason} -> {:error, {rule.type, rule.match_value, reason}} + end + end) + end + @doc """ Counts how many tracks would be affected by an album rule. @@ -302,4 +425,47 @@ defmodule MusicLibrary.ScrobbleRules do def count_rule_matches(%ScrobbleRule{type: :artist} = rule) do count_artist_matches(rule) end + + @doc """ + Logs the results of applying scrobble rules. + + Takes a list of results from rule application and logs summary statistics + and any errors that occurred. + + ## Examples + + iex> log_apply_results([{:ok, {:album, "Album", 5}}, {:error, {:artist, "Artist", "reason"}}]) + :ok + + """ + def log_apply_results(results) do + {applied, errors} = + Enum.split_with(results, fn + {:ok, _} -> true + {:error, _} -> false + end) + + total_applied = length(applied) + total_errors = length(errors) + + total_tracks_updated = + applied + |> Enum.map(fn {:ok, {_, _, count}} -> count end) + |> Enum.sum() + + Logger.info(fn -> + "Scrobble rules application completed: " <> + "applied #{total_applied} rules, " <> + "#{total_errors} errors, " <> + "#{total_tracks_updated} tracks updated" + end) + + Enum.each(errors, fn {:error, {type, match_value, reason}} -> + Logger.error(fn -> + "failed to apply #{type} rule " <> + "with match #{match_value} " <> + "with reason #{inspect(reason)}" + end) + end) + end end diff --git a/lib/music_library/worker/apply_scrobble_rules.ex b/lib/music_library/worker/apply_scrobble_rules.ex index 8e5cb2d3..26932ee6 100644 --- a/lib/music_library/worker/apply_scrobble_rules.ex +++ b/lib/music_library/worker/apply_scrobble_rules.ex @@ -10,42 +10,9 @@ defmodule MusicLibrary.Worker.ApplyScrobbleRules do alias MusicLibrary.ScrobbleRules - require Logger - @impl Oban.Worker def perform(%Oban.Job{args: _}) do - results = ScrobbleRules.apply_all_rules() - log_results(results) - end - - defp log_results(results) do - {applied, errors} = - Enum.split_with(results, fn - {:ok, _} -> true - {:error, _} -> false - end) - - total_applied = length(applied) - total_errors = length(errors) - - total_tracks_updated = - applied - |> Enum.map(fn {:ok, {_, _, count}} -> count end) - |> Enum.sum() - - Logger.info(fn -> - "Scrobble rules application completed: " <> - "applied #{total_applied} rules, " <> - "#{total_errors} errors, " <> - "#{total_tracks_updated} tracks updated" - end) - - Enum.each(errors, fn {:error, {type, match_value, reason}} -> - Logger.error(fn -> - "failed to apply #{type} rule " <> - "with match #{match_value} " <> - "with reason #{inspect(reason)}" - end) - end) + ScrobbleRules.apply_all_rules() + |> ScrobbleRules.log_apply_results() end end