Split ScrobbleActivity into focused modules
Move track CRUD/listing to ListeningStats and diagnostics to Maintenance, leaving ScrobbleActivity for scrobbling only. Closes #111
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
|
Logger.configure(level: :error)
|
||||||
|
|
||||||
|
timezone = MusicLibrary.default_timezone()
|
||||||
|
|
||||||
|
Benchee.run(
|
||||||
|
%{
|
||||||
|
"get_top_albums(limit: 10)" => fn -> ListeningStats.get_top_albums(limit: 10) end,
|
||||||
|
"get_top_albums_by_days(7)" =>
|
||||||
|
fn -> ListeningStats.get_top_albums_by_days(7, limit: 10, timezone: timezone) end,
|
||||||
|
"get_top_albums_by_days(30)" =>
|
||||||
|
fn -> ListeningStats.get_top_albums_by_days(30, limit: 10, timezone: timezone) end,
|
||||||
|
"get_top_albums_by_days(365)" =>
|
||||||
|
fn -> ListeningStats.get_top_albums_by_days(365, limit: 10, timezone: timezone) end,
|
||||||
|
"get_top_artists(limit: 10)" => fn -> ListeningStats.get_top_artists(limit: 10) end,
|
||||||
|
"get_top_artists_by_days(7)" =>
|
||||||
|
fn -> ListeningStats.get_top_artists_by_days(7, limit: 10, timezone: timezone) end,
|
||||||
|
"get_top_artists_by_days(30)" =>
|
||||||
|
fn -> ListeningStats.get_top_artists_by_days(30, limit: 10, timezone: timezone) end,
|
||||||
|
"get_top_artists_by_days(365)" =>
|
||||||
|
fn -> ListeningStats.get_top_artists_by_days(365, limit: 10, timezone: timezone) end,
|
||||||
|
"recent_activity(tz, 100)" => fn -> ListeningStats.recent_activity(timezone, 100) end,
|
||||||
|
"list_tracks(page: 1, page_size: 200)" =>
|
||||||
|
fn -> ListeningStats.list_tracks(%{page: 1, page_size: 200}) end
|
||||||
|
},
|
||||||
|
warmup: 2,
|
||||||
|
time: 5,
|
||||||
|
memory_time: 2
|
||||||
|
)
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
alias MusicLibrary.ScrobbleActivity
|
|
||||||
|
|
||||||
Logger.configure(level: :error)
|
|
||||||
|
|
||||||
timezone = MusicLibrary.default_timezone()
|
|
||||||
|
|
||||||
Benchee.run(
|
|
||||||
%{
|
|
||||||
"get_top_albums(limit: 10)" => fn -> ScrobbleActivity.get_top_albums(limit: 10) end,
|
|
||||||
"get_top_albums_by_days(7)" =>
|
|
||||||
fn -> ScrobbleActivity.get_top_albums_by_days(7, limit: 10, timezone: timezone) end,
|
|
||||||
"get_top_albums_by_days(30)" =>
|
|
||||||
fn -> ScrobbleActivity.get_top_albums_by_days(30, limit: 10, timezone: timezone) end,
|
|
||||||
"get_top_albums_by_days(365)" =>
|
|
||||||
fn -> ScrobbleActivity.get_top_albums_by_days(365, limit: 10, timezone: timezone) end,
|
|
||||||
"get_top_artists(limit: 10)" => fn -> ScrobbleActivity.get_top_artists(limit: 10) end,
|
|
||||||
"get_top_artists_by_days(7)" =>
|
|
||||||
fn -> ScrobbleActivity.get_top_artists_by_days(7, limit: 10, timezone: timezone) end,
|
|
||||||
"get_top_artists_by_days(30)" =>
|
|
||||||
fn -> ScrobbleActivity.get_top_artists_by_days(30, limit: 10, timezone: timezone) end,
|
|
||||||
"get_top_artists_by_days(365)" =>
|
|
||||||
fn -> ScrobbleActivity.get_top_artists_by_days(365, limit: 10, timezone: timezone) end,
|
|
||||||
"recent_activity(tz, 100)" => fn -> ScrobbleActivity.recent_activity(timezone, 100) end,
|
|
||||||
"list_tracks(page: 1, page_size: 200)" =>
|
|
||||||
fn -> ScrobbleActivity.list_tracks(%{page: 1, page_size: 200}) end
|
|
||||||
},
|
|
||||||
warmup: 2,
|
|
||||||
time: 5,
|
|
||||||
memory_time: 2
|
|
||||||
)
|
|
||||||
@@ -99,13 +99,13 @@ Last.fm schemas (separate, not Ecto-persisted to main DB):
|
|||||||
| `Notes` | Note | Free-text notes for records and artists |
|
| `Notes` | Note | Free-text notes for records and artists |
|
||||||
| `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering |
|
| `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering |
|
||||||
| `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs |
|
| `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs |
|
||||||
| `ScrobbleActivity` | (LastFm.Track, ArtistRecord) | Scrobbling releases, track CRUD, data quality diagnostics |
|
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
|
||||||
| `ListeningStats` | (LastFm.Track, ArtistRecord, ArtistInfo) | Read-only listening analytics: scrobble counts, recent activity, top albums/artists by period |
|
| `ListeningStats` | (LastFm.Track, ArtistRecord, ArtistInfo) | Listening analytics, track CRUD, search, listing: scrobble counts, recent activity, top albums/artists by period |
|
||||||
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online |
|
| `OnlineStoreTemplates` | OnlineStoreTemplate | URL templates for buying records online |
|
||||||
| `Search` | (cross-context) | Universal search across collection, wishlist, artists, record sets |
|
| `Search` | (cross-context) | Universal search across collection, wishlist, artists, record sets |
|
||||||
| `Secrets` | Secret | Encrypted key-value storage |
|
| `Secrets` | Secret | Encrypted key-value storage |
|
||||||
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow |
|
| `BarcodeScan` | (Result struct) | Barcode → MusicBrainz lookup workflow |
|
||||||
| `Maintenance` | (Oban.Job) | Background job monitoring, database vacuum/optimize |
|
| `Maintenance` | (Oban.Job, LastFm.Track) | Background job monitoring, database vacuum/optimize, scrobble data quality diagnostics |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ defmodule Mix.Tasks.Scrobble.Audit do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias LastFm.Track
|
alias LastFm.Track
|
||||||
alias MusicLibrary.{Repo, ScrobbleActivity}
|
alias MusicLibrary.{ListeningStats, Maintenance, Repo}
|
||||||
|
|
||||||
@impl Mix.Task
|
@impl Mix.Task
|
||||||
def run(args) do
|
def run(args) do
|
||||||
@@ -102,7 +102,7 @@ defmodule Mix.Tasks.Scrobble.Audit do
|
|||||||
|
|
||||||
defp generate_audit_report(:all, verbose) do
|
defp generate_audit_report(:all, verbose) do
|
||||||
%{
|
%{
|
||||||
total_tracks: ScrobbleActivity.count_tracks(),
|
total_tracks: ListeningStats.scrobble_count(),
|
||||||
artist_issues: audit_artist_musicbrainz_ids(verbose),
|
artist_issues: audit_artist_musicbrainz_ids(verbose),
|
||||||
album_issues: audit_album_musicbrainz_ids(verbose)
|
album_issues: audit_album_musicbrainz_ids(verbose)
|
||||||
}
|
}
|
||||||
@@ -110,20 +110,20 @@ defmodule Mix.Tasks.Scrobble.Audit do
|
|||||||
|
|
||||||
defp generate_audit_report(:artist, verbose) do
|
defp generate_audit_report(:artist, verbose) do
|
||||||
%{
|
%{
|
||||||
total_tracks: ScrobbleActivity.count_tracks(),
|
total_tracks: ListeningStats.scrobble_count(),
|
||||||
artist_issues: audit_artist_musicbrainz_ids(verbose)
|
artist_issues: audit_artist_musicbrainz_ids(verbose)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_audit_report(:album, verbose) do
|
defp generate_audit_report(:album, verbose) do
|
||||||
%{
|
%{
|
||||||
total_tracks: ScrobbleActivity.count_tracks(),
|
total_tracks: ListeningStats.scrobble_count(),
|
||||||
album_issues: audit_album_musicbrainz_ids(verbose)
|
album_issues: audit_album_musicbrainz_ids(verbose)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp audit_artist_musicbrainz_ids(verbose) do
|
defp audit_artist_musicbrainz_ids(verbose) do
|
||||||
results = ScrobbleActivity.get_artists_missing_musicbrainz_id()
|
results = Maintenance.get_artists_missing_musicbrainz_id()
|
||||||
|
|
||||||
total_tracks_affected =
|
total_tracks_affected =
|
||||||
Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end)
|
Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end)
|
||||||
@@ -145,7 +145,7 @@ defmodule Mix.Tasks.Scrobble.Audit do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp audit_album_musicbrainz_ids(verbose) do
|
defp audit_album_musicbrainz_ids(verbose) do
|
||||||
results = ScrobbleActivity.get_albums_missing_musicbrainz_id()
|
results = Maintenance.get_albums_missing_musicbrainz_id()
|
||||||
|
|
||||||
total_tracks_affected =
|
total_tracks_affected =
|
||||||
Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end)
|
Enum.reduce(results, 0, fn %{track_count: count}, acc -> acc + count end)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
defmodule MusicLibrary.ListeningStats do
|
defmodule MusicLibrary.ListeningStats do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Read-only listening analytics derived from Last.fm scrobble data.
|
Listening analytics and track management derived from Last.fm scrobble data.
|
||||||
|
|
||||||
Provides scrobble counts, recent activity feeds, and top albums/artists
|
Provides scrobble counts, recent activity feeds, top albums/artists
|
||||||
by time period. All queries are read-only joins across LastFm.Track,
|
by time period, and track CRUD/search/listing. All queries join across
|
||||||
Collection, Wishlist, and ArtistInfo.
|
LastFm.Track, Collection, Wishlist, and ArtistInfo.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
@@ -36,27 +36,10 @@ defmodule MusicLibrary.ListeningStats do
|
|||||||
# not have the necessary information. In that case we can go from
|
# not have the necessary information. In that case we can go from
|
||||||
# track -> album -> record -> artist
|
# track -> album -> record -> artist
|
||||||
|
|
||||||
all_artists_query =
|
|
||||||
from ar in ArtistRecord,
|
|
||||||
distinct: true
|
|
||||||
|
|
||||||
tracks_query =
|
tracks_query =
|
||||||
from t in Track,
|
from [t, cr, wr, ar] in tracks_with_record_info_query(),
|
||||||
left_join: cr in subquery(Collection.collected_releases_query()),
|
|
||||||
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
|
||||||
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: ar in subquery(all_artists_query),
|
|
||||||
on: wr.record_id == ar.record_id or cr.record_id == ar.record_id,
|
|
||||||
order_by: [desc: t.scrobbled_at_uts],
|
order_by: [desc: t.scrobbled_at_uts],
|
||||||
limit: ^limit,
|
limit: ^limit
|
||||||
select: %{
|
|
||||||
track: t,
|
|
||||||
collected_record_id: cr.record_id,
|
|
||||||
wishlisted_record_id: wr.record_id,
|
|
||||||
artist_id: ar.musicbrainz_id,
|
|
||||||
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
recent_tracks = Repo.all(tracks_query)
|
recent_tracks = Repo.all(tracks_query)
|
||||||
|
|
||||||
@@ -100,6 +83,99 @@ defmodule MusicLibrary.ListeningStats do
|
|||||||
Calendar.strftime(ldt, "%d/%m/%Y %X")
|
Calendar.strftime(ldt, "%d/%m/%Y %X")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Track CRUD + listing
|
||||||
|
|
||||||
|
@spec list_tracks(map()) :: [map()]
|
||||||
|
def list_tracks(params \\ %{}) do
|
||||||
|
query = Map.get(params, :query, "")
|
||||||
|
page = Map.get(params, :page, 1)
|
||||||
|
page_size = Map.get(params, :page_size, @pagination[:tracks_page_size])
|
||||||
|
order = Map.get(params, :order, :scrobbled_at)
|
||||||
|
|
||||||
|
base_query = tracks_with_record_info_query()
|
||||||
|
|
||||||
|
search_query =
|
||||||
|
if query == "" do
|
||||||
|
base_query
|
||||||
|
else
|
||||||
|
query_term = "%#{String.downcase(query)}%"
|
||||||
|
|
||||||
|
from [t, cr, wr, ar] in base_query,
|
||||||
|
where:
|
||||||
|
like(fragment("lower(?)", t.title), ^query_term) or
|
||||||
|
like(fragment("lower(json_extract(?, '$.name'))", t.artist), ^query_term) or
|
||||||
|
like(fragment("lower(json_extract(?, '$.title'))", t.album), ^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(?, '$.name')", t.artist)]
|
||||||
|
|
||||||
|
:album ->
|
||||||
|
from [t] in search_query,
|
||||||
|
order_by: [asc: fragment("json_extract(?, '$.title')", t.album)]
|
||||||
|
end
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
from(t in ordered_query, limit: ^page_size, offset: ^offset)
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_track!(integer() | String.t()) :: LastFm.Track.t()
|
||||||
|
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
|
||||||
|
|
||||||
|
@spec update_track(LastFm.Track.t(), map()) ::
|
||||||
|
{:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def update_track(%Track{} = track, attrs) do
|
||||||
|
changeset = Track.changeset(track, attrs)
|
||||||
|
Repo.update(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete_track(LastFm.Track.t()) :: {:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def delete_track(%Track{} = track) do
|
||||||
|
Repo.delete(track)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec search_tracks_count(String.t()) :: non_neg_integer()
|
||||||
|
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
|
||||||
|
|
||||||
|
# Top albums/artists by period
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets top albums for the specified time periods (7, 30, 90, 365 days) and all
|
Gets top albums for the specified time periods (7, 30, 90, 365 days) and all
|
||||||
time. Returns a list of maps with album information and play counts.
|
time. Returns a list of maps with album information and play counts.
|
||||||
@@ -187,6 +263,27 @@ defmodule MusicLibrary.ListeningStats do
|
|||||||
|
|
||||||
# Shared base queries
|
# Shared base queries
|
||||||
|
|
||||||
|
defp tracks_with_record_info_query do
|
||||||
|
all_artists_query =
|
||||||
|
from ar in ArtistRecord,
|
||||||
|
distinct: true
|
||||||
|
|
||||||
|
from t in Track,
|
||||||
|
left_join: cr in subquery(Collection.collected_releases_query()),
|
||||||
|
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
||||||
|
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
||||||
|
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
||||||
|
left_join: ar in subquery(all_artists_query),
|
||||||
|
on: wr.record_id == ar.record_id or cr.record_id == ar.record_id,
|
||||||
|
select: %{
|
||||||
|
track: t,
|
||||||
|
collected_record_id: cr.record_id,
|
||||||
|
wishlisted_record_id: wr.record_id,
|
||||||
|
artist_id: ar.musicbrainz_id,
|
||||||
|
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp top_albums_base_query do
|
defp top_albums_base_query do
|
||||||
from t in Track,
|
from t in Track,
|
||||||
left_join: cr in subquery(Collection.collected_releases_query()),
|
left_join: cr in subquery(Collection.collected_releases_query()),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
defmodule MusicLibrary.Maintenance do
|
defmodule MusicLibrary.Maintenance do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Context for database maintenance operations and background job monitoring.
|
Context for database maintenance operations, background job monitoring,
|
||||||
|
and scrobble data quality diagnostics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias LastFm.Track
|
||||||
alias MusicLibrary.BackgroundRepo
|
alias MusicLibrary.BackgroundRepo
|
||||||
alias MusicLibrary.Repo
|
alias MusicLibrary.Repo
|
||||||
|
|
||||||
@@ -39,4 +41,96 @@ defmodule MusicLibrary.Maintenance do
|
|||||||
def optimize do
|
def optimize do
|
||||||
Repo.optimize()
|
Repo.optimize()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Scrobble data quality diagnostics
|
||||||
|
|
||||||
|
@spec count_tracks_missing_artist_musicbrainz_id() :: non_neg_integer()
|
||||||
|
def count_tracks_missing_artist_musicbrainz_id do
|
||||||
|
query =
|
||||||
|
from t in Track,
|
||||||
|
where:
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
|
||||||
|
select: count(t.scrobbled_at_uts)
|
||||||
|
|
||||||
|
Repo.one(query) || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec count_tracks_missing_album_musicbrainz_id() :: non_neg_integer()
|
||||||
|
def count_tracks_missing_album_musicbrainz_id do
|
||||||
|
query =
|
||||||
|
from t in Track,
|
||||||
|
where:
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
|
||||||
|
select: count(t.scrobbled_at_uts)
|
||||||
|
|
||||||
|
Repo.one(query) || 0
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets artists with missing MusicBrainz IDs, grouped by artist name.
|
||||||
|
|
||||||
|
Returns a list of maps with artist name and track count.
|
||||||
|
"""
|
||||||
|
@spec get_artists_missing_musicbrainz_id(keyword()) :: [map()]
|
||||||
|
def get_artists_missing_musicbrainz_id(opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit)
|
||||||
|
|
||||||
|
query =
|
||||||
|
from t in Track,
|
||||||
|
where:
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
|
||||||
|
select: %{
|
||||||
|
artist_name: fragment("json_extract(?, '$.name')", t.artist),
|
||||||
|
track_count: count(t.scrobbled_at_uts)
|
||||||
|
},
|
||||||
|
group_by: fragment("json_extract(?, '$.name')", t.artist),
|
||||||
|
order_by: [desc: count(t.scrobbled_at_uts)]
|
||||||
|
|
||||||
|
query =
|
||||||
|
if limit do
|
||||||
|
from q in query, limit: ^limit
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
Repo.all(query)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets albums with missing MusicBrainz IDs, grouped by album title and artist.
|
||||||
|
|
||||||
|
Returns a list of maps with album title, artist name, and track count.
|
||||||
|
"""
|
||||||
|
@spec get_albums_missing_musicbrainz_id(keyword()) :: [map()]
|
||||||
|
def get_albums_missing_musicbrainz_id(opts \\ []) do
|
||||||
|
limit = Keyword.get(opts, :limit)
|
||||||
|
|
||||||
|
query =
|
||||||
|
from t in Track,
|
||||||
|
where:
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
|
||||||
|
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
|
||||||
|
select: %{
|
||||||
|
album_title: fragment("json_extract(?, '$.title')", t.album),
|
||||||
|
artist_name: fragment("json_extract(?, '$.name')", t.artist),
|
||||||
|
track_count: count(t.scrobbled_at_uts)
|
||||||
|
},
|
||||||
|
group_by: [
|
||||||
|
fragment("json_extract(?, '$.title')", t.album),
|
||||||
|
fragment("json_extract(?, '$.name')", t.artist)
|
||||||
|
],
|
||||||
|
order_by: [desc: count(t.scrobbled_at_uts)]
|
||||||
|
|
||||||
|
query =
|
||||||
|
if limit do
|
||||||
|
from q in query, limit: ^limit
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
Repo.all(query)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
defmodule MusicLibrary.ScrobbleActivity do
|
defmodule MusicLibrary.ScrobbleActivity do
|
||||||
import Ecto.Query
|
alias LastFm.Scrobble
|
||||||
|
|
||||||
alias LastFm.{Scrobble, Track}
|
|
||||||
alias MusicBrainz.Release
|
alias MusicBrainz.Release
|
||||||
alias MusicLibrary.{Collection, Records.ArtistRecord, Repo, Secrets, Wishlist}
|
alias MusicLibrary.Secrets
|
||||||
|
|
||||||
@pagination Application.compile_env!(:music_library, :pagination)
|
|
||||||
|
|
||||||
@spec can_scrobble?() :: boolean()
|
@spec can_scrobble?() :: boolean()
|
||||||
def can_scrobble? do
|
def can_scrobble? do
|
||||||
@@ -200,204 +196,4 @@ defmodule MusicLibrary.ScrobbleActivity do
|
|||||||
|
|
||||||
defp main_artist_name([]), do: nil
|
defp main_artist_name([]), do: nil
|
||||||
defp main_artist_name([artist | _rest]), do: artist.name
|
defp main_artist_name([artist | _rest]), do: artist.name
|
||||||
|
|
||||||
@spec list_tracks(map()) :: [map()]
|
|
||||||
def list_tracks(params \\ %{}) do
|
|
||||||
query = Map.get(params, :query, "")
|
|
||||||
page = Map.get(params, :page, 1)
|
|
||||||
page_size = Map.get(params, :page_size, @pagination[:tracks_page_size])
|
|
||||||
order = Map.get(params, :order, :scrobbled_at)
|
|
||||||
|
|
||||||
all_artists_query =
|
|
||||||
from ar in ArtistRecord,
|
|
||||||
distinct: true
|
|
||||||
|
|
||||||
base_query =
|
|
||||||
from t in Track,
|
|
||||||
left_join: cr in subquery(Collection.collected_releases_query()),
|
|
||||||
on: cr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: wr in subquery(Wishlist.wishlisted_releases_query()),
|
|
||||||
on: wr.release_id == fragment("? ->> '$.musicbrainz_id'", t.album),
|
|
||||||
left_join: ar in subquery(all_artists_query),
|
|
||||||
on: wr.record_id == ar.record_id or cr.record_id == ar.record_id,
|
|
||||||
select: %{
|
|
||||||
track: t,
|
|
||||||
collected_record_id: cr.record_id,
|
|
||||||
wishlisted_record_id: wr.record_id,
|
|
||||||
artist_id: ar.musicbrainz_id,
|
|
||||||
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(?, '$.name'))", t.artist), ^query_term) or
|
|
||||||
like(fragment("lower(json_extract(?, '$.title'))", t.album), ^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(?, '$.name')", t.artist)]
|
|
||||||
|
|
||||||
:album ->
|
|
||||||
from t in search_query, order_by: [asc: fragment("json_extract(?, '$.title')", t.album)]
|
|
||||||
end
|
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
from(t in ordered_query, limit: ^page_size, offset: ^offset)
|
|
||||||
|> Repo.all()
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec count_tracks() :: non_neg_integer()
|
|
||||||
def count_tracks do
|
|
||||||
Repo.aggregate(Track, :count, :scrobbled_at_uts)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec get_track!(integer() | String.t()) :: LastFm.Track.t()
|
|
||||||
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
|
|
||||||
|
|
||||||
@spec update_track(LastFm.Track.t(), map()) ::
|
|
||||||
{:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
|
|
||||||
def update_track(%Track{} = track, attrs) do
|
|
||||||
changeset = Track.changeset(track, attrs)
|
|
||||||
Repo.update(changeset)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec delete_track(LastFm.Track.t()) :: {:ok, LastFm.Track.t()} | {:error, Ecto.Changeset.t()}
|
|
||||||
def delete_track(%Track{} = track) do
|
|
||||||
Repo.delete(track)
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec search_tracks_count(String.t()) :: non_neg_integer()
|
|
||||||
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
|
|
||||||
|
|
||||||
@spec count_tracks_missing_artist_musicbrainz_id() :: non_neg_integer()
|
|
||||||
def count_tracks_missing_artist_musicbrainz_id do
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
where:
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
|
|
||||||
select: count(t.scrobbled_at_uts)
|
|
||||||
|
|
||||||
Repo.one(query) || 0
|
|
||||||
end
|
|
||||||
|
|
||||||
@spec count_tracks_missing_album_musicbrainz_id() :: non_neg_integer()
|
|
||||||
def count_tracks_missing_album_musicbrainz_id do
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
where:
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
|
|
||||||
select: count(t.scrobbled_at_uts)
|
|
||||||
|
|
||||||
Repo.one(query) || 0
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets artists with missing MusicBrainz IDs, grouped by artist name.
|
|
||||||
|
|
||||||
Returns a list of maps with artist name and track count.
|
|
||||||
"""
|
|
||||||
@spec get_artists_missing_musicbrainz_id(keyword()) :: [map()]
|
|
||||||
def get_artists_missing_musicbrainz_id(opts \\ []) do
|
|
||||||
limit = Keyword.get(opts, :limit)
|
|
||||||
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
where:
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.artist) or
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.artist),
|
|
||||||
select: %{
|
|
||||||
artist_name: fragment("json_extract(?, '$.name')", t.artist),
|
|
||||||
track_count: count(t.scrobbled_at_uts)
|
|
||||||
},
|
|
||||||
group_by: fragment("json_extract(?, '$.name')", t.artist),
|
|
||||||
order_by: [desc: count(t.scrobbled_at_uts)]
|
|
||||||
|
|
||||||
query =
|
|
||||||
if limit do
|
|
||||||
from q in query, limit: ^limit
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
|
|
||||||
Repo.all(query)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Gets albums with missing MusicBrainz IDs, grouped by album title and artist.
|
|
||||||
|
|
||||||
Returns a list of maps with album title, artist name, and track count.
|
|
||||||
"""
|
|
||||||
@spec get_albums_missing_musicbrainz_id(keyword()) :: [map()]
|
|
||||||
def get_albums_missing_musicbrainz_id(opts \\ []) do
|
|
||||||
limit = Keyword.get(opts, :limit)
|
|
||||||
|
|
||||||
query =
|
|
||||||
from t in Track,
|
|
||||||
where:
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') IS NULL", t.album) or
|
|
||||||
fragment("json_extract(?, '$.musicbrainz_id') = ''", t.album),
|
|
||||||
select: %{
|
|
||||||
album_title: fragment("json_extract(?, '$.title')", t.album),
|
|
||||||
artist_name: fragment("json_extract(?, '$.name')", t.artist),
|
|
||||||
track_count: count(t.scrobbled_at_uts)
|
|
||||||
},
|
|
||||||
group_by: [
|
|
||||||
fragment("json_extract(?, '$.title')", t.album),
|
|
||||||
fragment("json_extract(?, '$.name')", t.artist)
|
|
||||||
],
|
|
||||||
order_by: [desc: count(t.scrobbled_at_uts)]
|
|
||||||
|
|
||||||
query =
|
|
||||||
if limit do
|
|
||||||
from q in query, limit: ^limit
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
|
|
||||||
Repo.all(query)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Form do
|
|||||||
use MusicLibraryWeb, :live_component
|
use MusicLibraryWeb, :live_component
|
||||||
|
|
||||||
alias LastFm.Track
|
alias LastFm.Track
|
||||||
alias MusicLibrary.ScrobbleActivity
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
@@ -104,7 +104,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Form do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp save_track(socket, track_params) do
|
defp save_track(socket, track_params) do
|
||||||
case ScrobbleActivity.update_track(socket.assigns.track, track_params) do
|
case ListeningStats.update_track(socket.assigns.track, track_params) do
|
||||||
{:ok, track} ->
|
{:ok, track} ->
|
||||||
notify_parent({:saved, track})
|
notify_parent({:saved, track})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
|
|
||||||
alias LastFm.Track
|
alias LastFm.Track
|
||||||
alias MusicLibrary.Assets.Transform
|
alias MusicLibrary.Assets.Transform
|
||||||
alias MusicLibrary.ScrobbleActivity
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
@default_tracks_list_params %{
|
@default_tracks_list_params %{
|
||||||
query: "",
|
query: "",
|
||||||
@@ -229,7 +229,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp apply_action(socket, :edit, %{"scrobbled_at_uts" => id} = params) do
|
defp apply_action(socket, :edit, %{"scrobbled_at_uts" => id} = params) do
|
||||||
track = ScrobbleActivity.get_track!(id)
|
track = ListeningStats.get_track!(id)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> apply_fallback_index(params, :tracks, &apply_action/3)
|
|> apply_fallback_index(params, :tracks, &apply_action/3)
|
||||||
@@ -241,7 +241,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
defp apply_action(socket, :index, params) do
|
defp apply_action(socket, :index, params) do
|
||||||
query = params["query"] || ""
|
query = params["query"] || ""
|
||||||
order = parse_order(params["order"] || "scrobbled_at")
|
order = parse_order(params["order"] || "scrobbled_at")
|
||||||
total_tracks = ScrobbleActivity.search_tracks_count(query)
|
total_tracks = ListeningStats.search_tracks_count(query)
|
||||||
|
|
||||||
track_list_params =
|
track_list_params =
|
||||||
@default_tracks_list_params
|
@default_tracks_list_params
|
||||||
@@ -267,8 +267,8 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
|
def handle_event("delete", %{"scrobbled-at-uts" => scrobbled_at_uts}, socket) do
|
||||||
track = ScrobbleActivity.get_track!(scrobbled_at_uts)
|
track = ListeningStats.get_track!(scrobbled_at_uts)
|
||||||
{:ok, _} = ScrobbleActivity.delete_track(track)
|
{:ok, _} = ListeningStats.delete_track(track)
|
||||||
|
|
||||||
{:noreply, stream_delete(socket, :tracks, %{track: track})}
|
{:noreply, stream_delete(socket, :tracks, %{track: track})}
|
||||||
end
|
end
|
||||||
@@ -294,7 +294,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLive.Index do
|
|||||||
defp parse_order(_), do: :scrobbled_at
|
defp parse_order(_), do: :scrobbled_at
|
||||||
|
|
||||||
defp load_and_assign_tracks(socket, track_list_params) do
|
defp load_and_assign_tracks(socket, track_list_params) do
|
||||||
tracks = ScrobbleActivity.list_tracks(track_list_params)
|
tracks = ListeningStats.list_tracks(track_list_params)
|
||||||
tracks_empty? = tracks == []
|
tracks_empty? = tracks == []
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule MusicLibrary.ListeningStatsTest do
|
|||||||
import MusicLibrary.ArtistInfoFixtures
|
import MusicLibrary.ArtistInfoFixtures
|
||||||
import MusicLibrary.ScrobbledTracksFixtures
|
import MusicLibrary.ScrobbledTracksFixtures
|
||||||
|
|
||||||
|
alias LastFm.Track
|
||||||
alias MusicLibrary.Fixtures.Records, as: RecordsFixtures
|
alias MusicLibrary.Fixtures.Records, as: RecordsFixtures
|
||||||
alias MusicLibrary.ListeningStats
|
alias MusicLibrary.ListeningStats
|
||||||
alias MusicLibrary.Records.Record
|
alias MusicLibrary.Records.Record
|
||||||
@@ -20,6 +21,271 @@ defmodule MusicLibrary.ListeningStatsTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = list_tracks(%{page: 1, page_size: 2})
|
||||||
|
assert length(tracks_page_1) == 2
|
||||||
|
|
||||||
|
# Get next 2 tracks
|
||||||
|
tracks_page_2 = 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 = 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 = ListeningStats.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 = ListeningStats.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 ->
|
||||||
|
ListeningStats.get_track!(999_999_999)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises Ecto.NoResultsError when given invalid string" do
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
ListeningStats.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} = ListeningStats.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{}} = ListeningStats.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} = ListeningStats.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} = ListeningStats.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{}} = ListeningStats.delete_track(track)
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
ListeningStats.get_track!(track.scrobbled_at_uts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error when track has already been deleted" do
|
||||||
|
track = track_fixture()
|
||||||
|
{:ok, _} = ListeningStats.delete_track(track)
|
||||||
|
|
||||||
|
# Attempt to delete again should fail
|
||||||
|
assert_raise Ecto.StaleEntryError, fn ->
|
||||||
|
ListeningStats.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 = ListeningStats.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 = ListeningStats.search_tracks_count("Special")
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns zero when query matches no tracks" do
|
||||||
|
track_fixture(%{title: "Test Track"})
|
||||||
|
|
||||||
|
count = ListeningStats.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 = ListeningStats.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 = ListeningStats.search_tracks_count("Special")
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "get_top_artists/1" do
|
describe "get_top_artists/1" do
|
||||||
test "counts tracks with missing artist_infos records" do
|
test "counts tracks with missing artist_infos records" do
|
||||||
artist_info =
|
artist_info =
|
||||||
@@ -199,4 +465,14 @@ defmodule MusicLibrary.ListeningStatsTest do
|
|||||||
assert thin_lizzy.play_count == 3
|
assert thin_lizzy.play_count == 3
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp list_tracks do
|
||||||
|
ListeningStats.list_tracks()
|
||||||
|
|> Enum.map(fn r -> r.track end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_tracks(params) do
|
||||||
|
ListeningStats.list_tracks(params)
|
||||||
|
|> Enum.map(fn r -> r.track end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule MusicLibrary.MaintenanceTest do
|
defmodule MusicLibrary.MaintenanceTest do
|
||||||
use MusicLibrary.DataCase
|
use MusicLibrary.DataCase
|
||||||
|
|
||||||
|
import MusicLibrary.ScrobbledTracksFixtures
|
||||||
|
|
||||||
alias MusicLibrary.Maintenance
|
alias MusicLibrary.Maintenance
|
||||||
|
|
||||||
describe "vacuum/0" do
|
describe "vacuum/0" do
|
||||||
@@ -23,4 +25,170 @@ defmodule MusicLibrary.MaintenanceTest do
|
|||||||
assert Maintenance.count_active_jobs("MusicLibrary.Worker.NonExistent") == 0
|
assert Maintenance.count_active_jobs("MusicLibrary.Worker.NonExistent") == 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "count_tracks_missing_artist_musicbrainz_id/0" do
|
||||||
|
test "returns zero when no tracks exist" do
|
||||||
|
count = Maintenance.count_tracks_missing_artist_musicbrainz_id()
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns count of tracks with empty artist musicbrainz_id" do
|
||||||
|
track_fixture(%{artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_musicbrainz_id: "valid-id"})
|
||||||
|
|
||||||
|
count = Maintenance.count_tracks_missing_artist_musicbrainz_id()
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "count_tracks_missing_album_musicbrainz_id/0" do
|
||||||
|
test "returns zero when no tracks exist" do
|
||||||
|
count = Maintenance.count_tracks_missing_album_musicbrainz_id()
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns count of tracks with empty album musicbrainz_id" do
|
||||||
|
track_fixture(%{album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_musicbrainz_id: "valid-id"})
|
||||||
|
|
||||||
|
count = Maintenance.count_tracks_missing_album_musicbrainz_id()
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_artists_missing_musicbrainz_id/1" do
|
||||||
|
test "returns empty list when no tracks exist" do
|
||||||
|
result = Maintenance.get_artists_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns artists grouped by name with track counts" do
|
||||||
|
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
||||||
|
|
||||||
|
result = Maintenance.get_artists_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
|
||||||
|
artist_a = Enum.find(result, fn r -> r.artist_name == "Artist A" end)
|
||||||
|
artist_b = Enum.find(result, fn r -> r.artist_name == "Artist B" end)
|
||||||
|
|
||||||
|
assert artist_a.track_count == 2
|
||||||
|
assert artist_b.track_count == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "orders artists by track count descending" do
|
||||||
|
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
||||||
|
|
||||||
|
result = Maintenance.get_artists_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
assert List.first(result).artist_name == "Artist B"
|
||||||
|
assert List.first(result).track_count == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "limits results when limit option provided" do
|
||||||
|
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist C", artist_musicbrainz_id: ""})
|
||||||
|
|
||||||
|
result = Maintenance.get_artists_missing_musicbrainz_id(limit: 2)
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "excludes artists with valid musicbrainz_id" do
|
||||||
|
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: "valid-id"})
|
||||||
|
|
||||||
|
result = Maintenance.get_artists_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert length(result) == 1
|
||||||
|
assert List.first(result).artist_name == "Artist A"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_albums_missing_musicbrainz_id/1" do
|
||||||
|
test "returns empty list when no tracks exist" do
|
||||||
|
result = Maintenance.get_albums_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns albums grouped by title and artist with track counts" do
|
||||||
|
track_fixture(%{
|
||||||
|
album_title: "Album A",
|
||||||
|
artist_name: "Artist X",
|
||||||
|
album_musicbrainz_id: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
track_fixture(%{
|
||||||
|
album_title: "Album A",
|
||||||
|
artist_name: "Artist X",
|
||||||
|
album_musicbrainz_id: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
track_fixture(%{
|
||||||
|
album_title: "Album B",
|
||||||
|
artist_name: "Artist Y",
|
||||||
|
album_musicbrainz_id: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
result = Maintenance.get_albums_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
|
||||||
|
album_a = Enum.find(result, fn r -> r.album_title == "Album A" end)
|
||||||
|
album_b = Enum.find(result, fn r -> r.album_title == "Album B" end)
|
||||||
|
|
||||||
|
assert album_a.track_count == 2
|
||||||
|
assert album_a.artist_name == "Artist X"
|
||||||
|
assert album_b.track_count == 1
|
||||||
|
assert album_b.artist_name == "Artist Y"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "orders albums by track count descending" do
|
||||||
|
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
||||||
|
|
||||||
|
result = Maintenance.get_albums_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
assert List.first(result).album_title == "Album B"
|
||||||
|
assert List.first(result).track_count == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "limits results when limit option provided" do
|
||||||
|
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_title: "Album C", album_musicbrainz_id: ""})
|
||||||
|
|
||||||
|
result = Maintenance.get_albums_missing_musicbrainz_id(limit: 2)
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "excludes albums with valid musicbrainz_id" do
|
||||||
|
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
|
||||||
|
track_fixture(%{album_title: "Album B", album_musicbrainz_id: "valid-id"})
|
||||||
|
|
||||||
|
result = Maintenance.get_albums_missing_musicbrainz_id()
|
||||||
|
|
||||||
|
assert length(result) == 1
|
||||||
|
assert List.first(result).album_title == "Album A"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
defmodule MusicLibrary.ScrobbleActivityTest do
|
|
||||||
use MusicLibrary.DataCase
|
|
||||||
|
|
||||||
import MusicLibrary.ScrobbledTracksFixtures
|
|
||||||
|
|
||||||
alias LastFm.Track
|
|
||||||
alias MusicLibrary.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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = list_tracks(%{page: 1, page_size: 2})
|
|
||||||
assert length(tracks_page_1) == 2
|
|
||||||
|
|
||||||
# Get next 2 tracks
|
|
||||||
tracks_page_2 = 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 = 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
|
|
||||||
|
|
||||||
defp list_tracks do
|
|
||||||
ScrobbleActivity.list_tracks()
|
|
||||||
|> Enum.map(fn r -> r.track end)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "count_tracks_missing_artist_musicbrainz_id/0" do
|
|
||||||
test "returns zero when no tracks exist" do
|
|
||||||
count = ScrobbleActivity.count_tracks_missing_artist_musicbrainz_id()
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns count of tracks with empty artist musicbrainz_id" do
|
|
||||||
track_fixture(%{artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_musicbrainz_id: "valid-id"})
|
|
||||||
|
|
||||||
count = ScrobbleActivity.count_tracks_missing_artist_musicbrainz_id()
|
|
||||||
|
|
||||||
assert count == 2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "count_tracks_missing_album_musicbrainz_id/0" do
|
|
||||||
test "returns zero when no tracks exist" do
|
|
||||||
count = ScrobbleActivity.count_tracks_missing_album_musicbrainz_id()
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns count of tracks with empty album musicbrainz_id" do
|
|
||||||
track_fixture(%{album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_musicbrainz_id: "valid-id"})
|
|
||||||
|
|
||||||
count = ScrobbleActivity.count_tracks_missing_album_musicbrainz_id()
|
|
||||||
|
|
||||||
assert count == 2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_artists_missing_musicbrainz_id/1" do
|
|
||||||
test "returns empty list when no tracks exist" do
|
|
||||||
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns artists grouped by name with track counts" do
|
|
||||||
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert length(result) == 2
|
|
||||||
|
|
||||||
artist_a = Enum.find(result, fn r -> r.artist_name == "Artist A" end)
|
|
||||||
artist_b = Enum.find(result, fn r -> r.artist_name == "Artist B" end)
|
|
||||||
|
|
||||||
assert artist_a.track_count == 2
|
|
||||||
assert artist_b.track_count == 1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "orders artists by track count descending" do
|
|
||||||
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert length(result) == 2
|
|
||||||
assert List.first(result).artist_name == "Artist B"
|
|
||||||
assert List.first(result).track_count == 3
|
|
||||||
end
|
|
||||||
|
|
||||||
test "limits results when limit option provided" do
|
|
||||||
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist C", artist_musicbrainz_id: ""})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_artists_missing_musicbrainz_id(limit: 2)
|
|
||||||
|
|
||||||
assert length(result) == 2
|
|
||||||
end
|
|
||||||
|
|
||||||
test "excludes artists with valid musicbrainz_id" do
|
|
||||||
track_fixture(%{artist_name: "Artist A", artist_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{artist_name: "Artist B", artist_musicbrainz_id: "valid-id"})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_artists_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert length(result) == 1
|
|
||||||
assert List.first(result).artist_name == "Artist A"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "get_albums_missing_musicbrainz_id/1" do
|
|
||||||
test "returns empty list when no tracks exist" do
|
|
||||||
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns albums grouped by title and artist with track counts" do
|
|
||||||
track_fixture(%{
|
|
||||||
album_title: "Album A",
|
|
||||||
artist_name: "Artist X",
|
|
||||||
album_musicbrainz_id: ""
|
|
||||||
})
|
|
||||||
|
|
||||||
track_fixture(%{
|
|
||||||
album_title: "Album A",
|
|
||||||
artist_name: "Artist X",
|
|
||||||
album_musicbrainz_id: ""
|
|
||||||
})
|
|
||||||
|
|
||||||
track_fixture(%{
|
|
||||||
album_title: "Album B",
|
|
||||||
artist_name: "Artist Y",
|
|
||||||
album_musicbrainz_id: ""
|
|
||||||
})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert length(result) == 2
|
|
||||||
|
|
||||||
album_a = Enum.find(result, fn r -> r.album_title == "Album A" end)
|
|
||||||
album_b = Enum.find(result, fn r -> r.album_title == "Album B" end)
|
|
||||||
|
|
||||||
assert album_a.track_count == 2
|
|
||||||
assert album_a.artist_name == "Artist X"
|
|
||||||
assert album_b.track_count == 1
|
|
||||||
assert album_b.artist_name == "Artist Y"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "orders albums by track count descending" do
|
|
||||||
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert length(result) == 2
|
|
||||||
assert List.first(result).album_title == "Album B"
|
|
||||||
assert List.first(result).track_count == 3
|
|
||||||
end
|
|
||||||
|
|
||||||
test "limits results when limit option provided" do
|
|
||||||
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_title: "Album B", album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_title: "Album C", album_musicbrainz_id: ""})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_albums_missing_musicbrainz_id(limit: 2)
|
|
||||||
|
|
||||||
assert length(result) == 2
|
|
||||||
end
|
|
||||||
|
|
||||||
test "excludes albums with valid musicbrainz_id" do
|
|
||||||
track_fixture(%{album_title: "Album A", album_musicbrainz_id: ""})
|
|
||||||
track_fixture(%{album_title: "Album B", album_musicbrainz_id: "valid-id"})
|
|
||||||
|
|
||||||
result = ScrobbleActivity.get_albums_missing_musicbrainz_id()
|
|
||||||
|
|
||||||
assert length(result) == 1
|
|
||||||
assert List.first(result).album_title == "Album A"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp list_tracks(params) do
|
|
||||||
ScrobbleActivity.list_tracks(params)
|
|
||||||
|> Enum.map(fn r -> r.track end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -4,7 +4,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
|
|||||||
import MusicLibrary.ScrobbledTracksFixtures
|
import MusicLibrary.ScrobbledTracksFixtures
|
||||||
import Phoenix.LiveViewTest, only: [render_submit: 1, render_change: 1, form: 3]
|
import Phoenix.LiveViewTest, only: [render_submit: 1, render_change: 1, form: 3]
|
||||||
|
|
||||||
alias MusicLibrary.ScrobbleActivity
|
alias MusicLibrary.ListeningStats
|
||||||
|
|
||||||
# Test data
|
# Test data
|
||||||
@invalid_track_attrs %{title: "", artist: %{name: ""}, album: %{title: ""}}
|
@invalid_track_attrs %{title: "", artist: %{name: ""}, album: %{title: ""}}
|
||||||
@@ -38,7 +38,7 @@ defmodule MusicLibraryWeb.ScrobbledTracksLiveTest do
|
|||||||
|
|
||||||
test "shows empty state when no tracks", %{conn: conn} do
|
test "shows empty state when no tracks", %{conn: conn} do
|
||||||
# Delete the created track
|
# Delete the created track
|
||||||
ScrobbleActivity.delete_track(track_fixture())
|
ListeningStats.delete_track(track_fixture())
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> visit(~p"/scrobbled-tracks")
|
|> visit(~p"/scrobbled-tracks")
|
||||||
|
|||||||
Reference in New Issue
Block a user