484 lines
15 KiB
Elixir
484 lines
15 KiB
Elixir
defmodule MusicLibrary.ScrobbleActivity do
|
|
import Ecto.Query
|
|
|
|
alias LastFm.{Scrobble, Track}
|
|
alias MusicBrainz.Release
|
|
alias MusicLibrary.{Artists, Collection, 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 from_recent_tracks(recent_tracks, timezone) do
|
|
all_artist_pairs = Artists.get_all_artist_pairs()
|
|
recent_release_ids = recent_release_ids(recent_tracks)
|
|
collected_releases = Collection.collected_releases(recent_release_ids)
|
|
wishlisted_releases = Wishlist.wishlisted_releases(recent_release_ids)
|
|
|
|
localized_recent_tracks =
|
|
Enum.map(recent_tracks, fn t ->
|
|
%{
|
|
t
|
|
| scrobbled_at_label: localize_scrobbled_at(t.scrobbled_at_uts, timezone),
|
|
artist: polyfill_artist(t, collected_releases, wishlisted_releases, all_artist_pairs)
|
|
}
|
|
end)
|
|
|
|
all_artist_ids = Artists.get_all_artist_ids()
|
|
recent_artist_ids = recent_artist_ids(localized_recent_tracks)
|
|
artist_ids = MapSet.intersection(all_artist_ids, recent_artist_ids)
|
|
|
|
recent_albums =
|
|
localized_recent_tracks
|
|
|> Enum.dedup_by(fn t -> t.album end)
|
|
|> Enum.map(fn t ->
|
|
%{
|
|
scrobbled_at_uts: t.scrobbled_at_uts,
|
|
scrobbled_at_label: t.scrobbled_at_label,
|
|
metadata: t.album,
|
|
artist: t.artist,
|
|
cover_url: t.cover_url
|
|
}
|
|
end)
|
|
|
|
%{
|
|
localized_recent_tracks: localized_recent_tracks,
|
|
localized_recent_albums: recent_albums,
|
|
collected_releases: collected_releases,
|
|
wishlisted_releases: wishlisted_releases,
|
|
artist_ids: artist_ids
|
|
}
|
|
end
|
|
|
|
defp polyfill_artist(track, collected_releases, wishlisted_releases, all_artist_pairs) do
|
|
%{
|
|
track.artist
|
|
| musicbrainz_id:
|
|
find_artist_id(track, collected_releases, wishlisted_releases, all_artist_pairs)
|
|
}
|
|
end
|
|
|
|
defguardp has_no_artist_id(track)
|
|
when is_nil(track.artist.musicbrainz_id) or track.artist.musicbrainz_id == ""
|
|
|
|
defp find_artist_id(track, collected_releases, wishlisted_releases, all_artist_pairs)
|
|
when has_no_artist_id(track) do
|
|
matched_release =
|
|
Enum.find(collected_releases ++ wishlisted_releases, fn r ->
|
|
r.release_id == track.album.musicbrainz_id
|
|
end)
|
|
|
|
record_id = if matched_release, do: matched_release.record_id
|
|
|
|
if record_id do
|
|
Enum.find_value(all_artist_pairs, fn pair ->
|
|
if pair.record_id == record_id, do: pair.artist_id
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp find_artist_id(track, _collected_releases, _wishlisted_releases, _all_artist_pairs) do
|
|
track.artist.musicbrainz_id
|
|
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
|
|
|
|
defp recent_release_ids(recent_tracks) do
|
|
recent_tracks
|
|
|> Enum.map(fn t -> t.album.musicbrainz_id end)
|
|
|> Enum.uniq()
|
|
|> Enum.reject(fn musicbrainz_id -> musicbrainz_id == "" end)
|
|
end
|
|
|
|
defp recent_artist_ids(recent_tracks) do
|
|
recent_tracks
|
|
|> Enum.map(fn t -> t.artist.musicbrainz_id end)
|
|
|> Enum.uniq()
|
|
|> Enum.reject(fn musicbrainz_id -> musicbrainz_id == "" end)
|
|
|> MapSet.new()
|
|
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,
|
|
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')")
|
|
},
|
|
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,
|
|
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')")
|
|
},
|
|
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
|
|
period = Keyword.get(opts, :period, :last_7_days)
|
|
|
|
albums =
|
|
case period 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
|
|
|
|
all_album_ids =
|
|
albums
|
|
|> Enum.map(fn t -> t.album_musicbrainz_id end)
|
|
|> Enum.uniq()
|
|
|> Enum.reject(fn musicbrainz_id -> musicbrainz_id == "" end)
|
|
|
|
collected_releases = Collection.collected_releases(all_album_ids)
|
|
wishlisted_releases = Wishlist.wishlisted_releases(all_album_ids)
|
|
|
|
%{
|
|
collected_releases: collected_releases,
|
|
wishlisted_releases: wishlisted_releases,
|
|
albums: albums
|
|
}
|
|
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
|