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