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, :distinct), 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, :distinct)], 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, :distinct), 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, :distinct)], 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, :distinct) }, order_by: [desc: count(t.scrobbled_at_uts, :distinct)], 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, :distinct) }, order_by: [desc: count(t.scrobbled_at_uts, :distinct)], 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) 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 """ 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