Files
music_library/lib/music_library/scrobble_activity.ex
T
2026-02-27 12:16:38 +00:00

426 lines
12 KiB
Elixir

defmodule MusicLibrary.ScrobbleActivity do
import Ecto.Query
alias LastFm.{Scrobble, Track}
alias MusicBrainz.Release
alias MusicLibrary.{Collection, Records.ArtistRecord, Repo, Secrets, Wishlist}
@pagination Application.compile_env!(:music_library, :pagination)
def can_scrobble? do
Secrets.get("last_fm_session_key") !== nil
end
def scrobble_release(release_with_tracks, opts) when is_list(opts) do
case Enum.sort(opts) do
[finished_at: _, started_at: _] ->
raise ArgumentError, """
Cannot scrobble a release with both started_at and finished_at.
Remove either of them.
"""
[started_at: started_at] ->
scrobble_release(release_with_tracks, {:started_at, started_at})
[finished_at: finished_at] ->
scrobble_release(release_with_tracks, {:finished_at, finished_at})
end
end
def scrobble_release(release_with_tracks, {:finished_at, finished_at}) do
release_duration = Release.release_duration(release_with_tracks)
started_at = DateTime.add(finished_at, -release_duration, :millisecond)
scrobble_release(release_with_tracks, {:started_at, started_at})
end
def scrobble_release(release_with_tracks, {:started_at, started_at}) do
release_duration = Release.release_duration(release_with_tracks)
if release_duration == 0 do
{:error, :no_duration}
else
with {:ok, session_key} <- fetch_session_key() do
{scrobbles, _finished_at} =
release_with_tracks
|> MusicBrainz.Release.tracks()
|> to_scrobbles(release_with_tracks, started_at)
LastFm.scrobble(scrobbles, session_key)
end
end
end
def scrobble_medium(number, release_with_tracks, opts) when is_list(opts) do
case Enum.sort(opts) do
[finished_at: _, started_at: _] ->
raise ArgumentError, """
Cannot scrobble a medium with both started_at and finished_at.
Remove either of them.
"""
[started_at: started_at] ->
scrobble_medium(number, release_with_tracks, {:started_at, started_at})
[finished_at: finished_at] ->
scrobble_medium(number, release_with_tracks, {:finished_at, finished_at})
end
end
def scrobble_medium(number, release_with_tracks, {:finished_at, finished_at}) do
medium_duration =
release_with_tracks.media
|> Enum.find(fn medium -> medium.number == number end)
|> Release.medium_duration()
started_at = DateTime.add(finished_at, -medium_duration, :millisecond)
scrobble_medium(number, release_with_tracks, {:started_at, started_at})
end
def scrobble_medium(number, release_with_tracks, {:started_at, started_at}) do
medium_duration =
release_with_tracks.media
|> Enum.find(fn medium -> medium.number == number end)
|> Release.medium_duration()
if medium_duration == 0 do
{:error, :no_duration}
else
with {:ok, session_key} <- fetch_session_key() do
medium =
release_with_tracks.media
|> Enum.find(fn medium -> medium.number == number end)
{scrobbles, _finished_at} =
medium.tracks
|> to_scrobbles(release_with_tracks, started_at)
LastFm.scrobble(scrobbles, session_key)
end
end
end
def scrobble_tracks(selected_track_ids, release_with_tracks, opts) when is_list(opts) do
case Enum.sort(opts) do
[finished_at: _, started_at: _] ->
raise ArgumentError, """
Cannot scrobble tracks with both started_at and finished_at.
Remove either of them.
"""
[started_at: started_at] ->
scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at})
[finished_at: finished_at] ->
scrobble_tracks(selected_track_ids, release_with_tracks, {:finished_at, finished_at})
end
end
def scrobble_tracks(selected_track_ids, release_with_tracks, {:finished_at, finished_at}) do
all_tracks = Release.tracks(release_with_tracks)
selected_tracks =
Enum.filter(all_tracks, fn track -> MapSet.member?(selected_track_ids, track.id) end)
tracks_duration = Enum.sum_by(selected_tracks, fn track -> track.length || 0 end)
started_at = DateTime.add(finished_at, -tracks_duration, :millisecond)
scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at})
end
def scrobble_tracks(selected_track_ids, release_with_tracks, {:started_at, started_at}) do
all_tracks = Release.tracks(release_with_tracks)
selected_tracks =
Enum.filter(all_tracks, fn track -> MapSet.member?(selected_track_ids, track.id) end)
tracks_duration = Enum.sum_by(selected_tracks, fn track -> track.length || 0 end)
if tracks_duration == 0 do
{:error, :no_duration}
else
with {:ok, session_key} <- fetch_session_key() do
{scrobbles, _finished_at} =
selected_tracks
|> to_scrobbles(release_with_tracks, started_at)
LastFm.scrobble(scrobbles, session_key)
end
end
end
defp fetch_session_key do
case Secrets.get("last_fm_session_key") do
%{value: value} -> {:ok, value}
nil -> {:error, :no_session_key}
end
end
defp to_scrobbles(tracks, release_with_tracks, started_at) do
tracks
|> Enum.map_reduce(started_at, fn track, time ->
album_artist =
if release_with_tracks.artists !== track.artists do
main_artist_name(release_with_tracks.artists)
end
time = time |> DateTime.add(track.length, :millisecond)
scrobble = %Scrobble{
artist: main_artist_name(track.artists),
album: release_with_tracks.title,
album_artist: album_artist,
track: track.title,
timestamp: DateTime.to_unix(time)
}
{scrobble, time}
end)
end
defp main_artist_name([]), do: nil
defp main_artist_name([artist | _rest]), do: artist.name
@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, @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
@doc """
Returns the total number of scrobbled tracks.
This counts all tracks stored in the database using their `scrobbled_at_uts` as the primary key.
## Examples
iex> MusicLibrary.ScrobbleActivity.count_tracks()
42
"""
def count_tracks do
Repo.aggregate(Track, :count, :scrobbled_at_uts)
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
@doc """
Counts tracks with missing or empty artist MusicBrainz IDs.
## Examples
iex> count_tracks_missing_artist_musicbrainz_id()
42
"""
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
@doc """
Counts tracks with missing or empty album MusicBrainz IDs.
## Examples
iex> count_tracks_missing_album_musicbrainz_id()
37
"""
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.
## Examples
iex> get_artists_missing_musicbrainz_id()
[%{artist_name: "Some Artist", track_count: 5}, ...]
"""
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.
## Examples
iex> get_albums_missing_musicbrainz_id()
[%{album_title: "Some Album", artist_name: "Some Artist", track_count: 3}, ...]
"""
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