Scrobbled tracks CRUD
- Failing tests - Warnings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,5 +15,6 @@ defmodule LastFm.Album do
|
||||
def changeset(album, attrs) do
|
||||
album
|
||||
|> cast(attrs, [:musicbrainz_id, :title])
|
||||
|> validate_required([:title])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,6 +50,7 @@ defmodule LastFm.Artist do
|
||||
:on_tour,
|
||||
:base_url
|
||||
])
|
||||
|> validate_required([:name])
|
||||
end
|
||||
|
||||
def events_url(artist) do
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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'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'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
|
||||
Reference in New Issue
Block a user