First rough implementation of scrobble rules (Claude)

This plan outlines the implementation of functionality to manage
arbitrary rules for fixing data in the `scrobbled_tracks` table. The
system will support two types of transformations:

1. **Album Rule**: Update the album MusicBrainz ID of all tracks
belonging to a specific album (identified by album title)
2. **Artist Rule**: Update the artist MusicBrainz ID of all tracks
belonging to a specific artist (identified by artist name)

- `scrobbled_tracks` table exists with the following structure:
  - `scrobbled_at_uts` (integer, primary key)
  - `musicbrainz_id` (string) - track MusicBrainz ID
  - `title` (string) - track title
  - `cover_url` (string)
  - `scrobbled_at_label` (string)
  - `artist` (map) - embedded artist data with `musicbrainz_id` and
`name`
  - `album` (map) - embedded album data with `musicbrainz_id` and
`title`
  - `last_fm_data` (map) - raw Last.fm API response

- `LastFm.Track` - Schema for scrobbled tracks
- `LastFm.Artist` - Embedded artist schema
- `LastFm.Album` - Embedded album schema

- Main navigation has Stats, Collection, Wishlist sections
- "More" dropdown contains development tools and utilities
- Uses Phoenix LiveView for interactive components

**File**:
`priv/repo/migrations/YYYYMMDDHHMMSS_create_scrobble_rules.exs`

Create `scrobble_rules` table with:

- `id` (primary key)
- `type` (string) - "album" or "artist"
- `match_value` (string) - album title or artist name to match
- `target_musicbrainz_id` (string) - MusicBrainz ID to set
- `enabled` (boolean) - whether rule is active
- `description` (text) - optional description for the rule
- `inserted_at` (timestamp)
- `updated_at` (timestamp)

Indexes:

- `type, match_value` (compound index for efficient matching)
- `enabled` (for filtering active rules)

**File**: `lib/music_library/scrobble_rules/scrobble_rule.ex`

Create Ecto schema with:

- Field definitions matching migration
- Validations:
  - `type` must be "album" or "artist"
  - `match_value` required and non-empty
  - `target_musicbrainz_id` required and non-empty
  - `enabled` defaults to true
- Changeset functions for create/update operations

**File**: `lib/music_library/scrobble_rules.ex`

Implement context functions:

- `list_scrobble_rules(opts \\ [])` - list all rules with optional
filtering
- `get_scrobble_rule!(id)` - get specific rule by ID
- `create_scrobble_rule(attrs)` - create new rule
- `update_scrobble_rule(rule, attrs)` - update existing rule
- `delete_scrobble_rule(rule)` - delete rule
- `change_scrobble_rule(rule, attrs \\ %{})` - create changeset
- `list_enabled_rules()` - get only enabled rules for worker
- `apply_album_rule(rule)` - apply album transformation
- `apply_artist_rule(rule)` - apply artist transformation
- `apply_all_rules()` - apply all enabled rules

**File**: `lib/music_library_web/components/layouts/app.html.heex`

Add new navigation link in the "More" dropdown:

- Icon: `hero-adjustments-horizontal`
- Label: "Scrobble Rules"
- Route: `/scrobble-rules`
- Position: After "Oban" link, before separator

**File**: `lib/music_library_web/router.ex`

Add new LiveView routes in the authenticated scope:

- `/scrobble-rules` - index page
- `/scrobble-rules/new` - create new rule
- `/scrobble-rules/:id/edit` - edit existing rule

**File**: `lib/music_library_web/live/scrobble_rules_live/index.ex`

Implement LiveView with:

- List view showing all rules with status indicators
- Create/Edit forms with validation
- Delete confirmation
- Filter controls (by type, enabled/disabled)
- Manual rule application triggers
- Real-time updates via PubSub

**Template**:
`lib/music_library_web/live/scrobble_rules_live/index.html.heex`

- Table layout with rule details
- Action buttons (Edit, Delete, Apply)
- Form components for create/edit
- Status indicators for enabled/disabled rules

**File**:
`lib/music_library_web/live/scrobble_rules_live/form_component.ex`

Create reusable form component:

- Type selection (album/artist)
- Match value input with validation
- Target MusicBrainz ID input
- Description textarea
- Enable/disable toggle
- Save/Cancel actions

Where possible, use the components available at
<https://docs.fluxonui.com/1.1.4/overview.html>.

