Files
music_library/lib/music_library/scrobble_activity.ex
T
Claudio Ortolina 36bc9266b3 Extract queries
2025-09-26 15:32:50 +03:00

472 lines
15 KiB
Elixir

defmodule MusicLibrary.ScrobbleActivity do
import Ecto.Query
alias LastFm.{Scrobble, Track}
alias MusicBrainz.Release
alias MusicLibrary.{Artists, Collection, Records.ArtistRecord, Repo, Secrets, Wishlist}
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 scobble 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
session_key = Secrets.get!("last_fm_session_key").value
{scrobbles, _finished_at} =
release_with_tracks
|> MusicBrainz.Release.tracks()
|> to_scrobbles(release_with_tracks, started_at)
LastFm.scrobble(scrobbles, session_key)
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 scobble 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
session_key = Secrets.get!("last_fm_session_key").value
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
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
def scrobble_count do
Repo.aggregate(Track, :count, :scrobbled_at_uts)
end
def recent_activity(timezone, limit \\ 100) do
# When we get recent tracks, we need to:
#
# - Map each track to a record in the collection (if it exists)
# - Map each track to a record in the wishlist (if it exists)
# - Map each track to an artist, knowing that sometimes track artists do
# not have the necessary information. In that case we can go from
# track -> album -> record -> artist
all_artists_query =
from ar in ArtistRecord,
distinct: true
tracks_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,
order_by: [desc: t.scrobbled_at_uts],
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 =
recent_tracks
|> Enum.map(fn %{track: track, artist_id: artist_id} = rt ->
%{rt | track: polifyll_track(track, timezone, artist_id)}
end)
recent_albums =
recent_tracks
|> Enum.dedup_by(fn %{track: track} -> track.album end)
|> Enum.map(fn %{track: track} = tr ->
tr
|> Map.delete(:track)
|> Map.put(
:album,
%{
scrobbled_at_uts: track.scrobbled_at_uts,
scrobbled_at_label: track.scrobbled_at_label,
metadata: track.album,
artist: track.artist,
cover_url: track.cover_url
}
)
end)
%{
recent_tracks: recent_tracks,
recent_albums: recent_albums
}
end
defp polifyll_track(track, timezone, artist_id) do
%{
track
| scrobbled_at_label: localize_scrobbled_at(track.scrobbled_at_uts, timezone),
artist: polyfill_artist(track.artist, artist_id)
}
end
defp polyfill_artist(artist, musicbrainz_id) do
if is_nil(artist.musicbrainz_id) or artist.musicbrainz_id == "" do
%{artist | musicbrainz_id: musicbrainz_id}
else
artist
end
end
def localize_scrobbled_at(uts, timezone) do
ldt =
uts
|> DateTime.from_unix!()
|> DateTime.shift_zone!(timezone)
Calendar.strftime(ldt, "%d/%m/%Y %X")
end
@doc """
Gets the top albums by scrobble count for the given number of days.
Returns a list of maps with album information and play counts.
"""
def get_top_albums_by_days(days, opts) do
limit = Keyword.get(opts, :limit, 10)
current_time = Keyword.get_lazy(opts, :current_time, &DateTime.utc_now/0)
timezone = Keyword.get(opts, :timezone, &MusicLibrary.default_timezone/0)
cutoff_timestamp =
current_time
|> DateTime.add(-days, :day)
|> NaiveDateTime.beginning_of_day()
|> DateTime.from_naive!(timezone)
|> DateTime.to_unix()
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),
where: t.scrobbled_at_uts >= ^cutoff_timestamp,
where: fragment("json_extract(album, '$.title') != ''"),
group_by: [
fragment("json_extract(album, '$.title')"),
fragment("json_extract(artist, '$.name')")
],
select: %{
album_title: fragment("json_extract(album, '$.title')"),
artist_name: fragment("json_extract(artist, '$.name')"),
artist_musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
play_count: count(t.scrobbled_at_uts),
cover_url: fragment("max(?)", t.cover_url),
album_musicbrainz_id: fragment("json_extract(album, '$.musicbrainz_id')"),
collected_record_id: cr.record_id,
wishlisted_record_id: wr.record_id,
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
},
order_by: [desc: count(t.scrobbled_at_uts)],
limit: ^limit
Repo.all(query)
end
@doc """
Gets the top albums by scrobble count across all time.
Returns a list of maps with album information and play counts.
"""
def get_top_albums(opts) do
limit = Keyword.get(opts, :limit, 10)
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),
where: fragment("json_extract(album, '$.title') != ''"),
group_by: [
fragment("json_extract(album, '$.title')"),
fragment("json_extract(artist, '$.name')")
],
select: %{
album_title: fragment("json_extract(album, '$.title')"),
artist_name: fragment("json_extract(artist, '$.name')"),
artist_musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
play_count: count(t.scrobbled_at_uts),
cover_url: fragment("max(?)", t.cover_url),
album_musicbrainz_id: fragment("json_extract(album, '$.musicbrainz_id')"),
collected_record_id: cr.record_id,
wishlisted_record_id: wr.record_id,
cover_hash: coalesce(cr.cover_hash, wr.cover_hash)
},
order_by: [desc: count(t.scrobbled_at_uts)],
limit: ^limit
Repo.all(query)
end
@doc """
Gets the top artists by scrobble count across all time.
Returns a list of maps with artist information and play counts.
"""
def get_top_artists(opts) do
limit = Keyword.get(opts, :limit, 10)
query =
from t in Track,
join: ai in Artists.ArtistInfo,
on: ai.id == fragment("json_extract(?, '$.musicbrainz_id')", t.artist),
group_by: [
fragment("json_extract(artist, '$.name')"),
fragment("json_extract(artist, '$.musicbrainz_id')")
],
select: %{
name: fragment("json_extract(artist, '$.name')"),
musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
image_hash: ai.image_data_hash,
play_count: count(t.scrobbled_at_uts)
},
order_by: [desc: count(t.scrobbled_at_uts)],
limit: ^limit
Repo.all(query)
end
@doc """
Gets the top artists by scrobble count for the given number of days.
Returns a list of maps with artist information and play counts.
"""
def get_top_artists_by_days(days, opts) do
limit = Keyword.get(opts, :limit, 10)
current_time = Keyword.get_lazy(opts, :current_time, &DateTime.utc_now/0)
timezone = Keyword.get(opts, :timezone, &MusicLibrary.default_timezone/0)
cutoff_timestamp =
current_time
|> DateTime.add(-days, :day)
|> NaiveDateTime.beginning_of_day()
|> DateTime.from_naive!(timezone)
|> DateTime.to_unix()
query =
from t in Track,
join: ai in Artists.ArtistInfo,
on: ai.id == fragment("json_extract(?, '$.musicbrainz_id')", t.artist),
where: t.scrobbled_at_uts >= ^cutoff_timestamp,
group_by: [
fragment("json_extract(artist, '$.name')"),
fragment("json_extract(artist, '$.musicbrainz_id')")
],
select: %{
name: fragment("json_extract(artist, '$.name')"),
musicbrainz_id: fragment("json_extract(artist, '$.musicbrainz_id')"),
image_hash: ai.image_data_hash,
play_count: count(t.scrobbled_at_uts)
},
order_by: [desc: count(t.scrobbled_at_uts)],
limit: ^limit
Repo.all(query)
end
@doc """
Gets top albums for the specified time periods (30, 90, 365 days) and all
time. Returns a map with the results, along with collected and wishlisted
releases.
"""
def get_top_albums_by_period(opts) do
case Keyword.get(opts, :period, :last_7_days) do
:all_time -> get_top_albums(opts)
:last_7_days -> get_top_albums_by_days(7, opts)
:last_30_days -> get_top_albums_by_days(30, opts)
:last_90_days -> get_top_albums_by_days(90, opts)
:last_365_days -> get_top_albums_by_days(365, opts)
end
end
@doc """
Gets top artists for a time period (30, 90, 365 days) and all time.
"""
def get_top_artists_by_period(opts) do
period = Keyword.get(opts, :period, :last_7_days)
case period do
:all_time -> get_top_artists(opts)
:last_7_days -> get_top_artists_by_days(7, opts)
:last_30_days -> get_top_artists_by_days(30, opts)
:last_90_days -> get_top_artists_by_days(90, opts)
: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