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:
@@ -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
|
||||
|
||||
+3
-1
@@ -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}
|
||||
]}
|
||||
]
|
||||
|
||||
|
||||
@@ -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 <https://docs.fluxonui.com/1.1.4/overview.html>.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user