**File**: `lib/music_library/scrobble_rules/worker.ex`

Implement Oban worker:

- Scheduled to run periodically (e.g., every 30 minutes)
- Fetches all enabled rules
- Applies each rule in sequence
- Logs application results
- Handles errors gracefully
- Tracks last application time

**File**: `test/music_library/scrobble_rules_test.exs`

- Test all context functions
- Test rule application logic
- Test edge cases and error handling

**File**: `test/music_library/scrobble_rules/scrobble_rule_test.exs`

- Test schema validations
- Test changeset functions
- Test constraint validations

**File**: `test/music_library_web/live/scrobble_rules_live_test.exs`

- Test LiveView interactions
- Test form submissions
- Test real-time updates
- Test navigation and routing

**File**: `test/music_library/scrobble_rules/worker_test.exs`

- Test job execution
- Test rule application
- Test error handling
- Test scheduling

- Ensure atomic operations when applying rules
- Validate MusicBrainz IDs before applying
- Log all transformations for audit trail

- Index optimization for rule matching
- Batch processing for large datasets
- Rate limiting for external API calls

- Clear feedback on rule application
- Preview mode to show what would change
- Bulk operations for managing multiple rules
- Export/import functionality for rule sets

- Input validation and sanitization
- Rate limiting on rule creation
- Audit logging for all changes

```sql
-- scrobble_rules table
CREATE TABLE scrobble_rules (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  type TEXT NOT NULL CHECK (type IN ('album', 'artist')),
  match_value TEXT NOT NULL,
  target_musicbrainz_id TEXT NOT NULL,
  enabled BOOLEAN NOT NULL DEFAULT TRUE,
  description TEXT,
  inserted_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_scrobble_rules_type_match ON scrobble_rules(type,
match_value);
CREATE INDEX idx_scrobble_rules_enabled ON scrobble_rules(enabled);
```

```
lib/music_library/
├── scrobble_rules/
│   ├── scrobble_rule.ex          # Schema
│   └── worker.ex                 # Oban worker
└── scrobble_rules.ex             # Context

lib/music_library_web/
└── live/
    └── scrobble_rules_live/
        ├── index.ex              # LiveView
        ├── index.html.heex       # Template
        └── form_component.ex     # Form component

priv/repo/migrations/
└── YYYYMMDDHHMMSS_create_scrobble_rules.exs

test/
├── music_library/
│   ├── scrobble_rules_test.exs
│   └── scrobble_rules/
│       ├── scrobble_rule_test.exs
│       └── worker_test.exs
└── music_library_web/
    └── live/
        └── scrobble_rules_live_test.exs
```

1. Database migration and schema
2. Context module with basic CRUD operations
3. LiveView interface for rule management
4. Navigation and routing updates
5. Periodic worker implementation
6. Comprehensive testing
7. Documentation and deployment

