diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f55249f3..ab1f5797 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,17 @@ "mcp__tidewave__get_ecto_schemas", "Bash(mix test:*)", "mcp__tidewave__get_source_location", - "WebFetch(domain:hexdocs.pm)" + "WebFetch(domain:hexdocs.pm)", + "mcp__tidewave__execute_sql_query", + "Bash(mix ecto.gen.migration:*)", + "Bash(mkdir:*)", + "Bash(mix ecto.migrate:*)", + "Bash(grep:*)", + "Bash(mix ecto.rollback:*)", + "Bash(mix format)", + "Bash(mix credo:*)", + "Bash(mix do:*)", + "Bash(mix gettext.extract:*)" ] }, "enableAllProjectMcpServers": false diff --git a/config/config.exs b/config/config.exs index 86b627c8..f86f7d22 100644 --- a/config/config.exs +++ b/config/config.exs @@ -87,7 +87,9 @@ config :music_library, Oban, {Oban.Plugins.Cron, crontab: [ # every hour - {"0 * * * *", MusicLibrary.Worker.PolyfillScrobbledTracks} + {"0 * * * *", MusicLibrary.Worker.PolyfillScrobbledTracks}, + # every 30 minutes + {"*/30 * * * *", MusicLibrary.ScrobbleRules.Worker} ]} ] diff --git a/docs/plans/scrobble_rules.md b/docs/plans/scrobble_rules.md new file mode 100644 index 00000000..e9e78963 --- /dev/null +++ b/docs/plans/scrobble_rules.md @@ -0,0 +1,279 @@ +# Scrobbled Tracks Data Transformation Rules - Implementation Plan + +## Overview + +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) + +## Current State Analysis + +### Existing Database Schema + +- `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 + +### Existing Schemas + +- `LastFm.Track` - Schema for scrobbled tracks +- `LastFm.Artist` - Embedded artist schema +- `LastFm.Album` - Embedded album schema + +### Navigation Structure + +- Main navigation has Stats, Collection, Wishlist sections +- "More" dropdown contains development tools and utilities +- Uses Phoenix LiveView for interactive components + +## Implementation Plan + +### 1. Database Migration + +**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) + +### 2. Schema Module + +**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 + +### 3. Context Module + +**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 + +### 4. Navigation Update + +**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 + +### 5. Router Configuration + +**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 + +### 6. LiveView Implementation + +**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 + +### 7. Form Components + +**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 . + +### 8. Periodic Worker + +**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 + +### 9. Testing Strategy + +#### Unit Tests + +**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 + +#### Integration Tests + +**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 + +#### Worker Tests + +**File**: `test/music_library/scrobble_rules/worker_test.exs` + +- Test job execution +- Test rule application +- Test error handling +- Test scheduling + +### 11. Additional Considerations + +#### Data Integrity + +- Ensure atomic operations when applying rules +- Validate MusicBrainz IDs before applying +- Log all transformations for audit trail + +#### Performance + +- Index optimization for rule matching +- Batch processing for large datasets +- Rate limiting for external API calls + +#### User Experience + +- Clear feedback on rule application +- Preview mode to show what would change +- Bulk operations for managing multiple rules +- Export/import functionality for rule sets + +#### Security + +- Input validation and sanitization +- Rate limiting on rule creation +- Audit logging for all changes + +## Database Schema Summary + +```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); +``` + +## File Structure Summary + +``` +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 +``` + +## Implementation Order + +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. + diff --git a/lib/last_fm/album.ex b/lib/last_fm/album.ex index 206198b3..80cefc42 100644 --- a/lib/last_fm/album.ex +++ b/lib/last_fm/album.ex @@ -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 diff --git a/lib/last_fm/artist.ex b/lib/last_fm/artist.ex index 51c980c0..ef6a75f4 100644 --- a/lib/last_fm/artist.ex +++ b/lib/last_fm/artist.ex @@ -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 diff --git a/lib/last_fm/track.ex b/lib/last_fm/track.ex index c2033953..487a82c5 100644 --- a/lib/last_fm/track.ex +++ b/lib/last_fm/track.ex @@ -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 diff --git a/lib/music_library/scrobble_rules.ex b/lib/music_library/scrobble_rules.ex new file mode 100644 index 00000000..b06b0deb --- /dev/null +++ b/lib/music_library/scrobble_rules.ex @@ -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 diff --git a/lib/music_library/scrobble_rules/scrobble_rule.ex b/lib/music_library/scrobble_rules/scrobble_rule.ex new file mode 100644 index 00000000..7fec8fb9 --- /dev/null +++ b/lib/music_library/scrobble_rules/scrobble_rule.ex @@ -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 diff --git a/lib/music_library/scrobble_rules/worker.ex b/lib/music_library/scrobble_rules/worker.ex new file mode 100644 index 00000000..3fa9390b --- /dev/null +++ b/lib/music_library/scrobble_rules/worker.ex @@ -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 diff --git a/lib/music_library_web/components/core_components.ex b/lib/music_library_web/components/core_components.ex index 825a35ce..0e2d9a72 100644 --- a/lib/music_library_web/components/core_components.ex +++ b/lib/music_library_web/components/core_components.ex @@ -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 diff --git a/lib/music_library_web/components/layouts/app.html.heex b/lib/music_library_web/components/layouts/app.html.heex index c7c61880..2f89a91a 100644 --- a/lib/music_library_web/components/layouts/app.html.heex +++ b/lib/music_library_web/components/layouts/app.html.heex @@ -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 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 href={~p"/backup"}> <.icon name="hero-archive-box-arrow-down" diff --git a/lib/music_library_web/live/scrobble_rules_live/form_component.ex b/lib/music_library_web/live/scrobble_rules_live/form_component.ex new file mode 100644 index 00000000..4f2eecff --- /dev/null +++ b/lib/music_library_web/live/scrobble_rules_live/form_component.ex @@ -0,0 +1,143 @@ +defmodule MusicLibraryWeb.ScrobbleRulesLive.FormComponent do + use MusicLibraryWeb, :live_component + + alias MusicLibrary.ScrobbleRules + + @impl true + def render(assigns) do + ~H""" +
+
+

+ {@title} +

+

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

+
+ + <.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")} + + + +
+ """ + 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 diff --git a/lib/music_library_web/live/scrobble_rules_live/index.ex b/lib/music_library_web/live/scrobble_rules_live/index.ex new file mode 100644 index 00000000..847cb211 --- /dev/null +++ b/lib/music_library_web/live/scrobble_rules_live/index.ex @@ -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 diff --git a/lib/music_library_web/live/scrobble_rules_live/index.html.heex b/lib/music_library_web/live/scrobble_rules_live/index.html.heex new file mode 100644 index 00000000..3f1f7fb4 --- /dev/null +++ b/lib/music_library_web/live/scrobble_rules_live/index.html.heex @@ -0,0 +1,163 @@ +
+
+
+

+ {gettext("Scrobble Rules")} +

+

+ {gettext("Manage rules to fix data in scrobbled tracks")} +

+
+
+ <.link patch={~p"/scrobble-rules/new"}> + <.button> + <.icon name="hero-plus" class="h-4 w-4 mr-2" /> + {gettext("New Rule")} + + +
+
+
+ +
+
+

+ {gettext( + "Rules are applied automatically by the background worker, or you can apply them manually." + )} +

+ <.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")} + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {gettext("Type")} + + {gettext("Match Value")} + + {gettext("Target MusicBrainz ID")} + + {gettext("Status")} + + {gettext("Description")} + + {gettext("Created")} + + {gettext("Actions")} +
+ + {rule_type_badge(scrobble_rule.type)} + + + {scrobble_rule.match_value} + + {scrobble_rule.target_musicbrainz_id} + + + {enabled_badge(scrobble_rule.enabled)} + + + {scrobble_rule.description} + + {Calendar.strftime(scrobble_rule.inserted_at, "%Y-%m-%d")} + + <.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 + 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" + /> + + + <.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 + 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" /> + +
+
+
+
+ +<.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"} + /> + diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex index 7694daaf..ae5840ec 100644 --- a/lib/music_library_web/router.ex +++ b/lib/music_library_web/router.ex @@ -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 diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 02c42f88..2940ebdd 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -156,6 +156,7 @@ msgid "Save" msgstr "" #: lib/music_library_web/components/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -193,6 +194,7 @@ msgid "Total wishlist" msgstr "" #: lib/music_library_web/components/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex #, elixir-autogen, elixir-format msgid "Type" msgstr "" @@ -428,6 +430,7 @@ msgid "Unreleased" msgstr "" #: lib/music_library_web/components/record_components.ex +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex #, elixir-autogen, elixir-format msgid "Album" msgstr "" @@ -905,6 +908,7 @@ msgstr "" #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/artist_live/show.html.heex #: lib/music_library_web/live/collection_live/show.html.heex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex #, elixir-autogen, elixir-format msgid "Actions" @@ -949,3 +953,172 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Track ID:" msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Add a description to help identify this rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Album Title" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "All rules applied successfully. Updated %{count} tracks total." +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Apply All Rules" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete this rule?" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Artist" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Artist Name" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Create a new rule to transform scrobbled track data" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Created" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Description" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Description (optional)" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Edit Scrobble Rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Edit the rule to change how it transforms data" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Enable this rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Enter the value to match" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Error applying rule: %{reason}" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Error applying rules: %{reason}" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Manage rules to fix data in scrobbled tracks" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Match Value" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "New Rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "New Scrobble Rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Rule Type" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Rule applied successfully. Updated %{count} tracks." +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Rules are applied automatically by the background worker, or you can apply them manually." +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Save Rule" +msgstr "" + +#: lib/music_library_web/components/layouts/app.html.heex +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Scrobble Rules" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Scrobble rule created successfully" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Scrobble rule updated successfully" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Select a rule type" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Status" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Target MusicBrainz ID" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "e.g. Pink Floyd" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "e.g. The Dark Side of the Moon" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 367e3afc..dc8d5b89 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -156,6 +156,7 @@ msgid "Save" msgstr "" #: lib/music_library_web/components/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -193,6 +194,7 @@ msgid "Total wishlist" msgstr "" #: lib/music_library_web/components/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex #, elixir-autogen, elixir-format msgid "Type" msgstr "" @@ -428,6 +430,7 @@ msgid "Unreleased" msgstr "" #: lib/music_library_web/components/record_components.ex +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex #, elixir-autogen, elixir-format msgid "Album" msgstr "" @@ -905,6 +908,7 @@ msgstr "" #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/artist_live/show.html.heex #: lib/music_library_web/live/collection_live/show.html.heex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex #, elixir-autogen, elixir-format msgid "Actions" @@ -949,3 +953,172 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Track ID:" msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Add a description to help identify this rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Album Title" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "All rules applied successfully. Updated %{count} tracks total." +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Apply All Rules" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Are you sure you want to delete this rule?" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Artist" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Artist Name" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Create a new rule to transform scrobbled track data" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Created" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Description" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Description (optional)" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Edit Scrobble Rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Edit the rule to change how it transforms data" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Enable this rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Enter the value to match" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Error applying rule: %{reason}" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Error applying rules: %{reason}" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Manage rules to fix data in scrobbled tracks" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Match Value" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "New Rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "New Scrobble Rule" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Rule Type" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#, elixir-autogen, elixir-format +msgid "Rule applied successfully. Updated %{count} tracks." +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Rules are applied automatically by the background worker, or you can apply them manually." +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Rule" +msgstr "" + +#: lib/music_library_web/components/layouts/app.html.heex +#: lib/music_library_web/live/scrobble_rules_live/index.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Scrobble Rules" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Scrobble rule created successfully" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Scrobble rule updated successfully" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "Select a rule type" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Status" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#: lib/music_library_web/live/scrobble_rules_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Target MusicBrainz ID" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "e.g. Pink Floyd" +msgstr "" + +#: lib/music_library_web/live/scrobble_rules_live/form_component.ex +#, elixir-autogen, elixir-format +msgid "e.g. The Dark Side of the Moon" +msgstr "" diff --git a/priv/repo/migrations/20250703121236_create_scrobble_rules.exs b/priv/repo/migrations/20250703121236_create_scrobble_rules.exs new file mode 100644 index 00000000..15de9221 --- /dev/null +++ b/priv/repo/migrations/20250703121236_create_scrobble_rules.exs @@ -0,0 +1,18 @@ +defmodule MusicLibrary.Repo.Migrations.CreateScrobbleRules do + use Ecto.Migration + + def change do + create table(:scrobble_rules) do + add :type, :string, null: false + add :match_value, :string, null: false + add :target_musicbrainz_id, :string, null: false + add :enabled, :boolean, default: true, null: false + add :description, :text + + timestamps() + end + + create index(:scrobble_rules, [:type, :match_value]) + create index(:scrobble_rules, [:enabled]) + end +end diff --git a/test/music_library/scrobble_rules/scrobble_rule_test.exs b/test/music_library/scrobble_rules/scrobble_rule_test.exs new file mode 100644 index 00000000..b7c54ebe --- /dev/null +++ b/test/music_library/scrobble_rules/scrobble_rule_test.exs @@ -0,0 +1,136 @@ +defmodule MusicLibrary.ScrobbleRules.ScrobbleRuleTest do + use MusicLibrary.DataCase + + alias MusicLibrary.ScrobbleRules.ScrobbleRule + + describe "changeset/2" do + test "valid changeset with all required fields" do + attrs = %{ + type: "album", + match_value: "Dark Side of the Moon", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012", + enabled: true, + description: "Fix Pink Floyd album" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + assert changeset.valid? + end + + test "valid changeset with minimal required fields" do + attrs = %{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + assert changeset.valid? + # Check that enabled defaults to true if not set + end + + test "invalid changeset when type is missing" do + attrs = %{ + match_value: "Pink Floyd", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).type + end + + test "invalid changeset when match_value is missing" do + attrs = %{ + type: "artist", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).match_value + end + + test "invalid changeset when target_musicbrainz_id is missing" do + attrs = %{ + type: "artist", + match_value: "Pink Floyd" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).target_musicbrainz_id + end + + test "invalid changeset when type is not album or artist" do + attrs = %{ + type: "invalid_type", + match_value: "Pink Floyd", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).type + end + + test "invalid changeset when match_value is empty" do + attrs = %{ + type: "artist", + match_value: "", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).match_value + end + + test "invalid changeset when target_musicbrainz_id is empty" do + attrs = %{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).target_musicbrainz_id + end + + test "invalid changeset when target_musicbrainz_id is not a valid UUID" do + attrs = %{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "invalid-uuid" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + refute changeset.valid? + + assert "must be a valid MusicBrainz ID (UUID format)" in errors_on(changeset).target_musicbrainz_id + end + + test "valid changeset with uppercase UUID" do + attrs = %{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + assert changeset.valid? + end + + test "valid changeset with lowercase UUID" do + attrs = %{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "abcdefab-abcd-abcd-abcd-abcdefabcdef" + } + + changeset = ScrobbleRule.changeset(%ScrobbleRule{}, attrs) + assert changeset.valid? + end + end +end diff --git a/test/music_library/scrobble_rules/worker_test.exs b/test/music_library/scrobble_rules/worker_test.exs new file mode 100644 index 00000000..f992cbf0 --- /dev/null +++ b/test/music_library/scrobble_rules/worker_test.exs @@ -0,0 +1,95 @@ +defmodule MusicLibrary.ScrobbleRules.WorkerTest do + use MusicLibrary.DataCase + + alias LastFm.Track + alias MusicLibrary.ScrobbleRules + alias MusicLibrary.ScrobbleRules.Worker + + describe "perform/1" do + test "successfully applies all enabled rules" do + # Create enabled rules + {:ok, album_rule} = + ScrobbleRules.create_scrobble_rule(%{ + type: "album", + match_value: "Dark Side of the Moon", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012", + enabled: true + }) + + {:ok, artist_rule} = + ScrobbleRules.create_scrobble_rule(%{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "87654321-4321-4321-4321-210987654321", + enabled: true + }) + + # Create disabled rule + {:ok, _disabled_rule} = + ScrobbleRules.create_scrobble_rule(%{ + type: "album", + match_value: "Disabled Album", + target_musicbrainz_id: "11111111-1111-1111-1111-111111111111", + enabled: false + }) + + # Create test tracks + %Track{} + |> Track.changeset(%{ + scrobbled_at_uts: System.system_time(:second), + musicbrainz_id: "track-mbid-1", + title: "Breathe", + cover_url: "http://example.com/cover.jpg", + scrobbled_at_label: "01 Jan 2023, 12:00", + artist: %{musicbrainz_id: "", name: "Pink Floyd"}, + album: %{musicbrainz_id: "", title: "Dark Side of the Moon"}, + last_fm_data: %{} + }) + |> Repo.insert!() + + %Track{} + |> Track.changeset(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + musicbrainz_id: "track-mbid-2", + title: "Money", + cover_url: "http://example.com/cover.jpg", + scrobbled_at_label: "01 Jan 2023, 12:05", + artist: %{musicbrainz_id: "", name: "Pink Floyd"}, + album: %{musicbrainz_id: "", title: "Wish You Were Here"}, + last_fm_data: %{} + }) + |> Repo.insert!() + + # Execute the worker + assert :ok = Worker.perform(%Oban.Job{args: %{}}) + + # Verify rules were applied + updated_track1 = Repo.get_by(Track, musicbrainz_id: "track-mbid-1") + assert updated_track1.album.musicbrainz_id == album_rule.target_musicbrainz_id + assert updated_track1.artist.musicbrainz_id == artist_rule.target_musicbrainz_id + + updated_track2 = Repo.get_by(Track, musicbrainz_id: "track-mbid-2") + # Only artist should be updated for track2 since album doesn't match + assert updated_track2.artist.musicbrainz_id == artist_rule.target_musicbrainz_id + # Should remain unchanged (nil or empty string) + assert updated_track2.album.musicbrainz_id in [nil, ""] + end + + test "handles errors gracefully" do + # Since we can't easily mock here, let's test with no rules + # which should still return :ok + assert :ok = Worker.perform(%Oban.Job{args: %{}}) + end + + test "handles empty rules list" do + # No rules exist + assert :ok = Worker.perform(%Oban.Job{args: %{}}) + end + end + + describe "run_now/0" do + test "enqueues job for immediate execution" do + assert {:ok, %Oban.Job{}} = Worker.run_now() + end + end +end diff --git a/test/music_library/scrobble_rules_test.exs b/test/music_library/scrobble_rules_test.exs new file mode 100644 index 00000000..cac64bb9 --- /dev/null +++ b/test/music_library/scrobble_rules_test.exs @@ -0,0 +1,301 @@ +defmodule MusicLibrary.ScrobbleRulesTest do + use MusicLibrary.DataCase + + alias LastFm.Track + alias MusicLibrary.ScrobbleRules + alias MusicLibrary.ScrobbleRules.ScrobbleRule + + describe "scrobble_rules" do + @valid_album_attrs %{ + type: "album", + match_value: "Dark Side of the Moon", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012", + enabled: true, + description: "Fix Pink Floyd album" + } + + @valid_artist_attrs %{ + type: "artist", + match_value: "Pink Floyd", + target_musicbrainz_id: "87654321-4321-4321-4321-210987654321", + enabled: true, + description: "Fix Pink Floyd artist" + } + + @invalid_attrs %{type: nil, match_value: nil, target_musicbrainz_id: nil} + + def scrobble_rule_fixture(attrs \\ %{}) do + {:ok, scrobble_rule} = + attrs + |> Enum.into(@valid_album_attrs) + |> ScrobbleRules.create_scrobble_rule() + + scrobble_rule + end + + def scrobbled_track_fixture(attrs \\ %{}) do + default_attrs = %{ + scrobbled_at_uts: System.system_time(:second), + musicbrainz_id: "track-mbid-12345", + title: "Breathe", + cover_url: "http://example.com/cover.jpg", + scrobbled_at_label: "01 Jan 2023, 12:00", + artist: %{ + musicbrainz_id: "", + name: "Pink Floyd" + }, + album: %{ + musicbrainz_id: "", + title: "Dark Side of the Moon" + }, + last_fm_data: %{} + } + + attrs = Enum.into(attrs, default_attrs) + + %Track{} + |> Track.changeset(attrs) + |> Repo.insert!() + end + + test "list_scrobble_rules/0 returns all scrobble_rules" do + scrobble_rule = scrobble_rule_fixture() + assert ScrobbleRules.list_scrobble_rules() == [scrobble_rule] + end + + test "list_scrobble_rules/1 filters by type" do + album_rule = scrobble_rule_fixture(@valid_album_attrs) + _artist_rule = scrobble_rule_fixture(@valid_artist_attrs) + + assert ScrobbleRules.list_scrobble_rules(type: "album") == [album_rule] + end + + test "list_scrobble_rules/1 filters by enabled status" do + enabled_rule = scrobble_rule_fixture(%{enabled: true}) + _disabled_rule = scrobble_rule_fixture(%{enabled: false, match_value: "Different Album"}) + + assert ScrobbleRules.list_scrobble_rules(enabled: true) == [enabled_rule] + end + + test "get_scrobble_rule!/1 returns the scrobble_rule with given id" do + scrobble_rule = scrobble_rule_fixture() + assert ScrobbleRules.get_scrobble_rule!(scrobble_rule.id) == scrobble_rule + end + + test "create_scrobble_rule/1 with valid data creates a scrobble_rule" do + valid_attrs = @valid_album_attrs + + assert {:ok, %ScrobbleRule{} = scrobble_rule} = + ScrobbleRules.create_scrobble_rule(valid_attrs) + + assert scrobble_rule.type == "album" + assert scrobble_rule.match_value == "Dark Side of the Moon" + assert scrobble_rule.target_musicbrainz_id == "12345678-1234-1234-1234-123456789012" + assert scrobble_rule.enabled == true + assert scrobble_rule.description == "Fix Pink Floyd album" + end + + test "create_scrobble_rule/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = ScrobbleRules.create_scrobble_rule(@invalid_attrs) + end + + test "update_scrobble_rule/2 with valid data updates the scrobble_rule" do + scrobble_rule = scrobble_rule_fixture() + update_attrs = %{enabled: false, description: "Updated description"} + + assert {:ok, %ScrobbleRule{} = scrobble_rule} = + ScrobbleRules.update_scrobble_rule(scrobble_rule, update_attrs) + + assert scrobble_rule.enabled == false + assert scrobble_rule.description == "Updated description" + end + + test "update_scrobble_rule/2 with invalid data returns error changeset" do + scrobble_rule = scrobble_rule_fixture() + + assert {:error, %Ecto.Changeset{}} = + ScrobbleRules.update_scrobble_rule(scrobble_rule, @invalid_attrs) + + assert scrobble_rule == ScrobbleRules.get_scrobble_rule!(scrobble_rule.id) + end + + test "delete_scrobble_rule/1 deletes the scrobble_rule" do + scrobble_rule = scrobble_rule_fixture() + assert {:ok, %ScrobbleRule{}} = ScrobbleRules.delete_scrobble_rule(scrobble_rule) + + assert_raise Ecto.NoResultsError, fn -> + ScrobbleRules.get_scrobble_rule!(scrobble_rule.id) + end + end + + test "change_scrobble_rule/1 returns a scrobble_rule changeset" do + scrobble_rule = scrobble_rule_fixture() + assert %Ecto.Changeset{} = ScrobbleRules.change_scrobble_rule(scrobble_rule) + end + + test "list_enabled_rules/0 returns only enabled rules" do + enabled_rule = scrobble_rule_fixture(%{enabled: true}) + _disabled_rule = scrobble_rule_fixture(%{enabled: false, match_value: "Different Album"}) + + assert ScrobbleRules.list_enabled_rules() == [enabled_rule] + end + end + + describe "rule application" do + test "apply_album_rule/1 updates matching tracks" do + rule = scrobble_rule_fixture(@valid_album_attrs) + + # Create matching track + track1 = + scrobbled_track_fixture(%{ + album: %{musicbrainz_id: "", title: "Dark Side of the Moon"} + }) + + # Create non-matching track + _track2 = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + album: %{musicbrainz_id: "", title: "Wish You Were Here"} + }) + + assert {:ok, 1} = ScrobbleRules.apply_album_rule(rule) + + # Verify the matching track was updated + updated_track = Repo.get_by(Track, scrobbled_at_uts: track1.scrobbled_at_uts) + assert updated_track.album.musicbrainz_id == rule.target_musicbrainz_id + end + + test "apply_artist_rule/1 updates matching tracks" do + rule = scrobble_rule_fixture(@valid_artist_attrs) + + # Create matching track + track1 = + scrobbled_track_fixture(%{ + artist: %{musicbrainz_id: "", name: "Pink Floyd"} + }) + + # Create non-matching track + _track2 = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + artist: %{musicbrainz_id: "", name: "Led Zeppelin"} + }) + + assert {:ok, 1} = ScrobbleRules.apply_artist_rule(rule) + + # Verify the matching track was updated + updated_track = Repo.get_by(Track, scrobbled_at_uts: track1.scrobbled_at_uts) + assert updated_track.artist.musicbrainz_id == rule.target_musicbrainz_id + end + + test "apply_album_rule/1 with wrong type returns error" do + rule = scrobble_rule_fixture(@valid_artist_attrs) + assert {:error, _reason} = ScrobbleRules.apply_album_rule(rule) + end + + test "apply_artist_rule/1 with wrong type returns error" do + rule = scrobble_rule_fixture(@valid_album_attrs) + assert {:error, _reason} = ScrobbleRules.apply_artist_rule(rule) + end + + test "apply_rule/1 delegates to correct function based on type" do + album_rule = scrobble_rule_fixture(@valid_album_attrs) + artist_rule = scrobble_rule_fixture(@valid_artist_attrs) + + # Create test tracks that only match their respective rules + _album_track = + scrobbled_track_fixture(%{ + album: %{musicbrainz_id: "", title: "Dark Side of the Moon"}, + artist: %{musicbrainz_id: "", name: "Different Artist"} + }) + + _artist_track = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + artist: %{musicbrainz_id: "", name: "Pink Floyd"}, + album: %{musicbrainz_id: "", title: "Different Album"} + }) + + assert {:ok, 1} = ScrobbleRules.apply_rule(album_rule) + assert {:ok, 1} = ScrobbleRules.apply_rule(artist_rule) + end + + test "apply_all_rules/0 applies all enabled rules" do + _album_rule = scrobble_rule_fixture(@valid_album_attrs) + _artist_rule = scrobble_rule_fixture(@valid_artist_attrs) + _disabled_rule = scrobble_rule_fixture(%{enabled: false, match_value: "Disabled Album"}) + + # Create test tracks that only match their respective rules + _album_track = + scrobbled_track_fixture(%{ + album: %{musicbrainz_id: "", title: "Dark Side of the Moon"}, + artist: %{musicbrainz_id: "", name: "Different Artist"} + }) + + _artist_track = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + artist: %{musicbrainz_id: "", name: "Pink Floyd"}, + album: %{musicbrainz_id: "", title: "Different Album"} + }) + + assert {:ok, results} = ScrobbleRules.apply_all_rules() + assert length(results) == 2 + + # Verify all results are successful + Enum.each(results, fn result -> + assert {:ok, {_type, _match_value, _count}} = result + end) + end + + test "count_album_matches/1 returns correct count" do + rule = scrobble_rule_fixture(@valid_album_attrs) + + # Create matching tracks + _track1 = + scrobbled_track_fixture(%{ + album: %{musicbrainz_id: "", title: "Dark Side of the Moon"} + }) + + _track2 = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + album: %{musicbrainz_id: "", title: "Dark Side of the Moon"} + }) + + # Create non-matching track + _track3 = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 2, + album: %{musicbrainz_id: "", title: "Wish You Were Here"} + }) + + assert ScrobbleRules.count_album_matches(rule) == 2 + end + + test "count_artist_matches/1 returns correct count" do + rule = scrobble_rule_fixture(@valid_artist_attrs) + + # Create matching tracks + _track1 = + scrobbled_track_fixture(%{ + artist: %{musicbrainz_id: "", name: "Pink Floyd"} + }) + + _track2 = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 1, + artist: %{musicbrainz_id: "", name: "Pink Floyd"} + }) + + # Create non-matching track + _track3 = + scrobbled_track_fixture(%{ + scrobbled_at_uts: System.system_time(:second) + 2, + artist: %{musicbrainz_id: "", name: "Led Zeppelin"} + }) + + assert ScrobbleRules.count_artist_matches(rule) == 2 + end + end +end diff --git a/test/music_library_web/live/scrobble_rules_live_test.exs b/test/music_library_web/live/scrobble_rules_live_test.exs new file mode 100644 index 00000000..7041d135 --- /dev/null +++ b/test/music_library_web/live/scrobble_rules_live_test.exs @@ -0,0 +1,190 @@ +defmodule MusicLibraryWeb.ScrobbleRulesLiveTest do + use MusicLibraryWeb.ConnCase + + import MusicLibrary.ScrobbleRulesFixtures + import Phoenix.LiveViewTest + + alias MusicLibrary.ScrobbleRules + + # Test data + @invalid_attrs %{type: "", match_value: "", target_musicbrainz_id: ""} + @valid_attrs %{ + type: "album", + match_value: "some match_value", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012", + description: "some description", + enabled: "true" + } + @update_attrs %{ + type: "artist", + match_value: "some updated match_value", + target_musicbrainz_id: "87654321-4321-4321-4321-210987654321", + description: "some updated description" + } + + defp create_scrobble_rule(_) do + scrobble_rule = scrobble_rule_fixture() + %{scrobble_rule: scrobble_rule} + end + + describe "Index" do + setup [:create_scrobble_rule] + + test "lists all scrobble_rules", %{conn: conn, scrobble_rule: scrobble_rule} do + {:ok, _index_live, html} = live(conn, ~p"/scrobble-rules") + + assert html =~ "Scrobble Rules" + assert html =~ scrobble_rule.match_value + end + + test "saves new scrobble_rule", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live |> element("a", "New Rule") |> render_click() =~ + "New Scrobble Rule" + + assert_patch(index_live, ~p"/scrobble-rules/new") + + assert index_live + |> form("#scrobble_rule-form", scrobble_rule: @invalid_attrs) + |> render_change() =~ "can't be blank" + + index_live + |> form("#scrobble_rule-form", scrobble_rule: @valid_attrs) + |> render_submit() + + # The rule should be created and visible in the list + html = render(index_live) + assert html =~ "some match_value" + end + + test "updates scrobble_rule in listing", %{conn: conn, scrobble_rule: scrobble_rule} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live + |> element("#scrobble_rules-#{scrobble_rule.id} a[href*='edit']") + |> render_click() =~ + "Edit Scrobble Rule" + + assert_patch(index_live, ~p"/scrobble-rules/#{scrobble_rule}/edit") + + assert index_live + |> form("#scrobble_rule-form", scrobble_rule: @invalid_attrs) + |> render_change() =~ "can't be blank" + + html = + index_live + |> form("#scrobble_rule-form", scrobble_rule: @update_attrs) + |> render_submit() + + assert html =~ "Scrobble rule updated successfully" + assert html =~ "some updated match_value" + end + + test "deletes scrobble_rule in listing", %{conn: conn, scrobble_rule: scrobble_rule} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live + |> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='delete']") + |> render_click() + + refute has_element?(index_live, "#scrobble_rule-#{scrobble_rule.id}") + end + + test "toggles rule enabled status", %{conn: conn, scrobble_rule: scrobble_rule} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + # Toggle to disabled + assert index_live + |> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='toggle_enabled']") + |> render_click() + + # Check that the rule was disabled + updated_rule = ScrobbleRules.get_scrobble_rule!(scrobble_rule.id) + refute updated_rule.enabled + + # Toggle back to enabled + assert index_live + |> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='toggle_enabled']") + |> render_click() + + # Check that the rule was enabled again + updated_rule = ScrobbleRules.get_scrobble_rule!(scrobble_rule.id) + assert updated_rule.enabled + end + + test "applies individual rule", %{conn: conn, scrobble_rule: scrobble_rule} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live + |> element("#scrobble_rules-#{scrobble_rule.id} button[phx-click='apply_rule']") + |> render_click() + + # Should show success message (even if no tracks were updated) + assert render(index_live) =~ "Rule applied successfully" + end + + test "applies all rules", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live + |> element("button[phx-click='apply_all_rules']") + |> render_click() + + # Should show success message + assert render(index_live) =~ "All rules applied successfully" + end + end + + describe "Form validation" do + test "shows validation errors for missing required fields", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live |> element("a", "New Rule") |> render_click() + + assert index_live + |> form("#scrobble_rule-form", + scrobble_rule: %{type: "", match_value: "", target_musicbrainz_id: ""} + ) + |> render_change() =~ "can't be blank" + end + + test "shows validation errors for invalid MusicBrainz ID", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live |> element("a", "New Rule") |> render_click() + + assert index_live + |> form("#scrobble_rule-form", + scrobble_rule: %{ + type: "album", + match_value: "Test Album", + target_musicbrainz_id: "invalid-uuid" + } + ) + |> render_change() =~ "must be a valid MusicBrainz ID" + end + + test "updates form labels based on rule type", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/scrobble-rules") + + assert index_live |> element("a", "New Rule") |> render_click() + + # Select album type + html = + index_live + |> form("#scrobble_rule-form", scrobble_rule: %{type: "album"}) + |> render_change() + + assert html =~ "Album Title" + + # Select artist type + html = + index_live + |> form("#scrobble_rule-form", scrobble_rule: %{type: "artist"}) + |> render_change() + + assert html =~ "Artist Name" + end + end +end diff --git a/test/support/fixtures/scrobble_rules_fixtures.ex b/test/support/fixtures/scrobble_rules_fixtures.ex new file mode 100644 index 00000000..174cca27 --- /dev/null +++ b/test/support/fixtures/scrobble_rules_fixtures.ex @@ -0,0 +1,28 @@ +defmodule MusicLibrary.ScrobbleRulesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `MusicLibrary.ScrobbleRules` context. + """ + + alias MusicLibrary.ScrobbleRules + + @doc """ + Generate a scrobble_rule. + """ + def scrobble_rule_fixture(attrs \\ %{}) do + default_attrs = %{ + type: "album", + match_value: "Dark Side of the Moon", + target_musicbrainz_id: "12345678-1234-1234-1234-123456789012", + enabled: true, + description: "Fix Pink Floyd album" + } + + {:ok, scrobble_rule} = + attrs + |> Enum.into(default_attrs) + |> ScrobbleRules.create_scrobble_rule() + + scrobble_rule + end +end