Scrobbled tracks CRUD

- Failing tests
- Warnings
This commit is contained in:
Claudio Ortolina
2025-09-03 11:57:56 +03:00
parent 59b00502d3
commit dae334b1b7
18 changed files with 1735 additions and 17 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
"Bash(mix assets.deploy:*)",
"mcp__tidewave__get_logs",
"mcp__tidewave__project_eval",
"Bash(mix format:*)"
"Bash(mix format:*)",
"Bash(mise run dev:static-checks:*)"
]
},
"enableAllProjectMcpServers": false
+278
View File
@@ -0,0 +1,278 @@
# Scrobbled Tracks Management UI Plan
## Overview
Build a complete UI for managing scrobbled tracks with index, edit, and delete functionality. The interface will be accessible through the "More" menu in the top navigation and will follow existing patterns from collection and wishlist management.
## 1. Database & Context Layer
### Existing Schema
The `LastFm.Track` schema (`lib/last_fm/track.ex`) already exists with:
- Primary key: `scrobbled_at_uts` (Unix timestamp)
- Fields: `musicbrainz_id`, `title`, `cover_url`, `scrobbled_at_label`
- Embedded schemas: `artist` and `album`
- Raw `last_fm_data` map
### Context Functions Needed
Add to `LastFm` module (`lib/last_fm.ex`):
- `list_tracks/1` - Paginated track listing with search support
- `get_track!/1` - Individual track retrieval by `scrobbled_at_uts`
- `update_track/2` - Track metadata updates
- `delete_track/1` - Track deletion
- `search_tracks_count/1` - Count for pagination totals
## 2. LiveView Components
### Index LiveView
**File**: `lib/music_library_web/live/scrobbled_tracks_live/index.ex`
**Pattern**: Follow `CollectionLive.Index` structure
**Features**:
- Paginated list (200 items per page)
- Search functionality by track title/artist name
- Stream-based updates for performance (`phx-update="stream"`)
- Delete confirmation modals
- Edit links to individual track editing
**Default Parameters**:
```elixir
@default_tracks_list_params %{
query: "",
page: 1,
page_size: 200,
order: :scrobbled_at
}
```
### Edit Form Component
**File**: `lib/music_library_web/live/scrobbled_tracks_live/form_component.ex`
**Pattern**: Follow existing form components (e.g., `ScrobbleRulesLive.FormComponent`)
**Editable Fields**:
- Track title
- Artist name
- Album title
- Scrobbled date/time
- Cover URL (optional)
## 3. Templates & UI Design
### Index Template
**File**: `lib/music_library_web/live/scrobbled_tracks_live/index.html.heex`
**Layout**: Responsive card/list layout similar to collection
**Components**:
- Search bar at top with debounced input
- Track cards displaying:
- Cover image (with fallback for missing covers)
- Track title
- Artist name
- Album title
- Scrobbled date/time
- Edit/Delete action buttons
- Pagination component at bottom using `MusicLibraryWeb.PaginationComponent`
- Empty state message when no tracks found
### Form Component Template
**Features**:
- Form inputs for editable track metadata
- Client-side and server-side validation
- Cancel/Save buttons with proper form handling
- Error display for validation failures
## 4. Routing & Navigation
### Router Updates
**File**: `lib/music_library_web/router.ex`
Add to the logged-in scope around line 65:
```elixir
live "/scrobbled-tracks", ScrobbledTracksLive.Index, :index
live "/scrobbled-tracks/:scrobbled_at_uts/edit", ScrobbledTracksLive.Index, :edit
```
### Navigation Integration
**File**: `lib/music_library_web/components/layouts/app.html.heex`
Add to the "More" dropdown menu (after line 58):
```elixir
<.dropdown_link href={~p"/scrobbled-tracks"}>
<.icon name="hero-musical-note" class="h-4 w-4 mr-2" aria-hidden="true" data-slot="icon" />
{gettext("Scrobbled Tracks")}
</.dropdown_link>
```
## 5. Implementation Details
### Pagination Integration
- **Pattern**: Follow `CollectionLive.Index` pagination approach
- **Component**: Reuse `MusicLibraryWeb.PaginationComponent`
- **Parameters**: Support `query`, `page`, `page_size`, and `order`
- **URL Structure**: `/scrobbled-tracks?page=2&query=artist&page_size=50`
### Stream Implementation
- Use `stream(:tracks, tracks)` for efficient rendering of large track lists
- Implement `stream_delete(socket, :tracks, track)` for track deletion
- Handle pagination with `reset: true` for filtered results
- Use unique DOM IDs based on `scrobbled_at_uts`
### Search Functionality
- Search across track title, artist name, and album title
- Use database LIKE queries for basic string matching
- Debounced search input with LiveView `phx-change` events
- Reset to page 1 when search query changes
### Delete Confirmation
- Use modal overlay for delete confirmation
- Show track details (title, artist, date) in confirmation modal
- Implement optimistic UI updates with `stream_delete`
- Handle deletion errors gracefully
### Error Handling
- Validate track existence before edit operations
- Handle database constraint violations
- Display user-friendly error messages
- Graceful fallbacks for missing track data
## 6. File Structure
```
lib/music_library_web/live/scrobbled_tracks_live/
├── index.ex # Main LiveView controller
├── index.html.heex # Index template with track listing
└── form_component.ex # Edit form component
docs/plans/
└── scrobbled-tracks-crud.md # This plan document
```
## 7. Implementation Steps
1. **Create context functions** in `MusicLibrary.ScrobbleActivity` module for CRUD operations
2. **Implement pagination logic** following existing `CollectionLive.Index` patterns
3. **Create LiveView** with stream-based track listing and search
4. **Add search functionality** with query parameter handling and debouncing
5. **Implement edit form** with validation and error handling
6. **Add delete functionality** with confirmation modal
7. **Update navigation** to include "Scrobbled Tracks" link in More dropdown
8. **Add routing** for index and edit actions
9. **Style components** using Tailwind CSS and Fluxon UI components
10. **Add comprehensive tests** following existing test patterns
## 8. Technical Considerations
### Primary Key Handling
- Use `scrobbled_at_uts` as unique identifier in routes and database queries
- Handle potential timestamp collisions gracefully
- Consider compound keys if needed for uniqueness
### Performance Optimization
- Use LiveView streams for efficient rendering of large lists
- Implement database indexes on searchable fields
- Paginate results to avoid memory issues
- Use debounced search to reduce database load
### Data Integrity
- Validate required fields (track title, artist name)
- Handle embedded schema updates (artist, album data)
- Preserve Last.fm API data integrity
- Consider soft deletes vs hard deletes
### User Experience
- Consistent styling with existing collection/wishlist pages
- Responsive design for mobile/tablet viewing
- Loading states during pagination and search
- Clear feedback for user actions (edit, delete)
- Accessible keyboard navigation
### Security & Permissions
- Ensure proper authentication via `:logged_in` pipeline
- Validate user permissions for track modifications
- Sanitize input data to prevent XSS
- Use Phoenix CSRF protection
## 9. Testing Strategy
### Unit Tests
- Context function tests for CRUD operations
- LiveView event handling tests
- Form validation tests
- Pagination logic tests
### Integration Tests
- Full user flow tests (search, edit, delete)
- Navigation integration tests
- Error handling scenarios
- Responsive layout tests
### Test Files
```
test/music_library_web/live/scrobbled_tracks_live_test.exs
test/last_fm_test.exs (extend existing)
test/support/fixtures/scrobbled_tracks_fixtures.ex
```
## 10. Localization
Add gettext entries for:
- Page titles and headings
- Form labels and placeholders
- Error messages
- Action button text
- Empty state messages
- Navigation menu item
## 11. Future Enhancements
### Potential Extensions
- Bulk operations (delete multiple tracks)
- Export functionality (CSV, JSON)
- Advanced filtering (date ranges, specific artists)
- Scrobble statistics and analytics
- Integration with music streaming services
- Track matching with collection records
### Performance Improvements
- Virtual scrolling for very large lists
- Background track synchronization
- Caching for frequently accessed data
- Search index optimization
This plan ensures consistency with existing codebase patterns while providing a complete, user-friendly interface for scrobbled tracks management.
+1
View File
@@ -15,5 +15,6 @@ defmodule LastFm.Album do
def changeset(album, attrs) do
album
|> cast(attrs, [:musicbrainz_id, :title])
|> validate_required([:title])
end
end
+1
View File
@@ -50,6 +50,7 @@ defmodule LastFm.Artist do
:on_tour,
:base_url
])
|> validate_required([:name])
end
def events_url(artist) do
+3 -3
View File
@@ -29,8 +29,8 @@ defmodule LastFm.Track do
field :cover_url, :string
field :scrobbled_at_label, :string
embeds_one :artist, Artist
embeds_one :album, Album
embeds_one :artist, Artist, on_replace: :update
embeds_one :album, Album, on_replace: :update
field :last_fm_data, :map, default: %{}
end
@@ -89,6 +89,6 @@ defmodule LastFm.Track do
])
|> cast_embed(:artist)
|> cast_embed(:album)
|> validate_required([:scrobbled_at_uts])
|> validate_required([:scrobbled_at_uts, :title])
end
end
+96
View File
@@ -376,4 +376,100 @@ defmodule MusicLibrary.ScrobbleActivity do
:last_365_days -> get_top_artists_by_days(365, opts)
end
end
@doc """
Lists scrobbled tracks with pagination and search support.
"""
def list_tracks(params \\ %{}) do
query = Map.get(params, :query, "")
page = Map.get(params, :page, 1)
page_size = Map.get(params, :page_size, 200)
order = Map.get(params, :order, :scrobbled_at)
base_query = from(t in Track)
search_query =
if query == "" do
base_query
else
query_term = "%#{String.downcase(query)}%"
from t in base_query,
where:
like(fragment("lower(?)", t.title), ^query_term) or
like(fragment("lower(json_extract(artist, '$.name'))"), ^query_term) or
like(fragment("lower(json_extract(album, '$.title'))"), ^query_term)
end
ordered_query =
case order do
:scrobbled_at ->
from t in search_query, order_by: [desc: t.scrobbled_at_uts]
:title ->
from t in search_query, order_by: [asc: t.title]
:artist ->
from t in search_query, order_by: [asc: fragment("json_extract(artist, '$.name')")]
:album ->
from t in search_query, order_by: [asc: fragment("json_extract(album, '$.title')")]
end
offset = (page - 1) * page_size
from(t in ordered_query, limit: ^page_size, offset: ^offset)
|> Repo.all()
end
@doc """
Gets a single track by scrobbled_at_uts.
"""
def get_track!(scrobbled_at_uts) when is_integer(scrobbled_at_uts) do
Repo.get!(Track, scrobbled_at_uts)
end
def get_track!(scrobbled_at_uts) when is_binary(scrobbled_at_uts) do
case Integer.parse(scrobbled_at_uts) do
{id, ""} -> get_track!(id)
_ -> raise Ecto.NoResultsError, queryable: Track
end
end
@doc """
Updates a track with the given attributes.
"""
def update_track(%Track{} = track, attrs) do
changeset = Track.changeset(track, attrs)
Repo.update(changeset)
end
@doc """
Deletes a track.
"""
def delete_track(%Track{} = track) do
Repo.delete(track)
end
@doc """
Counts tracks matching the search query.
"""
def search_tracks_count(query \\ "") do
base_query = from(t in Track)
search_query =
if query == "" do
base_query
else
query_term = "%#{String.downcase(query)}%"
from t in base_query,
where:
like(fragment("lower(?)", t.title), ^query_term) or
like(fragment("lower(json_extract(artist, '$.name'))"), ^query_term) or
like(fragment("lower(json_extract(album, '$.title'))"), ^query_term)
end
Repo.aggregate(search_query, :count, :scrobbled_at_uts)
end
end
@@ -48,6 +48,15 @@
<.icon name="hero-chevron-down" class="h-4 w-4" />
</button>
</:toggle>
<.dropdown_link href={~p"/scrobbled-tracks"}>
<.icon
name="hero-musical-note"
class="h-4 w-4 mr-2"
aria-hidden="true"
data-slot="icon"
/>
{gettext("Scrobbled Tracks")}
</.dropdown_link>
<.dropdown_link href={~p"/scrobble-rules"}>
<.icon
name="hero-adjustments-horizontal"
@@ -90,7 +90,10 @@ defmodule MusicLibraryWeb.PaginationComponent do
attr :page_number, :integer, required: true
attr :page_size, :integer, required: true
attr :query, :string, required: true
attr :order, :atom, values: [:alphabetical, :purchase], required: true
attr :order, :atom,
values: [:alphabetical, :purchase, :scrobbled_at, :title, :artist, :album],
required: true
defp next_link(assigns) do
~H"""
@@ -107,7 +110,10 @@ defmodule MusicLibraryWeb.PaginationComponent do
attr :page_number, :integer, required: true
attr :page_size, :integer, required: true
attr :query, :string, required: true
attr :order, :atom, values: [:alphabetical, :purchase], required: true
attr :order, :atom,
values: [:alphabetical, :purchase, :scrobbled_at, :title, :artist, :album],
required: true
defp prev_link(assigns) do
~H"""
@@ -133,7 +139,10 @@ defmodule MusicLibraryWeb.PaginationComponent do
attr :page_size, :integer, required: true
attr :active, :boolean, default: false
attr :query, :string, required: true
attr :order, :atom, values: [:alphabetical, :purchase], required: true
attr :order, :atom,
values: [:alphabetical, :purchase, :scrobbled_at, :title, :artist, :album],
required: true
defp numbered_link(assigns) when assigns.active do
~H"""
@@ -0,0 +1,109 @@
defmodule MusicLibraryWeb.ScrobbledTracksLive.FormComponent do
use MusicLibraryWeb, :live_component
alias MusicLibrary.ScrobbleActivity
alias LastFm.Track
@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">
{gettext("Edit Scrobbled Track")}
</h1>
</header>
<.simple_form
for={@form}
id="track-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input
field={@form[:title]}
type="text"
label={gettext("Track Title")}
placeholder={gettext("Track name")}
/>
<.inputs_for :let={artist} field={@form[:artist]}>
<.input
field={artist[:name]}
type="text"
label={gettext("Artist Name")}
placeholder={gettext("Artist name")}
/>
</.inputs_for>
<.inputs_for :let={album} field={@form[:album]}>
<.input
field={album[:title]}
type="text"
label={gettext("Album Title")}
placeholder={gettext("Album name")}
/>
</.inputs_for>
<.input
field={@form[:scrobbled_at_label]}
type="text"
label={gettext("Scrobbled Date/Time")}
placeholder={gettext("Format: DD/MM/YYYY HH:MM:SS")}
/>
<.input
field={@form[:cover_url]}
type="url"
label={gettext("Cover Image URL (optional)")}
placeholder="https://example.com/cover.jpg"
/>
<:actions>
<.button phx-disable-with={gettext("Saving...")}>
{gettext("Update Track")}
</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(%{track: track} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:form, fn ->
to_form(Track.changeset(track, %{}))
end)}
end
@impl true
def handle_event("validate", %{"track" => track_params}, socket) do
changeset = Track.changeset(socket.assigns.track, track_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"track" => track_params}, socket) do
save_track(socket, track_params)
end
defp save_track(socket, track_params) do
case ScrobbleActivity.update_track(socket.assigns.track, track_params) do
{:ok, track} ->
notify_parent({:saved, track})
{:noreply,
socket
|> put_toast(:info, gettext("Track updated 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})
end
@@ -0,0 +1,160 @@
defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
use MusicLibraryWeb, :live_view
import MusicLibraryWeb.PaginationComponent
import MusicLibraryWeb.RecordComponents, only: [search_form: 1]
alias MusicLibrary.ScrobbleActivity
alias LastFm.Track
@default_tracks_list_params %{
query: "",
page: 1,
page_size: 200,
order: :scrobbled_at
}
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:current_section, :scrobble_activity)
|> stream_configure(:tracks, dom_id: fn %Track{scrobbled_at_uts: id} -> "tracks-#{id}" end)
{:ok, socket}
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, %{"scrobbled_at_uts" => id} = params) do
track = ScrobbleActivity.get_track!(id)
socket
|> apply_fallback_index(params)
|> assign(:page_title, gettext("Edit Track"))
|> assign(:track, track)
|> assign(:form, to_form(Track.changeset(track, %{})))
end
defp apply_action(socket, :index, params) do
query = params["query"] || ""
order = parse_order(params["order"] || "scrobbled_at")
total_tracks = ScrobbleActivity.search_tracks_count(query)
track_list_params =
@default_tracks_list_params
|> merge_query(query)
|> merge_order(order)
|> merge_pagination(params, total_tracks)
load_and_assign_tracks(socket, track_list_params)
end
def apply_fallback_index(socket, params) do
if get_in(socket.assigns, [:streams, :tracks]) == nil do
socket
|> apply_action(:index, params)
else
socket
end
end
@impl true
def handle_info({MusicLibraryWeb.ScrobbledTracksLive.FormComponent, {:saved, _track}}, socket) do
{:noreply, load_and_assign_tracks(socket, socket.assigns.track_list_params)}
end
@impl true
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
track = ScrobbleActivity.get_track!(scrobbled_at_uts)
{:ok, _} = ScrobbleActivity.delete_track(track)
{:noreply, stream_delete(socket, :tracks, track)}
end
def handle_event("search", %{"query" => query}, socket) do
qs =
@default_tracks_list_params
|> Map.put(:query, query)
|> Map.take([:query, :page, :page_size])
{:noreply, push_patch(socket, to: ~p"/scrobbled-tracks?#{qs}")}
end
defp parse_order("scrobbled_at"), do: :scrobbled_at
defp parse_order("title"), do: :title
defp parse_order("artist"), do: :artist
defp parse_order("album"), do: :album
defp parse_order(_), do: :scrobbled_at
defp merge_query(params, query), do: Map.put(params, :query, query)
defp merge_order(params, order), do: Map.put(params, :order, order)
defp merge_pagination(params, url_params, total_records) do
page = parse_page(url_params["page"])
page_size = parse_page_size(url_params["page_size"])
params
|> Map.put(:page, page)
|> Map.put(:page_size, page_size)
|> Map.put(:total_records, total_records)
end
defp parse_page(nil), do: 1
defp parse_page(page) when is_binary(page) do
case Integer.parse(page) do
{num, ""} when num > 0 -> num
_ -> 1
end
end
defp parse_page(_), do: 1
defp parse_page_size(nil), do: 200
defp parse_page_size(page_size) when is_binary(page_size) do
case Integer.parse(page_size) do
{num, ""} when num in [50, 100, 200, 500] -> num
_ -> 200
end
end
defp parse_page_size(_), do: 200
defp load_and_assign_tracks(socket, track_list_params) do
tracks = ScrobbleActivity.list_tracks(track_list_params)
tracks_empty? = tracks == []
# Add total_entries for pagination component
track_list_params_with_total =
Map.put(track_list_params, :total_entries, track_list_params.total_records)
socket
|> assign(:track_list_params, track_list_params_with_total)
|> assign(:tracks_empty?, tracks_empty?)
|> assign(:page_title, gettext("Scrobbled Tracks"))
|> stream(:tracks, tracks, reset: true)
end
def order_path(track_list_params, order) do
qs =
track_list_params
|> Map.put(:order, order)
|> Map.put(:page, 1)
|> Map.take([:query, :page, :page_size, :order])
~p"/scrobbled-tracks?#{qs}"
end
def back_path(track_list_params) do
qs =
track_list_params
|> Map.take([:query, :page, :page_size, :order])
~p"/scrobbled-tracks?#{qs}"
end
end
@@ -0,0 +1,166 @@
<div>
<header class="gap-6 mb-2">
<div class="flex items-center justify-between gap-6 mb-2 mt-2">
<.search_form query={@track_list_params.query} />
<div class="text-sm text-zinc-500 dark:text-zinc-400">
{ngettext("1 track", "%{count} tracks", @track_list_params.total_records)}
</div>
</div>
</header>
<div class="flex items-end justify-between gap-6 mt-8">
<.button_group>
<.button
patch={order_path(@track_list_params, :scrobbled_at)}
size="sm"
class={[
@track_list_params.order == :scrobbled_at && "!bg-zinc-100 dark:!bg-zinc-700"
]}
>
<.icon
name="hero-clock-solid"
class="icon"
aria-hidden="true"
data-slot="icon"
/>
{gettext("Scrobbled")}
</.button>
<.button
patch={order_path(@track_list_params, :title)}
size="sm"
class={[
@track_list_params.order == :title && "!bg-zinc-100 dark:!bg-zinc-700"
]}
>
<.icon name="hero-musical-note-solid" class="icon" aria-hidden="true" data-slot="icon" />
{gettext("Title")}
</.button>
<.button
patch={order_path(@track_list_params, :artist)}
size="sm"
class={[
@track_list_params.order == :artist && "!bg-zinc-100 dark:!bg-zinc-700"
]}
>
<.icon name="hero-user-solid" class="icon" aria-hidden="true" data-slot="icon" />
{gettext("Artist")}
</.button>
<.button
patch={order_path(@track_list_params, :album)}
size="sm"
class={[
@track_list_params.order == :album && "!bg-zinc-100 dark:!bg-zinc-700"
]}
>
<.icon name="hero-musical-note-solid" class="icon" aria-hidden="true" data-slot="icon" />
{gettext("Album")}
</.button>
</.button_group>
</div>
</div>
<div
:if={@tracks_empty?}
class="mt-8 p-8 text-center bg-zinc-50 dark:bg-zinc-800 rounded-lg"
>
<.icon name="hero-musical-note" class="h-12 w-12 text-zinc-400 mx-auto mb-4" />
<p class="text-zinc-600 dark:text-zinc-400">
{gettext("No scrobbled tracks found")}
</p>
<p :if={@track_list_params.query != ""} class="text-sm text-zinc-500 dark:text-zinc-500 mt-2">
{gettext("Try adjusting your search or clearing the filter")}
</p>
</div>
<div :if={!@tracks_empty?} class="mt-6">
<div id="tracks" phx-update="stream" class="grid gap-4">
<div
id="empty-state"
class="hidden only:block p-8 text-center bg-zinc-50 dark:bg-zinc-800 rounded-lg"
>
<.icon name="hero-musical-note" class="h-12 w-12 text-zinc-400 mx-auto mb-4" />
<p class="text-zinc-600 dark:text-zinc-400">
{gettext("No scrobbled tracks found")}
</p>
</div>
<div
:for={{id, track} <- @streams.tracks}
id={id}
class="bg-white dark:bg-zinc-800 p-4 rounded-lg shadow-sm border border-zinc-200 dark:border-zinc-700"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4 flex-1 min-w-0">
<div :if={track.cover_url && track.cover_url != ""} class="flex-shrink-0">
<img
class="w-12 h-12 rounded-md object-cover"
src={track.cover_url}
alt={"Cover for " <> track.album.title}
onerror={"this.src = '" <> ~p"/images/cover-not-found.png" <> "';"}
/>
</div>
<div :if={!track.cover_url || track.cover_url == ""} class="flex-shrink-0">
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-600 rounded-md flex items-center justify-center">
<.icon name="hero-musical-note" class="h-6 w-6 text-zinc-400" />
</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
{track.title}
</p>
<p class="text-sm text-zinc-600 dark:text-zinc-400 truncate">
{track.artist.name}
</p>
<p class="text-sm text-zinc-500 dark:text-zinc-500 truncate">
{track.album.title}
</p>
<p class="text-xs text-zinc-400 dark:text-zinc-600 mt-1">
{track.scrobbled_at_label}
</p>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<.button
patch={~p"/scrobbled-tracks/#{track.scrobbled_at_uts}/edit"}
size="sm"
variant="outline"
>
<.icon name="hero-pencil" class="h-4 w-4" />
<span class="sr-only">{gettext("Edit track")}</span>
</.button>
<.button
phx-click={
JS.push("delete", value: %{"scrobbled-at-uts": track.scrobbled_at_uts})
|> JS.hide(to: "##{id}")
}
data-confirm={gettext("Are you sure you want to delete this scrobbled track?")}
size="sm"
variant="outline"
class="text-red-600 hover:text-red-800 border-red-200 hover:border-red-300"
>
<.icon name="hero-trash" class="h-4 w-4" />
<span class="sr-only">{gettext("Delete track")}</span>
</.button>
</div>
</div>
</div>
</div>
<.pagination id={:bottom_pagination} pagination_params={@track_list_params} />
</div>
<.structured_modal
:if={@live_action == :edit}
id="track-modal"
on_close={JS.patch(back_path(@track_list_params))}
>
<.live_component
module={MusicLibraryWeb.ScrobbledTracksLive.FormComponent}
id={@track.scrobbled_at_uts}
action={@live_action}
track={@track}
patch={back_path(@track_list_params)}
/>
</.structured_modal>
+3
View File
@@ -69,6 +69,9 @@ defmodule MusicLibraryWeb.Router do
live "/online-store-templates", OnlineStoreTemplateLive.Index, :index
live "/online-store-templates/new", OnlineStoreTemplateLive.Index, :new
live "/online-store-templates/:id/edit", OnlineStoreTemplateLive.Index, :edit
live "/scrobbled-tracks", ScrobbledTracksLive.Index, :index
live "/scrobbled-tracks/:scrobbled_at_uts/edit", ScrobbledTracksLive.Index, :edit
end
end
end
+7
View File
@@ -192,6 +192,13 @@ run = 'iex -S mix phx.server'
description = 'Preview the README file via a GH flavoured markdown rendered'
run = 'gh gfm-preview README.md'
[tasks."dev:fix-translations-conflict"]
run = [
'git co --theirs priv/gettext/default.pot',
'git co --theirs priv/gettext/en/LC_MESSAGES/default.po',
'mix gettext.extract --merge',
]
# DOCKER
[tasks."docker:build"]
+109 -5
View File
@@ -173,6 +173,7 @@ msgstr ""
#: lib/music_library_web/live/artist_live/form_component.ex
#: lib/music_library_web/live/online_store_template_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
@@ -438,6 +439,7 @@ msgstr ""
#: lib/music_library_web/components/record_components.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Album"
msgstr ""
@@ -962,6 +964,7 @@ msgid "Add a description to help identify this rule"
msgstr ""
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Album Title"
msgstr ""
@@ -973,11 +976,13 @@ msgstr ""
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Artist"
msgstr ""
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Artist Name"
msgstr ""
@@ -1160,6 +1165,7 @@ msgid "Apply All"
msgstr ""
#: lib/music_library_web/components/record_form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Title"
msgstr ""
@@ -1314,15 +1320,102 @@ msgstr ""
msgid "No records released on this day."
msgstr ""
#: 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/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Open Notes"
msgid "1 track"
msgid_plural "%{count} tracks"
msgstr[0] ""
msgstr[1] ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Album name"
msgstr ""
#: lib/music_library_web/components/notes_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Read"
msgid "Are you sure you want to delete this scrobbled track?"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Artist name"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Cover Image URL (optional)"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Delete track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Edit Scrobbled Track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
#, elixir-autogen, elixir-format
msgid "Edit Track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Edit track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Format: DD/MM/YYYY HH:MM:SS"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "No scrobbled tracks found"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Scrobbled"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Scrobbled Date/Time"
msgstr ""
#: lib/music_library_web/components/layouts/app.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
#, elixir-autogen, elixir-format
msgid "Scrobbled Tracks"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Track Title"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Track name"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Track updated successfully"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Try adjusting your search or clearing the filter"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Update Track"
msgstr ""
#: lib/music_library_web/components/notes_component.ex
@@ -1334,3 +1427,14 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Note updated successfully"
msgstr ""
#: lib/music_library_web/live/artist_live/show.html.heex
#: lib/music_library_web/live/collection_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Open Notes"
msgstr ""
#: lib/music_library_web/components/notes_component.ex
#, elixir-autogen, elixir-format
msgid "Read"
msgstr ""
+109 -5
View File
@@ -173,6 +173,7 @@ msgstr ""
#: lib/music_library_web/live/artist_live/form_component.ex
#: lib/music_library_web/live/online_store_template_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
@@ -438,6 +439,7 @@ msgstr ""
#: lib/music_library_web/components/record_components.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Album"
msgstr ""
@@ -962,6 +964,7 @@ msgid "Add a description to help identify this rule"
msgstr ""
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Album Title"
msgstr ""
@@ -973,11 +976,13 @@ msgstr ""
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Artist"
msgstr ""
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Artist Name"
msgstr ""
@@ -1160,6 +1165,7 @@ msgid "Apply All"
msgstr ""
#: lib/music_library_web/components/record_form_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Title"
msgstr ""
@@ -1314,15 +1320,102 @@ msgstr ""
msgid "No records released on this day."
msgstr ""
#: 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/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Open Notes"
msgid "1 track"
msgid_plural "%{count} tracks"
msgstr[0] ""
msgstr[1] ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Album name"
msgstr ""
#: lib/music_library_web/components/notes_component.ex
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to delete this scrobbled track?"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Read"
msgid "Artist name"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Cover Image URL (optional)"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Edit Scrobbled Track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
#, elixir-autogen, elixir-format
msgid "Edit Track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Edit track"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Format: DD/MM/YYYY HH:MM:SS"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "No scrobbled tracks found"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Scrobbled"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Scrobbled Date/Time"
msgstr ""
#: lib/music_library_web/components/layouts/app.html.heex
#: lib/music_library_web/live/scrobbled_tracks_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Scrobbled Tracks"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Track Title"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Track name"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Track updated successfully"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Try adjusting your search or clearing the filter"
msgstr ""
#: lib/music_library_web/live/scrobbled_tracks_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Update Track"
msgstr ""
#: lib/music_library_web/components/notes_component.ex
@@ -1334,3 +1427,14 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "Note updated successfully"
msgstr ""
#: lib/music_library_web/live/artist_live/show.html.heex
#: lib/music_library_web/live/collection_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Open Notes"
msgstr ""
#: lib/music_library_web/components/notes_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Read"
msgstr ""
@@ -0,0 +1,285 @@
defmodule MusicLibrary.ScrobbleActivityTest do
use MusicLibrary.DataCase
import MusicLibrary.ScrobbledTracksFixtures
alias LastFm.Track
alias MusicLibrary.{Repo, ScrobbleActivity}
describe "list_tracks/1" do
test "returns all tracks when no parameters provided" do
track1 = track_fixture(%{title: "First Track"})
track2 = track_fixture(%{title: "Second Track"})
tracks = ScrobbleActivity.list_tracks()
assert length(tracks) == 2
track_titles = Enum.map(tracks, & &1.title)
assert "First Track" in track_titles
assert "Second Track" in track_titles
end
test "returns tracks ordered by scrobbled_at_uts by default" do
older_track =
track_fixture(%{
title: "Older Track",
scrobbled_at_uts: System.system_time(:second) - 3600
})
newer_track =
track_fixture(%{
title: "Newer Track",
scrobbled_at_uts: System.system_time(:second)
})
tracks = ScrobbleActivity.list_tracks(%{order: :scrobbled_at})
assert length(tracks) == 2
# Should be ordered by scrobbled_at_uts descending (newest first)
assert List.first(tracks).title == "Newer Track"
assert List.last(tracks).title == "Older Track"
end
test "returns tracks ordered by title" do
track_fixture(%{title: "Zebra Track"})
track_fixture(%{title: "Alpha Track"})
tracks = ScrobbleActivity.list_tracks(%{order: :title})
assert length(tracks) == 2
assert List.first(tracks).title == "Alpha Track"
assert List.last(tracks).title == "Zebra Track"
end
test "returns tracks ordered by artist name" do
track_fixture(%{artist_name: "Zebra Artist", title: "Track 1"})
track_fixture(%{artist_name: "Alpha Artist", title: "Track 2"})
tracks = ScrobbleActivity.list_tracks(%{order: :artist})
assert length(tracks) == 2
assert List.first(tracks).artist.name == "Alpha Artist"
assert List.last(tracks).artist.name == "Zebra Artist"
end
test "returns tracks ordered by album title" do
track_fixture(%{album_title: "Zebra Album", title: "Track 1"})
track_fixture(%{album_title: "Alpha Album", title: "Track 2"})
tracks = ScrobbleActivity.list_tracks(%{order: :album})
assert length(tracks) == 2
assert List.first(tracks).album.title == "Alpha Album"
assert List.last(tracks).album.title == "Zebra Album"
end
test "filters tracks by search query matching track title" do
track_fixture(%{title: "Special Track"})
track_fixture(%{title: "Regular Track"})
tracks = ScrobbleActivity.list_tracks(%{query: "Special"})
assert length(tracks) == 1
assert List.first(tracks).title == "Special Track"
end
test "filters tracks by search query matching artist name" do
track_fixture(%{artist_name: "Special Artist", title: "Track 1"})
track_fixture(%{artist_name: "Regular Artist", title: "Track 2"})
tracks = ScrobbleActivity.list_tracks(%{query: "Special Artist"})
assert length(tracks) == 1
assert List.first(tracks).artist.name == "Special Artist"
end
test "filters tracks by search query matching album title" do
track_fixture(%{album_title: "Special Album", title: "Track 1"})
track_fixture(%{album_title: "Regular Album", title: "Track 2"})
tracks = ScrobbleActivity.list_tracks(%{query: "Special Album"})
assert length(tracks) == 1
assert List.first(tracks).album.title == "Special Album"
end
test "applies pagination correctly" do
create_test_tracks(5)
# Get first 2 tracks
tracks_page_1 = ScrobbleActivity.list_tracks(%{page: 1, page_size: 2})
assert length(tracks_page_1) == 2
# Get next 2 tracks
tracks_page_2 = ScrobbleActivity.list_tracks(%{page: 2, page_size: 2})
assert length(tracks_page_2) == 2
# Ensure they're different tracks
page_1_ids = Enum.map(tracks_page_1, & &1.scrobbled_at_uts)
page_2_ids = Enum.map(tracks_page_2, & &1.scrobbled_at_uts)
assert Enum.empty?(page_1_ids -- (page_1_ids -- page_2_ids))
end
test "returns empty list when query matches no tracks" do
track_fixture(%{title: "Test Track"})
tracks = ScrobbleActivity.list_tracks(%{query: "NonexistentTrack"})
assert tracks == []
end
end
describe "get_track!/1" do
test "returns the track with given scrobbled_at_uts as integer" do
track = track_fixture()
found_track = ScrobbleActivity.get_track!(track.scrobbled_at_uts)
assert found_track.scrobbled_at_uts == track.scrobbled_at_uts
assert found_track.title == track.title
end
test "returns the track with given scrobbled_at_uts as string" do
track = track_fixture()
found_track = ScrobbleActivity.get_track!(to_string(track.scrobbled_at_uts))
assert found_track.scrobbled_at_uts == track.scrobbled_at_uts
end
test "raises Ecto.NoResultsError when track does not exist" do
assert_raise Ecto.NoResultsError, fn ->
ScrobbleActivity.get_track!(999_999_999)
end
end
test "raises Ecto.NoResultsError when given invalid string" do
assert_raise Ecto.NoResultsError, fn ->
ScrobbleActivity.get_track!("invalid")
end
end
end
describe "update_track/2" do
test "updates the track with valid attributes" do
track = track_fixture(%{title: "Original Title"})
update_attrs = %{
title: "Updated Title",
artist: %{name: "Updated Artist"},
album: %{title: "Updated Album"}
}
assert {:ok, updated_track} = ScrobbleActivity.update_track(track, update_attrs)
assert updated_track.title == "Updated Title"
assert updated_track.artist.name == "Updated Artist"
assert updated_track.album.title == "Updated Album"
end
test "returns error changeset with invalid attributes" do
track = track_fixture()
invalid_attrs = %{title: ""}
assert {:error, %Ecto.Changeset{}} = ScrobbleActivity.update_track(track, invalid_attrs)
end
test "updates scrobbled_at_label" do
track = track_fixture(%{scrobbled_at_label: "01/01/2024 12:00:00"})
update_attrs = %{scrobbled_at_label: "02/02/2024 14:30:00"}
assert {:ok, updated_track} = ScrobbleActivity.update_track(track, update_attrs)
assert updated_track.scrobbled_at_label == "02/02/2024 14:30:00"
end
test "updates cover_url" do
track = track_fixture(%{cover_url: "https://example.com/old.jpg"})
update_attrs = %{cover_url: "https://example.com/new.jpg"}
assert {:ok, updated_track} = ScrobbleActivity.update_track(track, update_attrs)
assert updated_track.cover_url == "https://example.com/new.jpg"
end
end
describe "delete_track/1" do
test "deletes the track" do
track = track_fixture()
assert {:ok, %Track{}} = ScrobbleActivity.delete_track(track)
assert_raise Ecto.NoResultsError, fn ->
ScrobbleActivity.get_track!(track.scrobbled_at_uts)
end
end
test "returns error when track has already been deleted" do
track = track_fixture()
{:ok, _} = ScrobbleActivity.delete_track(track)
# Attempt to delete again should fail
assert_raise Ecto.StaleEntryError, fn ->
ScrobbleActivity.delete_track(track)
end
end
end
describe "search_tracks_count/1" do
test "returns total count when no query provided" do
create_test_tracks(3)
count = ScrobbleActivity.search_tracks_count()
assert count == 3
end
test "returns filtered count when query provided" do
track_fixture(%{title: "Special Track"})
track_fixture(%{title: "Regular Track"})
track_fixture(%{title: "Another Track"})
count = ScrobbleActivity.search_tracks_count("Special")
assert count == 1
end
test "returns zero when query matches no tracks" do
track_fixture(%{title: "Test Track"})
count = ScrobbleActivity.search_tracks_count("Nonexistent")
assert count == 0
end
test "counts tracks matching artist name" do
track_fixture(%{artist_name: "Special Artist", title: "Track 1"})
track_fixture(%{artist_name: "Regular Artist", title: "Track 2"})
count = ScrobbleActivity.search_tracks_count("Special")
assert count == 1
end
test "counts tracks matching album title" do
track_fixture(%{album_title: "Special Album", title: "Track 1"})
track_fixture(%{album_title: "Regular Album", title: "Track 2"})
count = ScrobbleActivity.search_tracks_count("Special")
assert count == 1
end
end
describe "integration with existing functions" do
test "scrobble_count returns correct count" do
initial_count = ScrobbleActivity.scrobble_count()
create_test_tracks(3)
new_count = ScrobbleActivity.scrobble_count()
assert new_count == initial_count + 3
end
end
end
@@ -0,0 +1,321 @@
defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
use MusicLibraryWeb.ConnCase
import MusicLibrary.ScrobbledTracksFixtures
import Phoenix.LiveViewTest
alias MusicLibrary.ScrobbleActivity
# Test data
@invalid_track_attrs %{title: "", artist: %{name: ""}, album: %{title: ""}}
@valid_track_attrs %{
title: "Updated Track Title",
artist: %{name: "Updated Artist"},
album: %{title: "Updated Album"},
scrobbled_at_label: "02/02/2024 14:00:00",
cover_url: "https://example.com/updated-cover.jpg"
}
defp create_track(_) do
track = track_fixture()
%{track: track}
end
defp create_multiple_tracks(_) do
tracks = create_test_tracks(10)
%{tracks: tracks}
end
describe "Index" do
setup [:create_track]
test "lists scrobbled tracks", %{conn: conn, track: track} do
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks")
assert html =~ "Scrobbled Tracks"
assert html =~ track.title
assert html =~ track.artist.name
assert html =~ track.album.title
end
test "shows empty state when no tracks", %{conn: conn} do
# Delete the created track
ScrobbleActivity.delete_track(track_fixture())
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks")
assert html =~ "No scrobbled tracks found"
end
test "displays track count", %{conn: conn} do
create_test_tracks(3)
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks")
assert html =~ ~r/\d+ tracks?/
end
end
describe "Search functionality" do
setup [:create_multiple_tracks]
test "searches tracks by title", %{conn: conn} do
track_fixture(%{title: "Unique Track Title"})
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> form("form[phx-submit='search']", %{query: "Unique Track"})
|> render_submit()
assert html =~ "Unique Track Title"
end
test "searches tracks by artist name", %{conn: conn} do
track_fixture(%{artist_name: "Unique Artist Name"})
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> form("form[phx-submit='search']", %{query: "Unique Artist"})
|> render_submit()
assert html =~ "Unique Artist Name"
end
test "searches tracks by album title", %{conn: conn} do
track_fixture(%{album_title: "Unique Album Title"})
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> form("form[phx-submit='search']", %{query: "Unique Album"})
|> render_submit()
assert html =~ "Unique Album Title"
end
test "shows no results message for non-matching search", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> form("form[phx-submit='search']", %{query: "NonexistentTrack"})
|> render_submit()
assert html =~ "Try adjusting your search"
end
end
describe "Sorting functionality" do
setup [:create_multiple_tracks]
test "sorts by scrobbled date (default)", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
# Click on scrobbled date sort button
html =
index_live
|> element("button[patch*='order=scrobbled_at']")
|> render_click()
assert html =~ "Scrobbled Tracks"
# Check that URL contains the order parameter
assert_patch(
index_live,
~p"/scrobbled-tracks?order=scrobbled_at&page=1&page_size=200&query="
)
end
test "sorts by track title", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> element("button[patch*='order=title']")
|> render_click()
assert html =~ "Scrobbled Tracks"
assert_patch(index_live, ~p"/scrobbled-tracks?order=title&page=1&page_size=200&query=")
end
test "sorts by artist name", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> element("button[patch*='order=artist']")
|> render_click()
assert html =~ "Scrobbled Tracks"
assert_patch(index_live, ~p"/scrobbled-tracks?order=artist&page=1&page_size=200&query=")
end
test "sorts by album title", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
html =
index_live
|> element("button[patch*='order=album']")
|> render_click()
assert html =~ "Scrobbled Tracks"
assert_patch(index_live, ~p"/scrobbled-tracks?order=album&page=1&page_size=200&query=")
end
end
describe "Edit track" do
setup [:create_track]
test "updates track successfully", %{conn: conn, track: track} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
assert index_live
|> element("button[patch='/scrobbled-tracks/#{track.scrobbled_at_uts}/edit']")
|> render_click() =~ "Edit Scrobbled Track"
assert_patch(index_live, ~p"/scrobbled-tracks/#{track.scrobbled_at_uts}/edit")
assert index_live
|> form("#track-form", track: @invalid_track_attrs)
|> render_change() =~ "can&#39;t be blank"
html =
index_live
|> form("#track-form", track: @valid_track_attrs)
|> render_submit()
assert html =~ "Track updated successfully"
assert html =~ "Updated Track Title"
end
test "shows validation errors", %{conn: conn, track: track} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
assert index_live
|> element("button[patch='/scrobbled-tracks/#{track.scrobbled_at_uts}/edit']")
|> render_click()
assert index_live
|> form("#track-form", track: @invalid_track_attrs)
|> render_change() =~ "can&#39;t be blank"
end
test "closes modal when clicking outside or cancel", %{conn: conn, track: track} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
# Open edit modal
assert index_live
|> element("button[patch='/scrobbled-tracks/#{track.scrobbled_at_uts}/edit']")
|> render_click()
# Close modal by patching back to index
html = index_live |> element("button[phx-click='JS.patch']") |> render_click()
assert_patch(index_live, ~p"/scrobbled-tracks")
end
end
describe "Delete track" do
setup [:create_track]
test "deletes track successfully", %{conn: conn, track: track} do
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
# Find and click delete button
assert index_live
|> element(
"button[phx-click='delete'][phx-value-scrobbled-at-uts='#{track.scrobbled_at_uts}']"
)
|> render_click()
# Track should no longer be visible
refute has_element?(index_live, "#tracks-#{track.scrobbled_at_uts}")
end
end
describe "Pagination" do
test "shows pagination when more than one page", %{conn: conn} do
# Create enough tracks to require pagination (more than 200)
create_test_tracks(250)
{:ok, index_live, html} = live(conn, ~p"/scrobbled-tracks")
# Should show pagination component
assert html =~ "Next"
assert has_element?(index_live, "#bottom_pagination")
end
test "navigates to next page", %{conn: conn} do
create_test_tracks(250)
{:ok, index_live, _html} = live(conn, ~p"/scrobbled-tracks")
# Click next page button (if visible)
if has_element?(index_live, "button[patch*='page=2']") do
html =
index_live
|> element("button[patch*='page=2']")
|> render_click()
assert html =~ "Scrobbled Tracks"
end
end
end
describe "URL parameter handling" do
test "handles query parameter", %{conn: conn} do
track_fixture(%{title: "Special Track"})
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks?query=Special")
assert html =~ "Special Track"
end
test "handles page parameter", %{conn: conn} do
create_test_tracks(250)
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks?page=2&page_size=100")
assert html =~ "Scrobbled Tracks"
end
test "handles order parameter", %{conn: conn} do
create_test_tracks(5)
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks?order=title")
assert html =~ "Scrobbled Tracks"
end
test "handles invalid parameters gracefully", %{conn: conn} do
create_test_tracks(5)
{:ok, _index_live, html} = live(conn, ~p"/scrobbled-tracks?page=invalid&order=invalid")
assert html =~ "Scrobbled Tracks"
end
end
describe "Navigation integration" do
test "shows Scrobbled Tracks link in navigation", %{conn: conn} do
{:ok, _index_live, html} = live(conn, ~p"/")
assert html =~ "Scrobbled Tracks"
assert html =~ ~p"/scrobbled-tracks"
end
end
describe "Error handling" do
test "handles non-existent track edit gracefully", %{conn: conn} do
invalid_id = 999_999_999
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/scrobbled-tracks/#{invalid_id}/edit")
end
end
end
end
@@ -0,0 +1,64 @@
defmodule MusicLibrary.ScrobbledTracksFixtures do
@moduledoc """
This module defines test helpers for creating
scrobbled track entities via the database.
"""
alias MusicLibrary.Repo
alias LastFm.Track
@doc """
Generate a scrobbled track.
"""
def track_fixture(attrs \\ %{}) do
scrobbled_at_uts =
attrs[:scrobbled_at_uts] || System.system_time(:second) - Enum.random(0..86400)
# Create map attributes for embedded schemas
artist_attrs = %{
musicbrainz_id: attrs[:artist_musicbrainz_id] || "",
name: attrs[:artist_name] || "Test Artist"
}
album_attrs = %{
musicbrainz_id: attrs[:album_musicbrainz_id] || "",
title: attrs[:album_title] || "Test Album"
}
track_attrs = %{
scrobbled_at_uts: scrobbled_at_uts,
musicbrainz_id: attrs[:musicbrainz_id] || "",
title: attrs[:title] || "Test Track",
cover_url: attrs[:cover_url] || "https://example.com/cover.jpg",
scrobbled_at_label: attrs[:scrobbled_at_label] || "01/01/2024 12:00:00",
artist: artist_attrs,
album: album_attrs,
last_fm_data: attrs[:last_fm_data] || %{}
}
changeset = Track.changeset(%Track{}, track_attrs)
{:ok, track} = Repo.insert(changeset)
track
end
@doc """
Generate multiple scrobbled tracks for testing pagination and search.
"""
def create_test_tracks(count \\ 5) do
artists = ["Pink Floyd", "The Beatles", "Led Zeppelin", "Queen", "The Rolling Stones"]
albums = ["Dark Side", "Abbey Road", "IV", "A Night", "Sticky Fingers"]
tracks = ["Money", "Come Together", "Stairway", "Bohemian", "Brown Sugar"]
1..count
|> Enum.map(fn i ->
track_fixture(%{
# 1 hour apart
scrobbled_at_uts: System.system_time(:second) - i * 3600,
title: Enum.at(tracks, rem(i - 1, length(tracks))) <> " #{i}",
artist_name: Enum.at(artists, rem(i - 1, length(artists))),
album_title: Enum.at(albums, rem(i - 1, length(albums))) <> " #{i}",
scrobbled_at_label: "0#{rem(i, 9) + 1}/01/2024 1#{rem(i, 2)}:00:00"
})
end)
end
end