defmodule MusicLibrary.ScrobbleRules do @moduledoc """ The ScrobbleRules context. """ import Ecto.Query, warn: false require Logger alias LastFm.Track alias MusicLibrary.Repo alias MusicLibrary.ScrobbleRules.ScrobbleRule @doc """ Returns the list of scrobble_rules. ## Examples iex> list_scrobble_rules() [%ScrobbleRule{}, ...] """ def list_scrobble_rules(opts \\ []) do query = from r in ScrobbleRule, order_by: [desc: r.inserted_at] query = case Keyword.get(opts, :type) do nil -> query type -> from r in query, where: r.type == ^type end query = case Keyword.get(opts, :enabled) do nil -> query enabled -> from r in query, where: r.enabled == ^enabled end Repo.all(query) end @doc """ Gets a single scrobble_rule. Raises `Ecto.NoResultsError` if the Scrobble rule does not exist. ## Examples iex> get_scrobble_rule!(123) %ScrobbleRule{} iex> get_scrobble_rule!(456) ** (Ecto.NoResultsError) """ def get_scrobble_rule!(id), do: Repo.get!(ScrobbleRule, id) @doc """ Creates a scrobble_rule. ## Examples iex> create_scrobble_rule(%{field: value}) {:ok, %ScrobbleRule{}} iex> create_scrobble_rule(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ def create_scrobble_rule(attrs \\ %{}) do %ScrobbleRule{} |> ScrobbleRule.changeset(attrs) |> Repo.insert() end @doc """ Updates a scrobble_rule. ## Examples iex> update_scrobble_rule(scrobble_rule, %{field: new_value}) {:ok, %ScrobbleRule{}} iex> update_scrobble_rule(scrobble_rule, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ def update_scrobble_rule(%ScrobbleRule{} = scrobble_rule, attrs) do scrobble_rule |> ScrobbleRule.changeset(attrs) |> Repo.update() end @doc """ Deletes a scrobble_rule. ## Examples iex> delete_scrobble_rule(scrobble_rule) {:ok, %ScrobbleRule{}} iex> delete_scrobble_rule(scrobble_rule) {:error, %Ecto.Changeset{}} """ def delete_scrobble_rule(%ScrobbleRule{} = scrobble_rule) do Repo.delete(scrobble_rule) end @doc """ Returns an `%Ecto.Changeset{}` for tracking scrobble_rule changes. ## Examples iex> change_scrobble_rule(scrobble_rule) %Ecto.Changeset{data: %ScrobbleRule{}} """ def change_scrobble_rule(%ScrobbleRule{} = scrobble_rule, attrs \\ %{}) do ScrobbleRule.changeset(scrobble_rule, attrs) end @doc """ Returns the list of enabled scrobble_rules. ## Examples iex> list_enabled_rules() [%ScrobbleRule{}, ...] """ def list_enabled_rules do list_scrobble_rules(enabled: true) end @doc """ Applies an album rule to all matching scrobbled tracks. ## Examples iex> apply_album_rule(rule) {:ok, 5} iex> apply_album_rule(rule) {:error, :invalid_rule_type} """ def apply_album_rule(%ScrobbleRule{type: :album} = rule) do update_query = from(t in Track, where: fragment("json_extract(?, '$.title') = ?", t.album, ^rule.match_value), 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 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. ## Examples iex> apply_artist_rule(rule) {:ok, 10} iex> apply_artist_rule(rule) {:error, :invalid_rule_type} """ def apply_artist_rule(%ScrobbleRule{type: :artist} = rule) do update_query = from(t in Track, where: fragment("json_extract(?, '$.name') = ?", t.artist, ^rule.match_value), 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 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. ## Examples iex> apply_rule(rule) {:ok, 5} iex> apply_rule(rule) {:error, "Invalid rule type"} """ def apply_rule(%ScrobbleRule{type: :album} = rule) do apply_album_rule(rule) end def apply_rule(%ScrobbleRule{type: :artist} = rule) 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. ## Examples iex> apply_all_rules() {:ok, [{:album, 5}, {:artist, 10}]} """ def apply_all_rules do :telemetry.span([:music_library, :scrobble_rules, :apply_all_rules], %{}, fn -> result = 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}} end end) {result, %{scrobble_track_count: :all}} 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 :telemetry.span([:music_library, :scrobble_rules, :apply_all_rules], %{}, fn -> result = 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) {result, %{scrobble_track_count: Enum.count(tracks)}} end) end @doc """ Counts how many tracks would be affected by an album rule. ## Examples iex> count_album_matches(rule) 5 """ def count_album_matches(%ScrobbleRule{type: :album} = rule) do query = from(t in Track, where: fragment("json_extract(?, '$.title') = ?", t.album, ^rule.match_value), select: count(t.scrobbled_at_uts) ) Repo.one(query) || 0 end @doc """ Counts how many tracks would be affected by an artist rule. ## Examples iex> count_artist_matches(rule) 10 """ def count_artist_matches(%ScrobbleRule{type: :artist} = rule) do query = from(t in Track, where: fragment("json_extract(?, '$.name') = ?", t.artist, ^rule.match_value), select: count(t.scrobbled_at_uts) ) Repo.one(query) || 0 end @doc """ Counts how many tracks would be affected by a rule. ## Examples iex> count_rule_matches(rule) 5 """ def count_rule_matches(%ScrobbleRule{type: :album} = rule) do count_album_matches(rule) end 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