This plan ensures a robust, maintainable solution that integrates
seamlessly with the existing Music Library application architecture.
This commit is contained in:
Claudio Ortolina
2025-07-03 16:46:21 +01:00
parent f70cb317ac
commit d91685b646
23 changed files with 2371 additions and 2 deletions
+7
View File
@@ -1,6 +1,8 @@
defmodule LastFm.Album do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
musicbrainz_id: String.t(),
title: String.t()
@@ -9,4 +11,9 @@ defmodule LastFm.Album do
field :musicbrainz_id, :string
field :title, :string
end
def changeset(album, attrs) do
album
|> cast(attrs, [:musicbrainz_id, :title])
end
end
+16
View File
@@ -1,6 +1,8 @@
defmodule LastFm.Artist do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
musicbrainz_id: String.t(),
name: String.t(),
@@ -36,6 +38,20 @@ defmodule LastFm.Artist do
}
end
def changeset(artist, attrs) do
artist
|> cast(attrs, [
:musicbrainz_id,
:name,
:summary,
:bio,
:image,
:play_count,
:on_tour,
:base_url
])
end
def events_url(artist) do
artist.base_url <> "/+events"
end
+17
View File
@@ -7,6 +7,8 @@ defmodule LastFm.Track do
use Ecto.Schema
import Ecto.Changeset
alias LastFm.{Album, Artist}
@type t :: %__MODULE__{
@@ -68,4 +70,19 @@ defmodule LastFm.Track do
track["date"]["uts"]
|> String.to_integer()
end
def changeset(track, attrs) do
track
|> cast(attrs, [
:scrobbled_at_uts,
:musicbrainz_id,
:title,
:cover_url,
:scrobbled_at_label,
:last_fm_data
])
|> cast_embed(:artist)
|> cast_embed(:album)
|> validate_required([:scrobbled_at_uts])
end
end
+324
View File
@@ -0,0 +1,324 @@
defmodule MusicLibrary.ScrobbleRules do
@moduledoc """
The ScrobbleRules context.
"""
import Ecto.Query, warn: false
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
def apply_album_rule(%ScrobbleRule{type: type}) do
{:error, "Invalid rule type: #{type}. Expected 'album'"}
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
def apply_artist_rule(%ScrobbleRule{type: type}) do
{:error, "Invalid rule type: #{type}. Expected 'artist'"}
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
def apply_rule(%ScrobbleRule{type: type}) do
{:error, "Invalid rule type: #{type}"}
end
@doc """
Applies all enabled rules.
## Examples
iex> apply_all_rules()
{:ok, [{"album", 5}, {"artist", 10}]}
"""
def apply_all_rules do
rules = list_enabled_rules()
results =
Enum.map(rules, 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)
{:ok, results}
rescue
e -> {:error, "Failed to apply rules: #{Exception.message(e)}"}
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
def count_album_matches(%ScrobbleRule{type: type}) do
{:error, "Invalid rule type: #{type}. Expected 'album'"}
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
def count_artist_matches(%ScrobbleRule{type: type}) do
{:error, "Invalid rule type: #{type}. Expected 'artist'"}
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
def count_rule_matches(%ScrobbleRule{type: type}) do
{:error, "Invalid rule type: #{type}"}
end
end
@@ -0,0 +1,59 @@
defmodule MusicLibrary.ScrobbleRules.ScrobbleRule do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
id: integer() | nil,
type: String.t(),
match_value: String.t(),
target_musicbrainz_id: String.t(),
enabled: boolean(),
description: String.t() | nil,
inserted_at: NaiveDateTime.t() | nil,
updated_at: NaiveDateTime.t() | nil
}
@valid_types ~w(album artist)
schema "scrobble_rules" do
field :type, :string
field :match_value, :string
field :target_musicbrainz_id, :string
field :enabled, :boolean, default: true
field :description, :string
timestamps()
end
@doc false
def changeset(scrobble_rule, attrs) do
scrobble_rule
|> cast(attrs, [:type, :match_value, :target_musicbrainz_id, :enabled, :description])
|> validate_required([:type, :match_value, :target_musicbrainz_id])
|> validate_inclusion(:type, @valid_types)
|> validate_musicbrainz_id_format(:target_musicbrainz_id)
end
defp validate_musicbrainz_id_format(changeset, field) do
validate_change(changeset, field, fn _, value ->
case value do
"" ->
[{field, "cannot be empty"}]
value when is_binary(value) ->
if String.match?(
value,
~r/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
) do
[]
else
[{field, "must be a valid MusicBrainz ID (UUID format)"}]
end
_ ->
[{field, "must be a string"}]
end
end)
end
end
@@ -0,0 +1,89 @@
defmodule MusicLibrary.ScrobbleRules.Worker do
@moduledoc """
Oban worker that periodically applies all enabled scrobble rules.
This worker runs every 30 minutes and applies all enabled transformation rules
to the scrobbled_tracks table. It logs the results and handles errors gracefully.
"""
use Oban.Worker, queue: :heavy_writes, max_attempts: 3
alias MusicLibrary.ScrobbleRules
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: _}) do
Logger.info("Starting scrobble rules application")
case ScrobbleRules.apply_all_rules() do
{:ok, results} ->
log_results(results)
:ok
{:error, reason} ->
Logger.error("Failed to apply scrobble rules: #{inspect(reason)}")
{:error, reason}
end
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("Scrobble rules application completed", %{
rules_applied: total_applied,
rules_failed: total_errors,
tracks_updated: total_tracks_updated
})
# Log individual rule results
Enum.each(applied, fn {:ok, {type, match_value, count}} ->
Logger.info("Applied #{type} rule", %{
match_value: match_value,
tracks_updated: count
})
end)
# Log errors
Enum.each(errors, fn {:error, {type, match_value, reason}} ->
Logger.error("Failed to apply #{type} rule", %{
match_value: match_value,
error: reason
})
end)
end
@doc """
Schedules the worker to run periodically.
This should be called during application startup to ensure
the worker runs automatically.
"""
def schedule_periodic do
# Schedule to run every 30 minutes
__MODULE__.new(%{})
|> Oban.insert(schedule_in: {30, :minute})
end
@doc """
Triggers the worker to run immediately.
Useful for manual rule application or testing.
"""
def run_now do
__MODULE__.new(%{})
|> Oban.insert()
end
end
@@ -33,6 +33,7 @@ defmodule MusicLibraryWeb.CoreComponents do
defdelegate select(assigns), to: Fluxon.Components.Select
defdelegate separator(assigns), to: Fluxon.Components.Separator
defdelegate sheet(assigns), to: Fluxon.Components.Sheet
defdelegate switch(assigns), to: Fluxon.Components.Switch
defdelegate tooltip(assigns), to: Fluxon.Components.Tooltip
defdelegate tabs(assigns), to: Fluxon.Components.Tabs
defdelegate tabs_list(assigns), to: Fluxon.Components.Tabs
@@ -64,6 +64,15 @@
<.icon name="hero-cog" class="h-4 w-4 mr-2" aria-hidden="true" data-slot="icon" />
{gettext("Oban")}
</.dropdown_link>
<.dropdown_link href={~p"/scrobble-rules"}>
<.icon
name="hero-adjustments-horizontal"
class="h-4 w-4 mr-2"
aria-hidden="true"
data-slot="icon"
/>
{gettext("Scrobble Rules")}
</.dropdown_link>
<.dropdown_link href={~p"/backup"}>
<.icon
name="hero-archive-box-arrow-down"
@@ -0,0 +1,143 @@
defmodule MusicLibraryWeb.ScrobbleRulesLive.FormComponent do
use MusicLibraryWeb, :live_component
alias MusicLibrary.ScrobbleRules
@impl true
def render(assigns) do
~H"""
<div>
<header class="mb-6">
<h1 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{@title}
</h1>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
<%= if @action == :new do %>
{gettext("Create a new rule to transform scrobbled track data")}
<% else %>
{gettext("Edit the rule to change how it transforms data")}
<% end %>
</p>
</header>
<.simple_form
for={@form}
id="scrobble_rule-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.select
field={@form[:type]}
label={gettext("Rule Type")}
options={[
{gettext("Album"), "album"},
{gettext("Artist"), "artist"}
]}
prompt={gettext("Select a rule type")}
/>
<.input
field={@form[:match_value]}
type="text"
label={match_value_label(@form[:type].value)}
placeholder={match_value_placeholder(@form[:type].value)}
/>
<.input
field={@form[:target_musicbrainz_id]}
type="text"
label={gettext("Target MusicBrainz ID")}
placeholder="e.g. 12345678-1234-1234-1234-123456789012"
class="font-mono"
/>
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description (optional)")}
placeholder={gettext("Add a description to help identify this rule")}
/>
<.switch field={@form[:enabled]} label={gettext("Enable this rule")} />
<:actions>
<.button phx-disable-with={gettext("Saving...")}>
{gettext("Save Rule")}
</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(%{scrobble_rule: scrobble_rule} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:form, fn ->
to_form(ScrobbleRules.change_scrobble_rule(scrobble_rule))
end)}
end
@impl true
def handle_event("validate", %{"scrobble_rule" => scrobble_rule_params}, socket) do
changeset =
ScrobbleRules.change_scrobble_rule(socket.assigns.scrobble_rule, scrobble_rule_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"scrobble_rule" => scrobble_rule_params}, socket) do
save_scrobble_rule(socket, socket.assigns.action, scrobble_rule_params)
end
defp save_scrobble_rule(socket, :edit, scrobble_rule_params) do
case ScrobbleRules.update_scrobble_rule(socket.assigns.scrobble_rule, scrobble_rule_params) do
{:ok, scrobble_rule} ->
notify_parent({:saved, scrobble_rule})
{:noreply,
socket
|> put_flash(:info, gettext("Scrobble rule updated successfully"))
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_scrobble_rule(socket, :new, scrobble_rule_params) do
case ScrobbleRules.create_scrobble_rule(scrobble_rule_params) do
{:ok, scrobble_rule} ->
notify_parent({:saved, scrobble_rule})
{:noreply,
socket
|> put_flash(:info, gettext("Scrobble rule created successfully"))
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp match_value_label(type) do
case type do
"album" -> gettext("Album Title")
"artist" -> gettext("Artist Name")
_ -> gettext("Match Value")
end
end
defp match_value_placeholder(type) do
case type do
"album" -> gettext("e.g. The Dark Side of the Moon")
"artist" -> gettext("e.g. Pink Floyd")
_ -> gettext("Enter the value to match")
end
end
end
@@ -0,0 +1,132 @@
defmodule MusicLibraryWeb.ScrobbleRulesLive.Index do
use MusicLibraryWeb, :live_view
alias MusicLibrary.ScrobbleRules
alias MusicLibrary.ScrobbleRules.ScrobbleRule
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :current_section, :scrobble_rules)}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, gettext("Edit Scrobble Rule"))
|> assign(:scrobble_rule, ScrobbleRules.get_scrobble_rule!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, gettext("New Scrobble Rule"))
|> stream(:scrobble_rules, ScrobbleRules.list_scrobble_rules())
|> assign(:scrobble_rule, %ScrobbleRule{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, gettext("Scrobble Rules"))
|> assign(:scrobble_rule, nil)
|> stream(:scrobble_rules, ScrobbleRules.list_scrobble_rules())
end
@impl true
def handle_info(
{MusicLibraryWeb.ScrobbleRulesLive.FormComponent, {:saved, scrobble_rule}},
socket
) do
{:noreply, stream_insert(socket, :scrobble_rules, scrobble_rule)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
scrobble_rule = ScrobbleRules.get_scrobble_rule!(id)
{:ok, _} = ScrobbleRules.delete_scrobble_rule(scrobble_rule)
{:noreply, stream_delete(socket, :scrobble_rules, scrobble_rule)}
end
@impl true
def handle_event("toggle_enabled", %{"id" => id}, socket) do
scrobble_rule = ScrobbleRules.get_scrobble_rule!(id)
{:ok, updated_rule} =
ScrobbleRules.update_scrobble_rule(scrobble_rule, %{enabled: !scrobble_rule.enabled})
{:noreply, stream_insert(socket, :scrobble_rules, updated_rule)}
end
@impl true
def handle_event("apply_rule", %{"id" => id}, socket) do
scrobble_rule = ScrobbleRules.get_scrobble_rule!(id)
case ScrobbleRules.apply_rule(scrobble_rule) do
{:ok, count} ->
message = gettext("Rule applied successfully. Updated %{count} tracks.", count: count)
{:noreply, put_flash(socket, :info, message)}
{:error, reason} ->
message = gettext("Error applying rule: %{reason}", reason: reason)
{:noreply, put_flash(socket, :error, message)}
end
end
@impl true
def handle_event("apply_all_rules", _params, socket) do
case ScrobbleRules.apply_all_rules() do
{:ok, results} ->
total_updated =
results
|> Enum.filter(fn {status, _} -> status == :ok end)
|> Enum.map(fn {:ok, {_, _, count}} -> count end)
|> Enum.sum()
message =
gettext("All rules applied successfully. Updated %{count} tracks total.",
count: total_updated
)
{:noreply, put_flash(socket, :info, message)}
{:error, reason} ->
message = gettext("Error applying rules: %{reason}", reason: reason)
{:noreply, put_flash(socket, :error, message)}
end
end
defp rule_type_badge(type) do
case type do
"album" -> "Album"
"artist" -> "Artist"
_ -> type
end
end
defp enabled_badge(enabled) do
if enabled do
"Enabled"
else
"Disabled"
end
end
defp enabled_badge_class(enabled) do
if enabled do
"text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-300"
else
"text-red-800 bg-red-100 dark:bg-red-900 dark:text-red-300"
end
end
defp type_badge_class(type) do
case type do
"album" -> "text-blue-800 bg-blue-100 dark:bg-blue-900 dark:text-blue-300"
"artist" -> "text-purple-800 bg-purple-100 dark:bg-purple-900 dark:text-purple-300"
_ -> "text-gray-800 bg-gray-100 dark:bg-gray-900 dark:text-gray-300"
end
end
end
@@ -0,0 +1,163 @@
<header class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-zinc-900 dark:text-zinc-100">
{gettext("Scrobble Rules")}
</h1>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{gettext("Manage rules to fix data in scrobbled tracks")}
</p>
</div>
<div>
<.link patch={~p"/scrobble-rules/new"}>
<.button>
<.icon name="hero-plus" class="h-4 w-4 mr-2" />
{gettext("New Rule")}
</.button>
</.link>
</div>
</div>
</header>
<div class="mt-6 space-y-4">
<div class="flex justify-between items-center">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
{gettext(
"Rules are applied automatically by the background worker, or you can apply them manually."
)}
</p>
<.button phx-click="apply_all_rules" class="!bg-green-600 hover:!bg-green-700">
<.icon name="hero-play" class="h-4 w-4 mr-2" />
{gettext("Apply All Rules")}
</.button>
</div>
<div class="bg-white dark:bg-zinc-900 shadow-sm ring-1 ring-zinc-950/5 dark:ring-zinc-200/10 sm:rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800">
<thead class="bg-zinc-50 dark:bg-zinc-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Type")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Match Value")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Target MusicBrainz ID")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Status")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Description")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Created")}
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
{gettext("Actions")}
</th>
</tr>
</thead>
<tbody
id="scrobble_rules"
phx-update="stream"
class="bg-white dark:bg-zinc-900 divide-y divide-zinc-200 dark:divide-zinc-800"
>
<tr
:for={{dom_id, scrobble_rule} <- @streams.scrobble_rules}
id={dom_id}
class="hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<td class="px-6 py-4 whitespace-nowrap">
<span class={[
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
type_badge_class(scrobble_rule.type)
]}>
{rule_type_badge(scrobble_rule.type)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-zinc-900 dark:text-zinc-100 font-medium">
{scrobble_rule.match_value}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400 font-mono">
{scrobble_rule.target_musicbrainz_id}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={[
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
enabled_badge_class(scrobble_rule.enabled)
]}>
{enabled_badge(scrobble_rule.enabled)}
</span>
</td>
<td class="px-6 py-4 text-sm text-zinc-500 dark:text-zinc-400">
{scrobble_rule.description}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">
{Calendar.strftime(scrobble_rule.inserted_at, "%Y-%m-%d")}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<.button
phx-click="apply_rule"
phx-value-id={scrobble_rule.id}
class="!bg-green-600 hover:!bg-green-700 !text-white !text-xs !px-2 !py-1"
>
<.icon name="hero-play" class="h-3 w-3" />
</.button>
<.button
phx-click="toggle_enabled"
phx-value-id={scrobble_rule.id}
class={[
"!text-white !text-xs !px-2 !py-1",
if(scrobble_rule.enabled,
do: "!bg-yellow-600 hover:!bg-yellow-700",
else: "!bg-green-600 hover:!bg-green-700"
)
]}
>
<.icon
name={if scrobble_rule.enabled, do: "hero-pause", else: "hero-play"}
class="h-3 w-3"
/>
</.button>
<.link patch={~p"/scrobble-rules/#{scrobble_rule}/edit"}>
<.button class="!bg-blue-600 hover:!bg-blue-700 !text-white !text-xs !px-2 !py-1">
<.icon name="hero-pencil" class="h-3 w-3" />
</.button>
</.link>
<.button
phx-click="delete"
phx-value-id={scrobble_rule.id}
data-confirm={gettext("Are you sure you want to delete this rule?")}
class="!bg-red-600 hover:!bg-red-700 !text-white !text-xs !px-2 !py-1"
>
<.icon name="hero-trash" class="h-3 w-3" />
</.button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<.modal
:if={@live_action in [:new, :edit]}
id="scrobble_rule-modal"
open
on_close={JS.patch(~p"/scrobble-rules")}
>
<.live_component
module={MusicLibraryWeb.ScrobbleRulesLive.FormComponent}
id={@scrobble_rule.id || :new}
title={@page_title}
action={@live_action}
scrobble_rule={@scrobble_rule}
patch={~p"/scrobble-rules"}
/>
</.modal>
+4
View File
@@ -60,6 +60,10 @@ defmodule MusicLibraryWeb.Router do
live "/artists/:musicbrainz_id", ArtistLive.Show, :show
live "/artists/:musicbrainz_id/import", ArtistLive.Show, :import
live "/scrobble-rules", ScrobbleRulesLive.Index, :index
live "/scrobble-rules/new", ScrobbleRulesLive.Index, :new
live "/scrobble-rules/:id/edit", ScrobbleRulesLive.Index, :edit
end
end
end